diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ec3d850 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,26 @@ +# Pull Request + +## Related Issue + + +## Description of Changes + + +## Type of Change + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] UI/UX Improvement (Visual or interaction changes) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Refactor (code restructuring without changing external behavior) +- [ ] Documentation update + +## Checklist +- [ ] Component usage checked +- [ ] Responsive design verified +- [ ] Unit tests added/updated +- [ ] Code builds successfully +- [ ] Linter checks pass + +## Special Notes + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a3094c2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: Web CI + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + - 'feature/*' + +jobs: + check-web: + name: Check Web Code + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run Linter + run: npm run lint + + - name: Build + run: npm run build + + - name: Run Tests + run: npm run test diff --git a/src/components/auth/LoginPage.tsx b/src/components/auth/LoginPage.tsx index 6aa5dc7..d62786b 100644 --- a/src/components/auth/LoginPage.tsx +++ b/src/components/auth/LoginPage.tsx @@ -4,12 +4,11 @@ import React, { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useGlobal } from '@/context/GlobalContext'; import { useLogin } from '@/lib/hooks'; -import { useTranslation, Trans } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; import { Turnstile } from '@marsidev/react-turnstile'; import { Wallet, CheckCircle2, - Sparkles, Mail, Lock, MessageCircle, diff --git a/src/components/features/Accounts.tsx b/src/components/features/Accounts.tsx index 7adfbea..e30209e 100644 --- a/src/components/features/Accounts.tsx +++ b/src/components/features/Accounts.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useState, useMemo } from 'react'; -import { useAllAccountsSuspense, AccountType, Account } from '@/lib/hooks'; +import { useAllAccountsSuspense, Account } from '@/lib/hooks'; import { useTranslation } from 'react-i18next'; import { ACCOUNT_TYPES, EXCHANGE_RATES } from '@/lib/data'; import { @@ -26,14 +26,14 @@ const Accounts = () => { const [activeTab, setActiveTab] = useState('ALL'); const [page, setPage] = useState(1); const [searchQuery, setSearchQuery] = useState(''); - const [editAccount, setEditAccount] = useState(null); + const [editAccount, setEditAccount] = useState(null); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const itemsPerPage = 10; const formatCurrency = (amount: number, currency = 'CNY') => { try { return new Intl.NumberFormat('zh-CN', { style: 'currency', currency }).format(amount); - } catch (e) { + } catch { return `${currency} ${amount.toFixed(2)}`; } }; @@ -73,7 +73,7 @@ const Accounts = () => { { id: 'EXPENSE', label: t('common:expense') } ]; - const AccountRow = ({ account, isChild = false, groupBalance, hasChildren = false }: { account: any, isChild?: boolean, groupBalance?: number, hasChildren?: boolean }) => { + const AccountRow = ({ account, isChild = false, groupBalance, hasChildren = false }: { account: Account, isChild?: boolean, groupBalance?: number, hasChildren?: boolean }) => { const TypeIcon = ACCOUNT_TYPES[account.type].icon; const typeMeta = ACCOUNT_TYPES[account.type]; @@ -141,7 +141,7 @@ const Accounts = () => { ); }; - const renderAccountCard = (parentAccount: any) => { + const renderAccountCard = (parentAccount: Account) => { const children = accounts.filter(a => a.parentId === parentAccount.id); const groupBalance = children.reduce((sum, child) => { const rate = EXCHANGE_RATES[child.currency] || 1; diff --git a/src/components/features/AddAccountModal.tsx b/src/components/features/AddAccountModal.tsx index 5afcc8d..279af99 100644 --- a/src/components/features/AddAccountModal.tsx +++ b/src/components/features/AddAccountModal.tsx @@ -22,7 +22,6 @@ import { } from '@/components/ui/select'; import { Checkbox } from '@/components/ui/checkbox'; import { Plus, Trash2, CornerDownRight, Crown } from 'lucide-react'; -import { Separator } from '@/components/ui/separator'; import { toast } from "sonner"; import { Tooltip, @@ -54,7 +53,7 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => { const [balance, setBalance] = useState('0'); // Group account state - const [children, setChildren] = useState([ + const [children, setChildren] = useState(() => [ { id: Date.now().toString(), name: '', currency: 'CNY', balance: '0', isDefault: true } ]); @@ -91,7 +90,7 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => { } }; - const handleChildChange = (id: string, field: string, value: any) => { + const handleChildChange = (id: string, field: string, value: string) => { setChildren(children.map(c => { if (c.id === id) { return { ...c, [field]: value }; @@ -167,13 +166,12 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => { if (!saveAndContinue) { onClose(); } - } catch (error) { + } catch { // Error is already handled by the hook with toast } }; const isPro = user.plan === 'PRO'; - const canBeGroup = isPro && (type === AccountType.ASSET || type === AccountType.LIABILITY); const isPending = createAccount.isPending; return ( @@ -311,7 +309,7 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => {
- {children.map((child, index) => ( + {children.map((child) => (
diff --git a/src/components/features/Dashboard.tsx b/src/components/features/Dashboard.tsx index 70fec42..8763066 100644 --- a/src/components/features/Dashboard.tsx +++ b/src/components/features/Dashboard.tsx @@ -20,7 +20,7 @@ const Dashboard = () => { try { const locale = currency === 'CNY' ? 'zh-CN' : 'en-US'; return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount); - } catch (e) { + } catch { return `${currency} ${amount.toFixed(2)}`; } }; diff --git a/src/components/features/EditAccountModal.tsx b/src/components/features/EditAccountModal.tsx index d7361d3..279cc64 100644 --- a/src/components/features/EditAccountModal.tsx +++ b/src/components/features/EditAccountModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useGlobal } from '@/context/GlobalContext'; import { useUpdateAccount, useDeleteAccount, useCreateAccount, useAllAccounts, AccountType, Account } from '@/lib/hooks'; @@ -23,13 +23,21 @@ import { import { Plus, Trash2, CornerDownRight, AlertTriangle } from 'lucide-react'; import { toast } from "sonner"; -interface EditAccountModalProps { - isOpen: boolean; +interface EditAccountFormProps { + account: Account; onClose: () => void; - account: Account | null; } -const EditAccountModal = ({ isOpen, onClose, account }: EditAccountModalProps) => { +interface ChildAccount { + id: string; + name: string; + currency: string; + balance: string; + isDefault?: boolean; + isNew?: boolean; +} + +const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => { const { t } = useTranslation(['accounts', 'common']); const { currencies } = useGlobal(); const { accounts } = useAllAccounts(); @@ -37,42 +45,33 @@ const EditAccountModal = ({ isOpen, onClose, account }: EditAccountModalProps) = const deleteAccountMutation = useDeleteAccount(); const createAccountMutation = useCreateAccount(); - const [name, setName] = useState(''); - const [date, setDate] = useState(''); - const [number, setNumber] = useState(''); - const [remarks, setRemarks] = useState(''); - const [balance, setBalance] = useState('0'); - const [currency, setCurrency] = useState('CNY'); + const [name, setName] = useState(account.name); + const [date, setDate] = useState(account.date || new Date().toISOString().split('T')[0]); + const [number, setNumber] = useState(account.number || ''); + const [remarks, setRemarks] = useState(account.remarks || ''); + const [balance, setBalance] = useState(account.balance?.toString() || '0'); + const [currency, setCurrency] = useState(account.currency || 'CNY'); // Group account state - const [children, setChildren] = useState([]); + const isGroup = account.isGroup; + const [children, setChildren] = useState(() => { + if (isGroup) { + const accountChildren = accounts.filter(a => a.parentId === account.id); + return accountChildren.map(c => ({ + id: c.id, + name: c.name, + currency: c.currency, + balance: c.balance.toString(), + isDefault: false, + isNew: false + })); + } + return []; + }); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); const [migrationTargets, setMigrationTargets] = useState>({}); - useEffect(() => { - if (isOpen && account) { - setName(account.name); - setDate(account.date || new Date().toISOString().split('T')[0]); - setNumber(account.number || ''); - setRemarks(account.remarks || ''); - setBalance(account.balance?.toString() || '0'); - setCurrency(account.currency || 'CNY'); - - if (account.isGroup) { - const accountChildren = accounts.filter(a => a.parentId === account.id); - setChildren(accountChildren.map(c => ({ - ...c, - balance: c.balance.toString() - }))); - } else { - setChildren([]); - } - } - }, [isOpen, account, accounts]); - - const isGroup = account?.isGroup; - const handleAddChild = () => { setChildren([...children, { id: `new_${Date.now()}`, @@ -93,7 +92,7 @@ const EditAccountModal = ({ isOpen, onClose, account }: EditAccountModalProps) = } }; - const handleChildChange = (id: string, field: string, value: any) => { + const handleChildChange = (id: string, field: keyof ChildAccount, value: string | boolean) => { setChildren(children.map(c => { if (c.id === id) { return { ...c, [field]: value }; @@ -103,8 +102,6 @@ const EditAccountModal = ({ isOpen, onClose, account }: EditAccountModalProps) = }; const handleSaveWithChildren = async () => { - if (!account) return; - try { // Update Parent await updateAccountMutation.mutateAsync({ @@ -142,13 +139,12 @@ const EditAccountModal = ({ isOpen, onClose, account }: EditAccountModalProps) = } onClose(); - } catch (error) { + } catch { // Error is already handled by the hook with toast } }; const getAvailableTargets = (curr: string) => { - if (!account) return []; let accountsToDeleteIds = [account.id]; if (isGroup) { accountsToDeleteIds = [...accountsToDeleteIds, ...accounts.filter(a => a.parentId === account.id).map(a => a.id)]; @@ -164,14 +160,10 @@ const EditAccountModal = ({ isOpen, onClose, account }: EditAccountModalProps) = const prepareDelete = () => { setMigrationTargets({}); - - if (!account) return; setIsDeleteAlertOpen(true); }; const handleDeleteConfirm = async () => { - if (!account) return; - try { // For group accounts, the backend handles children deletion as part of the task // Just create one migration task that includes all child accounts @@ -182,7 +174,7 @@ const EditAccountModal = ({ isOpen, onClose, account }: EditAccountModalProps) = setIsDeleteAlertOpen(false); onClose(); - } catch (error) { + } catch { // Error is already handled by the hook with toast } }; @@ -192,12 +184,12 @@ const EditAccountModal = ({ isOpen, onClose, account }: EditAccountModalProps) = createAccountMutation.isPending; // Calculate if we have any blocked currencies (no targets available) - const requiredCurrencies = account ? Object.keys( + const requiredCurrencies = Object.keys( [account, ...(isGroup ? children : [])].reduce((acc, curr) => { if (curr && curr.currency) acc[curr.currency] = true; return acc; }, {} as Record) - ) : []; + ); const blockedCurrencies = requiredCurrencies.filter(curr => getAvailableTargets(curr).length === 0); const hasBlockedCurrencies = blockedCurrencies.length > 0; @@ -209,123 +201,115 @@ const EditAccountModal = ({ isOpen, onClose, account }: EditAccountModalProps) = return ( <> - - - - {t('accounts:edit_account')} - +
+
+
+
+ + setName(e.target.value)} /> +
+
+ + setDate(e.target.value)} /> +
+
-
-
-
-
- - setName(e.target.value)} /> -
-
- - setDate(e.target.value)} /> -
+ {!isGroup && ( +
+
+ +
- - {!isGroup && ( -
-
- - -
-
- - setBalance(e.target.value)} - disabled={account?.type === 'EXPENSE' || account?.type === 'INCOME'} - /> -
-
- )} - -
-
- - setNumber(e.target.value)} /> -
-
- - setRemarks(e.target.value)} /> -
+
+ + setBalance(e.target.value)} + disabled={account?.type === 'EXPENSE' || account?.type === 'INCOME'} + />
+ )} - {isGroup && ( -
-
- - -
+
+
+ + setNumber(e.target.value)} /> +
+
+ + setRemarks(e.target.value)} /> +
+
+
-
- {children.map((child, index) => ( -
-
-
-
- handleChildChange(child.id, 'name', e.target.value)} - /> -
-
- -
-
- handleChildChange(child.id, 'balance', e.target.value)} - /> -
-
- {child.isNew && ( - - )} -
-
+ {isGroup && ( +
+
+ + +
+ +
+ {children.map((child) => ( +
+
+
+
+ handleChildChange(child.id, 'name', e.target.value)} + /> +
+
+
- ))} +
+ handleChildChange(child.id, 'balance', e.target.value)} + /> +
+
+ {child.isNew && ( + + )} +
+
-
- )} -
- - - -
- - + ))}
-
- -
+
+ )} +
+ + + +
+ + +
+
@@ -394,4 +378,25 @@ const EditAccountModal = ({ isOpen, onClose, account }: EditAccountModalProps) = ); }; +interface EditAccountModalProps { + isOpen: boolean; + onClose: () => void; + account: Account | null; +} + +const EditAccountModal = ({ isOpen, onClose, account }: EditAccountModalProps) => { + const { t } = useTranslation(['accounts', 'common']); + + return ( + + + + {t('accounts:edit_account')} + + {account && } + + + ); +}; + export default EditAccountModal; diff --git a/src/components/features/Settings.tsx b/src/components/features/Settings.tsx index 03305f8..f3bea4e 100644 --- a/src/components/features/Settings.tsx +++ b/src/components/features/Settings.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useEffect, useRef } from 'react'; +import React from 'react'; import { useGlobal } from '@/context/GlobalContext'; import { UserProfile } from './settings/UserProfile'; import { Subscription } from './settings/Subscription'; @@ -8,30 +8,18 @@ import { CurrencySettings } from './settings/CurrencySettings'; import { ThemeSettings } from './settings/ThemeSettings'; import { LanguageSettings } from './settings/LanguageSettings'; import { MainSettings } from './settings/MainSettings'; -import { SettingsView } from '@/context/GlobalContext'; const Settings = () => { - const { settingsView } = useGlobal(); - const [view, setView] = useState(settingsView); - const prevSettingsViewRef = useRef(settingsView); - - // Sync global state to local view - useEffect(() => { - // Only sync when global state is changed externally (avoid triggering when switching views internally) - if (settingsView !== prevSettingsViewRef.current) { - setView(settingsView); - prevSettingsViewRef.current = settingsView; - } - }, [settingsView]); + const { settingsView, setSettingsView } = useGlobal(); return (
- {view === 'MAIN' && } - {view === 'PROFILE' && setView('MAIN')} />} - {view === 'SUBSCRIPTION' && setView('MAIN')} />} - {view === 'CURRENCY' && setView('MAIN')} onUpgrade={() => setView('SUBSCRIPTION')} />} - {view === 'THEME' && setView('MAIN')} onUpgrade={() => setView('SUBSCRIPTION')} />} - {view === 'LANGUAGE' && setView('MAIN')} />} + {settingsView === 'MAIN' && } + {settingsView === 'PROFILE' && setSettingsView('MAIN')} />} + {settingsView === 'SUBSCRIPTION' && setSettingsView('MAIN')} />} + {settingsView === 'CURRENCY' && setSettingsView('MAIN')} onUpgrade={() => setSettingsView('SUBSCRIPTION')} />} + {settingsView === 'THEME' && setSettingsView('MAIN')} onUpgrade={() => setSettingsView('SUBSCRIPTION')} />} + {settingsView === 'LANGUAGE' && setSettingsView('MAIN')} />}
); }; diff --git a/src/components/features/TaskCenterModal.tsx b/src/components/features/TaskCenterModal.tsx index ea91862..7f43809 100644 --- a/src/components/features/TaskCenterModal.tsx +++ b/src/components/features/TaskCenterModal.tsx @@ -61,7 +61,7 @@ const TaskCenterModal = () => { const cancelMutation = useCancelTask(); const retryMutation = useRetryTask(); const { accounts } = useAllAccounts(); - const tasks = tasksData?.data || []; + const tasks = useMemo(() => tasksData?.data || [], [tasksData?.data]); // Filter states const [typeFilter, setTypeFilter] = useState('all'); diff --git a/src/components/features/Transactions.tsx b/src/components/features/Transactions.tsx index 5ea6544..b6f9536 100644 --- a/src/components/features/Transactions.tsx +++ b/src/components/features/Transactions.tsx @@ -1,8 +1,7 @@ 'use client'; import React, { useState } from 'react'; -import { useAllTransactionsSuspense, useAllAccountsSuspense, useCreateTransaction, useUpdateTransaction, useDeleteTransaction, useCreateAccount, TransactionType, AccountType } from '@/lib/hooks'; -import { useGlobal } from '@/context/GlobalContext'; +import { useAllTransactionsSuspense, useAllAccountsSuspense, useCreateTransaction, useUpdateTransaction, useDeleteTransaction, useCreateAccount, TransactionType, AccountType, Account, Transaction } from '@/lib/hooks'; import { useTranslation } from 'react-i18next'; import { Plus, ArrowRightLeft } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -61,7 +60,7 @@ const Transactions = () => { const formatCurrency = (amount: number, currency = 'CNY') => { try { return new Intl.NumberFormat('zh-CN', { style: 'currency', currency }).format(amount); - } catch (e) { + } catch { return `${currency} ${amount.toFixed(2)}`; } }; @@ -71,14 +70,14 @@ const Transactions = () => { return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: currencyCode }) .formatToParts(0) .find(part => part.type === 'currency')?.value || currencyCode; - } catch (e) { + } catch { return currencyCode; } }; const currentCurrency = accounts.find(a => a.id === newTx.from)?.currency || 'CNY'; - const getFullAccountName = (account: any, allAccounts: any[]) => { + const getFullAccountName = (account: Account, allAccounts: Account[]) => { if (account.parentId) { const parent = allAccounts.find(a => a.id === account.parentId); return parent ? `${parent.name} - ${account.name}` : account.name; @@ -114,7 +113,7 @@ const Transactions = () => { const minutes = String(d.getMinutes()).padStart(2, '0'); const seconds = String(d.getSeconds()).padStart(2, '0'); return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`; - } catch (e) { + } catch { return dateStr; } }; @@ -133,7 +132,7 @@ const Transactions = () => { second: '2-digit', hour12: false }).format(d).replace(/\//g, '-'); - } catch (e) { + } catch { return dateStr; } }; @@ -213,7 +212,7 @@ const Transactions = () => { setShowAddModal(false); resetForm(); - } catch (error) { + } catch { // Error is already handled by the hook with toast } }; @@ -228,7 +227,7 @@ const Transactions = () => { if (val === 'NEW_EXPENSE') setIsCreatingExpense(true); else setIsCreatingExpense(false); }; - const handleEdit = (tx: any) => { + const handleEdit = (tx: Transaction) => { setNewTx({ amount: tx.amount.toString(), note: tx.note, @@ -255,13 +254,13 @@ const Transactions = () => { setShowAddModal(false); resetForm(); } - } catch (error) { + } catch { // Error is already handled by the hook with toast } } }; - const renderAccountOptions = (filterFn: (a: any) => boolean) => { + const renderAccountOptions = (filterFn: (a: Account) => boolean) => { return accounts.filter(filterFn).map(a => { if (a.isGroup) return null; const label = getFullAccountName(a, accounts); diff --git a/src/components/features/settings/MainSettings.tsx b/src/components/features/settings/MainSettings.tsx index 5ea2cf4..a458976 100644 --- a/src/components/features/settings/MainSettings.tsx +++ b/src/components/features/settings/MainSettings.tsx @@ -18,11 +18,7 @@ import { Card, CardContent } from '@/components/ui/card'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { SettingsView } from '@/context/GlobalContext'; -interface MainSettingsProps { - onNavigate: (view: SettingsView) => void; -} - -export const MainSettings = ({ onNavigate }: { onNavigate: (view: any) => void }) => { +export const MainSettings = ({ onNavigate }: { onNavigate: (view: SettingsView) => void }) => { const { t } = useTranslation(['settings', 'common']); const router = useRouter(); const { user, currentTheme, currencies, openTaskCenter } = useGlobal(); diff --git a/src/components/features/settings/Subscription.tsx b/src/components/features/settings/Subscription.tsx index 5b13a9b..2fbab10 100644 --- a/src/components/features/settings/Subscription.tsx +++ b/src/components/features/settings/Subscription.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useGlobal } from '@/context/GlobalContext'; -import { ChevronLeft, Check, Sparkles } from 'lucide-react'; +import { ChevronLeft, Check } from 'lucide-react'; import { Button } from '@/components/ui/button'; export const Subscription = ({ onBack }: { onBack: () => void }) => { diff --git a/src/components/features/settings/UserProfile.tsx b/src/components/features/settings/UserProfile.tsx index 596df5d..ee60cd4 100644 --- a/src/components/features/settings/UserProfile.tsx +++ b/src/components/features/settings/UserProfile.tsx @@ -29,7 +29,7 @@ export const UserProfile = ({ onBack }: { onBack: () => void }) => { const [nickname, setNickname] = useState(user.nickname); const [show2FA, setShow2FA] = useState(false); const [qrUrl, setQrUrl] = useState(''); - const [secret, setSecret] = useState(''); + const [, setSecret] = useState(''); const [code, setCode] = useState(''); const [password, setPassword] = useState(''); const [showPasswordModal, setShowPasswordModal] = useState(false); @@ -45,7 +45,7 @@ export const UserProfile = ({ onBack }: { onBack: () => void }) => { setQrUrl(data.url); setSecret(data.secret); setShow2FA(true); - } catch (e: any) { + } catch (e: unknown) { console.error(e); // Error handled by hook } @@ -61,7 +61,7 @@ export const UserProfile = ({ onBack }: { onBack: () => void }) => { updateUser({ twoFactorEnabled: true }); setShow2FA(false); setCode(''); - } catch (e: any) { + } catch (e: unknown) { console.error(e); // Error handled by hook } @@ -76,7 +76,7 @@ export const UserProfile = ({ onBack }: { onBack: () => void }) => { setShowDisable2FAModal(false); setCode(''); setPassword(''); - } catch (e: any) { + } catch (e: unknown) { console.error(e); // Error handled by hook } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 9cf8c52..fb4db74 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -9,8 +9,7 @@ import { LayoutDashboard, Wallet, ArrowRightLeft, - Settings, - LogOut + Settings } from 'lucide-react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; @@ -18,7 +17,7 @@ import { Button } from '@/components/ui/button'; const Sidebar = () => { const pathname = usePathname(); const router = useRouter(); - const { user, logout, setSettingsView } = useGlobal(); + const { user, setSettingsView } = useGlobal(); const { t } = useTranslation('common'); const navItems = [ diff --git a/src/context/GlobalContext.tsx b/src/context/GlobalContext.tsx index 8bec988..eea7c81 100644 --- a/src/context/GlobalContext.tsx +++ b/src/context/GlobalContext.tsx @@ -73,7 +73,7 @@ export const GlobalProvider = ({ children }: { children: React.ReactNode }) => { // Check authentication status on mount useEffect(() => { const checkAuth = async () => { - let token = localStorage.getItem('token'); + const token = localStorage.getItem('token'); const storedState = localStorage.getItem('gaap_state'); // Restore non-sensitive state from local storage first (preferences, etc) @@ -98,7 +98,7 @@ export const GlobalProvider = ({ children }: { children: React.ReactNode }) => { try { // Validate token by fetching profile - const data = await apiRequest('/api/user/profile'); + const data = await apiRequest<{ user: User }>('/api/user/profile'); if (data && data.user) { setUser(data.user); @@ -120,7 +120,7 @@ export const GlobalProvider = ({ children }: { children: React.ReactNode }) => { } try { - const refreshData = await apiRequest('/api/auth/refresh', { + const refreshData = await apiRequest<{ accessToken: string; refreshToken?: string }>('/api/auth/refresh', { method: 'POST', body: JSON.stringify({ refreshToken }) }); @@ -133,7 +133,7 @@ export const GlobalProvider = ({ children }: { children: React.ReactNode }) => { } // Retry profile fetch with new token - const userData = await apiRequest('/api/user/profile'); + const userData = await apiRequest<{ user: User }>('/api/user/profile'); if (userData && userData.user) { setUser(userData.user); setIsLoggedIn(true); diff --git a/src/context/__tests__/GlobalContext.test.tsx b/src/context/__tests__/GlobalContext.test.tsx index 9473946..f9ab49c 100644 --- a/src/context/__tests__/GlobalContext.test.tsx +++ b/src/context/__tests__/GlobalContext.test.tsx @@ -12,7 +12,7 @@ const TestComponent = () => { }; // Helper to create mock response -const createMockResponse = (data: any, code = 0, status = 200) => ({ +const createMockResponse = (data: unknown, code = 0, status = 200) => ({ ok: status >= 200 && status < 300, status, headers: { @@ -64,8 +64,8 @@ describe('GlobalContext Authentication', () => { localStorageMock.setItem('token', 'valid-token'); // Mock successful profile fetch - (global.fetch as any).mockResolvedValueOnce( - createMockResponse({ user: { email: 'test@example.com', nickname: 'TestUser', plan: 'FREE' } }) + vi.mocked(global.fetch).mockResolvedValueOnce( + createMockResponse({ user: { email: 'test@example.com', nickname: 'TestUser', plan: 'FREE' } }) as Response ); await act(async () => { @@ -90,15 +90,15 @@ describe('GlobalContext Authentication', () => { localStorageMock.setItem('refreshToken', 'valid-refresh-token'); // 1. Profile fetch -> 401 (business error code) - (global.fetch as any) - .mockResolvedValueOnce(createMockResponse(null, 401)) + vi.mocked(global.fetch) + .mockResolvedValueOnce(createMockResponse(null, 401) as Response) // 2. Refresh fetch -> 200 .mockResolvedValueOnce( - createMockResponse({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }) + createMockResponse({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }) as Response ) // 3. Retry profile fetch -> 200 .mockResolvedValueOnce( - createMockResponse({ user: { email: 'test@example.com', nickname: 'RefreshedUser', plan: 'PRO' } }) + createMockResponse({ user: { email: 'test@example.com', nickname: 'RefreshedUser', plan: 'PRO' } }) as Response ); await act(async () => { @@ -123,10 +123,10 @@ describe('GlobalContext Authentication', () => { localStorageMock.setItem('refreshToken', 'bad-refresh-token'); // 1. Profile fetch -> 401 - (global.fetch as any) - .mockResolvedValueOnce(createMockResponse(null, 401)) + vi.mocked(global.fetch) + .mockResolvedValueOnce(createMockResponse(null, 401) as Response) // 2. Refresh fetch -> 401 - .mockResolvedValueOnce(createMockResponse(null, 401)); + .mockResolvedValueOnce(createMockResponse(null, 401) as Response); await act(async () => { render( diff --git a/src/lib/api.ts b/src/lib/api.ts index fd9c239..1159533 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,6 +1,4 @@ -import { toast } from 'sonner'; - -export interface ApiResponse { +export interface ApiResponse { code: number; message: string; data: T; @@ -8,9 +6,9 @@ export interface ApiResponse { export class ApiError extends Error { code: number; - data: any; + data: unknown; - constructor(message: string, code: number, data?: any) { + constructor(message: string, code: number, data?: unknown) { super(message); this.name = 'ApiError'; this.code = code; @@ -84,7 +82,7 @@ async function refreshAccessToken(): Promise { } } -async function apiRequest(url: string, options: RequestInit = {}): Promise { +async function apiRequest(url: string, options: RequestInit = {}): Promise { const makeRequest = async (token: string | null): Promise => { const headers: Record = { 'Content-Type': 'application/json', diff --git a/src/lib/data.ts b/src/lib/data.ts index 3f89d3c..44fd3f1 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -2,7 +2,8 @@ import { Building2, CreditCard, Briefcase, - Receipt + Receipt, + LucideProps } from 'lucide-react'; // Simulated exchange rates (relative to CNY) @@ -94,7 +95,7 @@ export const THEMES = [ ]; // Account type definitions -export const ACCOUNT_TYPES: Record = { +export const ACCOUNT_TYPES: Record }> = { ASSET: { label: 'Assets', color: 'text-emerald-600', bg: 'bg-emerald-100', icon: Building2 }, LIABILITY: { label: 'Liabilities', color: 'text-red-600', bg: 'bg-red-100', icon: CreditCard }, INCOME: { label: 'Income', color: 'text-blue-600', bg: 'bg-blue-100', icon: Briefcase }, diff --git a/src/lib/hooks/useAccounts.ts b/src/lib/hooks/useAccounts.ts index f6ccfaf..dc1e971 100644 --- a/src/lib/hooks/useAccounts.ts +++ b/src/lib/hooks/useAccounts.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; import { accountService } from '../services'; -import { Account, AccountInput, AccountQuery } from '../types'; +import { AccountInput, AccountQuery } from '../types'; import { toast } from 'sonner'; // Query Keys diff --git a/src/lib/hooks/useAuth.test.tsx b/src/lib/hooks/useAuth.test.tsx index f8eec3b..06dc772 100644 --- a/src/lib/hooks/useAuth.test.tsx +++ b/src/lib/hooks/useAuth.test.tsx @@ -156,7 +156,7 @@ describe('useLogout', () => { it('should logout and clear tokens', async () => { vi.mocked(authService.logout).mockResolvedValue(); - const { Wrapper, queryClient } = createWrapper(); + const { Wrapper } = createWrapper(); const { result } = renderHook(() => useLogout(), { wrapper: Wrapper }); result.current.mutate(); diff --git a/src/lib/hooks/useTaskNotifications.ts b/src/lib/hooks/useTaskNotifications.ts index 6ede078..0528ae8 100644 --- a/src/lib/hooks/useTaskNotifications.ts +++ b/src/lib/hooks/useTaskNotifications.ts @@ -1,9 +1,9 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; -import { useWebSocket, WebSocketMessage } from './useWebSocket'; +import { useWebSocket } from './useWebSocket'; import { accountKeys } from './useAccounts'; import { transactionKeys } from './useTransactions'; import { useGlobal } from '@/context/GlobalContext'; @@ -21,13 +21,13 @@ export function useTaskNotifications() { const { lastMessage, status } = useWebSocket(); // Handle open task center (refresh data when opening) - const handleOpenTaskCenter = () => { + const handleOpenTaskCenter = useCallback(() => { openTaskCenter(); queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); - }; + }, [openTaskCenter, queryClient]); // Refresh related data based on task type - const refreshRelatedData = (taskType: string) => { + const refreshRelatedData = useCallback((taskType: string) => { if (taskType === 'ACCOUNT_MIGRATION') { // Refresh accounts and transactions after account migration queryClient.invalidateQueries({ queryKey: accountKeys.lists() }); @@ -35,7 +35,7 @@ export function useTaskNotifications() { } // Always refresh task list queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); - }; + }, [queryClient]); // Handle WebSocket messages useEffect(() => { @@ -68,7 +68,7 @@ export function useTaskNotifications() { refreshRelatedData(taskType); } } - }, [lastMessage, isLoggedIn, t, queryClient]); + }, [lastMessage, isLoggedIn, t, handleOpenTaskCenter, refreshRelatedData]); return { openTaskCenter: handleOpenTaskCenter, diff --git a/src/lib/hooks/useTransactions.ts b/src/lib/hooks/useTransactions.ts index 8048e54..5998c33 100644 --- a/src/lib/hooks/useTransactions.ts +++ b/src/lib/hooks/useTransactions.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; import { transactionService } from '../services'; -import { Transaction, TransactionInput, TransactionQuery, TransactionSortBy, SortOrder } from '../types'; +import { TransactionInput, TransactionQuery, TransactionSortBy, SortOrder } from '../types'; import { toast } from 'sonner'; import { accountKeys } from './useAccounts'; diff --git a/src/lib/hooks/useWebSocket.ts b/src/lib/hooks/useWebSocket.ts index 07526e6..aadd4da 100644 --- a/src/lib/hooks/useWebSocket.ts +++ b/src/lib/hooks/useWebSocket.ts @@ -11,7 +11,7 @@ export interface WebSocketMessage { taskId: string; status: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'CANCELLED'; taskType: string; - result?: any; + result?: unknown; }; } @@ -46,7 +46,10 @@ export function useWebSocket(): UseWebSocketReturn { return `${protocol}//${host}/api/ws?token=${encodeURIComponent(token)}`; }, []); + const connectRef = useRef<() => void>(() => { }); + const connect = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { return; } @@ -100,7 +103,8 @@ export function useWebSocket(): UseWebSocketReturn { reconnectTimeoutRef.current = setTimeout(() => { if (isLoggedIn) { - connect(); + // Use the ref to call connect to avoid "variable used before declaration" + connectRef.current(); } }, delay); } @@ -111,6 +115,11 @@ export function useWebSocket(): UseWebSocketReturn { } }, [getWebSocketUrl, isLoggedIn]); + // Update the ref whenever connect changes + useEffect(() => { + connectRef.current = connect; + }, [connect]); + const disconnect = useCallback(() => { if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); @@ -135,6 +144,7 @@ export function useWebSocket(): UseWebSocketReturn { // Connect when logged in, disconnect when not useEffect(() => { if (isLoggedIn) { + // eslint-disable-next-line react-hooks/set-state-in-effect connect(); } else { disconnect(); diff --git a/src/lib/services/accountService.ts b/src/lib/services/accountService.ts index e260dd1..e86c1b2 100644 --- a/src/lib/services/accountService.ts +++ b/src/lib/services/accountService.ts @@ -1,7 +1,7 @@ import apiRequest from '../api'; import { Account, AccountInput, AccountQuery, PaginatedResponse } from '../types'; -const buildQueryString = (query?: Record): string => { +const buildQueryString = (query?: Record): string => { if (!query) return ''; const params = new URLSearchParams(); Object.entries(query).forEach(([key, value]) => { diff --git a/src/lib/services/taskService.ts b/src/lib/services/taskService.ts index 18e0aed..91f24e6 100644 --- a/src/lib/services/taskService.ts +++ b/src/lib/services/taskService.ts @@ -4,8 +4,8 @@ export interface Task { id: string; type: string; status: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'CANCELLED'; - payload: any; - result?: any; + payload: unknown; + result?: unknown; progress: number; totalItems: number; processedItems: number; @@ -20,6 +20,7 @@ export interface TaskQuery { limit?: number; status?: Task['status']; type?: string; + [key: string]: string | number | undefined; } export interface PaginatedTaskResponse { @@ -29,7 +30,7 @@ export interface PaginatedTaskResponse { limit: number; } -const buildQueryString = (query?: Record): string => { +const buildQueryString = (query?: Record): string => { if (!query) return ''; const params = new URLSearchParams(); Object.entries(query).forEach(([key, value]) => { diff --git a/src/lib/services/transactionService.ts b/src/lib/services/transactionService.ts index 89170b9..303137a 100644 --- a/src/lib/services/transactionService.ts +++ b/src/lib/services/transactionService.ts @@ -1,7 +1,7 @@ import apiRequest from '../api'; import { Transaction, TransactionInput, TransactionQuery, PaginatedResponse } from '../types'; -const buildQueryString = (query?: Record): string => { +const buildQueryString = (query?: Record): string => { if (!query) return ''; const params = new URLSearchParams(); Object.entries(query).forEach(([key, value]) => { diff --git a/src/lib/types.ts b/src/lib/types.ts index 54867b7..8321dcd 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -72,6 +72,7 @@ export interface AccountQuery { limit?: number; type?: AccountType; parentId?: string; + [key: string]: string | number | boolean | undefined; } // ============== Transaction ============== @@ -107,6 +108,7 @@ export interface TransactionQuery { type?: TransactionType; sortBy?: TransactionSortBy; sortOrder?: SortOrder; + [key: string]: string | number | boolean | undefined; } // ============== User ============== diff --git a/tailwind.config.ts b/tailwind.config.ts index c5a118e..22afad5 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,4 +1,5 @@ import type { Config } from "tailwindcss"; +import tailwindcssAnimate from "tailwindcss-animate"; const config: Config = { darkMode: "class", // Enable dark mode support for future theme switching @@ -27,7 +28,7 @@ const config: Config = { }, }, }, - plugins: [require("tailwindcss-animate")], + plugins: [tailwindcssAnimate], }; export default config;