From 5e587837e84f1212e4dfe043d1f8bb7849887f44 Mon Sep 17 00:00:00 2001 From: admin <1297598740@qq.com> Date: Fri, 30 Jan 2026 15:40:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E7=BB=93=E6=9E=84=E3=80=81=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E9=9D=A2=E6=9D=BF=E5=92=8C=E5=88=9D=E6=AD=A5?= =?UTF-8?q?=E4=BA=A4=E6=98=93=E7=9B=B8=E5=85=B3=E9=A1=B5=E9=9D=A2=E5=8F=8A?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 5 +- .../common/CommandPalette/CommandPalette.tsx | 5 +- .../TransactionForm/TransactionForm.tsx | 11 +++ src/contexts/SettingsContext.tsx | 68 +++++++++++++++++++ src/pages/Home/Home.tsx | 34 ++++++---- src/pages/Transactions/Transactions.tsx | 11 ++- src/services/transactionService.ts | 27 ++------ src/types/index.ts | 18 +++++ walkthrough.md | 62 +++++++++++++++++ 9 files changed, 201 insertions(+), 40 deletions(-) create mode 100644 src/contexts/SettingsContext.tsx create mode 100644 walkthrough.md diff --git a/src/App.tsx b/src/App.tsx index fcb80f4..81b2f33 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { router } from './router'; import { ThemeProvider, PrivacyProvider, NotificationProvider, GuideProvider, useAutoTokenRefresh } from './hooks'; +import { SettingsProvider } from './contexts/SettingsContext'; /** * App Content Component @@ -25,7 +26,9 @@ function App() { - + + + diff --git a/src/components/common/CommandPalette/CommandPalette.tsx b/src/components/common/CommandPalette/CommandPalette.tsx index 2e5bb98..800a447 100644 --- a/src/components/common/CommandPalette/CommandPalette.tsx +++ b/src/components/common/CommandPalette/CommandPalette.tsx @@ -6,6 +6,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTheme } from '../../../hooks'; +import { useSettings } from '../../../contexts/SettingsContext'; import { Icon } from '@iconify/react'; import { useKey } from 'react-use'; import { getTransactions } from '../../../services/transactionService'; @@ -32,6 +33,8 @@ export const CommandPalette: React.FC = () => { const navigate = useNavigate(); const { toggleTheme, isDark } = useTheme(); + const { settings } = useSettings(); + const currentLedgerId = settings?.currentLedgerId; // Toggle open with Ctrl+K or Cmd+K useEffect(() => { @@ -129,7 +132,7 @@ export const CommandPalette: React.FC = () => { const timer = setTimeout(async () => { try { - const res = await getTransactions({ search: query, pageSize: 5 }); + const res = await getTransactions({ search: query, pageSize: 5, ledgerId: currentLedgerId }); setTransactionResults(res.items); } catch (err) { console.error('Search transactions failed', err); diff --git a/src/components/transaction/TransactionForm/TransactionForm.tsx b/src/components/transaction/TransactionForm/TransactionForm.tsx index c09e3fe..cecf973 100644 --- a/src/components/transaction/TransactionForm/TransactionForm.tsx +++ b/src/components/transaction/TransactionForm/TransactionForm.tsx @@ -56,6 +56,8 @@ interface TransactionFormProps { isEditing?: boolean; /** Whether to automatically skip steps if data is pre-filled */ smartSkipConfirmedSteps?: boolean; + /** Current active ledger ID */ + currentLedgerId?: number; } export const TransactionForm: React.FC = ({ @@ -65,6 +67,7 @@ export const TransactionForm: React.FC = ({ loading = false, isEditing = false, smartSkipConfirmedSteps = false, + currentLedgerId, }) => { // Current step (1, 2, or 3) const [currentStep, setCurrentStep] = useState(1); @@ -89,6 +92,7 @@ export const TransactionForm: React.FC = ({ : toLocalISOString(new Date()), note: '', tagIds: [], + ledgerId: currentLedgerId, ...initialData, // Override transactionDate again after spreading initialData ...(initialData?.transactionDate @@ -96,6 +100,13 @@ export const TransactionForm: React.FC = ({ : {}), }); + // Update ledgerId when currentLedgerId changes (only if not editing or explicit override needed) + useEffect(() => { + if (currentLedgerId && !isEditing) { + setFormData(prev => ({ ...prev, ledgerId: currentLedgerId })); + } + }, [currentLedgerId, isEditing]); + // Data State const [accounts, setAccounts] = useState([]); const [categories, setCategories] = useState([]); diff --git a/src/contexts/SettingsContext.tsx b/src/contexts/SettingsContext.tsx new file mode 100644 index 0000000..d3f37ce --- /dev/null +++ b/src/contexts/SettingsContext.tsx @@ -0,0 +1,68 @@ +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import type { UserSettings } from '../types'; +import { getSettings, updateSettings as apiUpdateSettings } from '../services/settingsService'; + +interface SettingsContextType { + settings: UserSettings | null; + loading: boolean; + error: string | null; + refreshSettings: () => Promise; + updateSettings: (data: Partial) => Promise; +} + +const SettingsContext = createContext(undefined); + +export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchSettings = useCallback(async () => { + try { + const data = await getSettings(); + setSettings(data); + setError(null); + } catch (err) { + console.error('Failed to load settings:', err); + setError('Failed to load settings'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchSettings(); + }, [fetchSettings]); + + const updateSettings = useCallback(async (data: Partial) => { + try { + const updated = await apiUpdateSettings(data); + setSettings(updated); + } catch (err) { + console.error('Failed to update settings:', err); + throw err; + } + }, []); + + return ( + + {children} + + ); +}; + +export const useSettings = (): SettingsContextType => { + const context = useContext(SettingsContext); + if (context === undefined) { + throw new Error('useSettings must be used within a SettingsProvider'); + } + return context; +}; diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index d63d3e8..7abe508 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -7,7 +7,8 @@ import { getTransactions, calculateTotalExpense } from '../../services/transacti import { getCategories } from '../../services/categoryService'; import { getLedgers, reorderLedgers } from '../../services/ledgerService'; import { getStreakInfo, type StreakInfoFormatted } from '../../services/streakService'; -import { getSettings, updateSettings } from '../../services/settingsService'; +import { updateSettings } from '../../services/settingsService'; +import { useSettings } from '../../contexts/SettingsContext'; import { Icon } from '@iconify/react'; import { SpendingTrendChart } from '../../components/charts/SpendingTrendChart'; import { Skeleton } from '../../components/common/Skeleton/Skeleton'; @@ -24,7 +25,7 @@ import { HealthScoreCard } from '../../components/home/HealthScoreCard/HealthSco import { QuickActionsBox } from '../../components/home/QuickActionsBox/QuickActionsBox'; import { getBudgets } from '../../services/budgetService'; import { toLocalDateString, isSameDay } from '../../utils/dateUtils'; -import type { Account, Transaction, Category, Ledger, UserSettings } from '../../types'; +import type { Account, Transaction, Category, Ledger } from '../../types'; /** @@ -36,7 +37,10 @@ function Home() { const [recentTransactions, setRecentTransactions] = useState([]); const [categories, setCategories] = useState([]); const [ledgers, setLedgers] = useState([]); - const [settings, setSettings] = useState(null); + + const { settings, loading: settingsLoading } = useSettings(); + const currentLedgerId = settings?.currentLedgerId || 0; + const [ledgerSelectorOpen, setLedgerSelectorOpen] = useState(false); const [voiceModalOpen, setVoiceModalOpen] = useState(false); const [showAccountForm, setShowAccountForm] = useState(false); @@ -59,8 +63,9 @@ function Home() { useEffect(() => { + if (settingsLoading) return; loadData(); - }, []); + }, [currentLedgerId, settingsLoading]); const loadData = async () => { try { @@ -82,22 +87,20 @@ function Home() { const rangeStartStr = toLocalDateString(rangeStart); const rangeEndStr = toLocalDateString(rangeEnd); - const [accountsData, recentTxData, categoriesData, ledgersData, settingsData, budgetsData, streakData, rangeTxData] = await Promise.all([ + const [accountsData, recentTxData, categoriesData, ledgersData, budgetsData, streakData, rangeTxData] = await Promise.all([ getAccounts(), - getTransactions({ page: 1, pageSize: 5 }), // Recent list (server sort) + getTransactions({ page: 1, pageSize: 5, ledgerId: currentLedgerId }), // Recent list (server sort) getCategories(), getLedgers().catch(() => []), - getSettings().catch(() => null), getBudgets().catch(() => []), getStreakInfo().catch(() => null), - getTransactions({ startDate: rangeStartStr, endDate: rangeEndStr, pageSize: 1000 }), // Bulk fetch + getTransactions({ startDate: rangeStartStr, endDate: rangeEndStr, pageSize: 1000, ledgerId: currentLedgerId }), // Bulk fetch ]); setAccounts(accountsData || []); setRecentTransactions(recentTxData?.items || []); setCategories(categoriesData || []); setLedgers(ledgersData || []); - setSettings(settingsData); setStreakInfo(streakData); // In-Memory Aggregation fix @@ -198,12 +201,13 @@ function Home() { const handleLedgerSelect = async (ledgerId: number) => { try { - if (settings) { - await updateSettings({ ...settings, currentLedgerId: ledgerId }); - setSettings({ ...settings, currentLedgerId: ledgerId }); - } - setLedgerSelectorOpen(false); - await loadData(); + try { + if (settings) { + await updateSettings({ currentLedgerId: ledgerId }); + } + setLedgerSelectorOpen(false); + // Data reload triggered by useEffect on currentLedgerId change + } catch (err) { setError('切换账本失败'); } } catch (err) { setError('切换账本失败'); } }; const handleLedgerReorder = async (reorderedLedgers: Ledger[]) => { diff --git a/src/pages/Transactions/Transactions.tsx b/src/pages/Transactions/Transactions.tsx index 6739a98..6d4843d 100644 --- a/src/pages/Transactions/Transactions.tsx +++ b/src/pages/Transactions/Transactions.tsx @@ -23,6 +23,7 @@ import { getCategories } from '../../services/categoryService'; import { getAccounts } from '../../services/accountService'; import { suggestAllocationForIncome } from '../../services/allocationRuleService'; import type { AllocationResult } from '../../services/allocationRuleService'; +import { useSettings } from '../../contexts/SettingsContext'; import { formatCurrency } from '../../utils'; import { Icon } from '@iconify/react'; import { TransactionForm } from '../../components/transaction'; @@ -66,6 +67,9 @@ export const Transactions: React.FC = () => { return params; }); + const { settings, loading: settingsLoading } = useSettings(); + const currentLedgerId = settings?.currentLedgerId || 0; + const [selectedTransaction, setSelectedTransaction] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [deleting, setDeleting] = useState(false); @@ -115,6 +119,7 @@ export const Transactions: React.FC = () => { let params: any = { ...filterValues, + ledgerId: currentLedgerId, }; if (viewMode === 'list') { @@ -143,7 +148,7 @@ export const Transactions: React.FC = () => { setLoading(false); } }, - [filterValues, viewMode, calendarMonth] + [filterValues, viewMode, calendarMonth, currentLedgerId] ); // Fetch categories and accounts on mount @@ -163,8 +168,9 @@ export const Transactions: React.FC = () => { // Fetch transactions when filters change useEffect(() => { + if (settingsLoading) return; fetchTransactions(1); - }, [fetchTransactions]); + }, [fetchTransactions, settingsLoading]); // Handle filter changes const handleFilterChange = useCallback((values: FilterValues) => { @@ -511,6 +517,7 @@ export const Transactions: React.FC = () => { onSubmit={handleCreateTransaction} onCancel={() => setShowTransactionForm(false)} loading={formLoading} + currentLedgerId={currentLedgerId} /> diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index 30250fa..a41f13b 100644 --- a/src/services/transactionService.ts +++ b/src/services/transactionService.ts @@ -7,6 +7,7 @@ import api from './api'; import type { Transaction, TransactionFormInput, + TransactionFilter, ApiResponse, PaginatedResponse, TransactionType, @@ -16,27 +17,7 @@ import type { RefundStatus, } from '../types'; -/** - * Transaction filter parameters - */ -export interface TransactionFilter { - /** Filter by start date (inclusive) */ - startDate?: string; - /** Filter by end date (inclusive) */ - endDate?: string; - /** Filter by category ID */ - categoryId?: number; - /** Filter by account ID */ - accountId?: number; - /** Filter by transaction type */ - type?: TransactionType; - /** Search in notes */ - search?: string; - /** Page number (1-based) */ - page?: number; - /** Items per page */ - pageSize?: number; -} +// TransactionFilter is imported from types/index.ts /** * Transform API response transaction to frontend Transaction type @@ -79,6 +60,8 @@ export async function getTransactions( if (filter.categoryId) params.category_id = filter.categoryId; if (filter.accountId) params.account_id = filter.accountId; if (filter.type) params.type = filter.type; + if (filter.currency) params.currency = filter.currency; + if (filter.ledgerId) params.ledger_id = filter.ledgerId; if (filter.search) params.note_search = filter.search; if (filter.page) params.offset = ((filter.page - 1) * (filter.pageSize || 20)).toString(); if (filter.pageSize) params.limit = filter.pageSize; @@ -153,6 +136,7 @@ export async function createTransaction(data: TransactionFormInput): Promise>('/transactions', payload); if (!response.data) { @@ -178,6 +162,7 @@ export async function updateTransaction( if (data.transactionDate !== undefined) payload.transaction_date = data.transactionDate; if (data.note !== undefined) payload.note = data.note; if (data.tagIds !== undefined) payload.tag_ids = data.tagIds; + if (data.ledgerId !== undefined) payload.ledger_id = data.ledgerId; const response = await api.put>(`/transactions/${id}`, payload); if (!response.data) { diff --git a/src/types/index.ts b/src/types/index.ts index 7912aec..96324c4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -286,6 +286,7 @@ export interface TransactionFormInput { transactionDate: string; note?: string; tagIds?: number[]; + ledgerId?: number; } export interface AccountFormInput { @@ -517,3 +518,20 @@ export interface UpdateDefaultAccountsInput { defaultExpenseAccountId?: number | null; defaultIncomeAccountId?: number | null; } + +// Transaction List Filter Params +export interface TransactionFilter { + startDate?: string; + endDate?: string; + categoryId?: number; + accountId?: number; + ledgerId?: number; + type?: TransactionType; + currency?: CurrencyCode; + noteSearch?: string; + sortField?: string; + sortAsc?: boolean; + search?: string; // Alias/Helper for noteSearch + page?: number; // Helper for offset calculation + pageSize?: number; // Helper for limit +} diff --git a/walkthrough.md b/walkthrough.md new file mode 100644 index 0000000..2ca7905 --- /dev/null +++ b/walkthrough.md @@ -0,0 +1,62 @@ +# Ledger Integration Walkthrough + +This document outlines the changes made to integrate Ledger functionality into the Transaction flow and provides steps for verification. + +## Changes Overview + +### Backend (Go) +- **`TransactionInput`**: Added `LedgerID` field to potential input. +- **`TransactionService`**: + - Updated `CreateTransaction` to assign the user's default ledger if no `LedgerID` is provided. + - Updated `CreateTransaction` and `UpdateTransaction` to validate and link the `LedgerID`. + - Injected `LedgerRepository` to allow fetching default ledgers and validating IDs. +- **`Router`**: Updated dependency injection to pass `LedgerRepository` to `TransactionService`. + +### Frontend (React/TypeScript) +- **Global State**: Created `SettingsContext` to provide global access to the currently selected `ledgerId`. +- **Types**: Updated `TransactionFormInput`, `TransactionFilter`, and other interfaces to support `this.ledgerId`. +- **Service (`transactionService.ts`)**: Updated `getTransactions`, `createTransaction`, and `updateTransaction` to map `ledgerId` to the backend API parameters (`ledger_id`). +- **Components**: + - **`TransactionForm`**: Now accepts `currentLedgerId` and submits it with new transactions. + - **`CommandPalette`**: Now filters transaction search results by the current active ledger. +- **Pages**: + - **`Home`**: Updated to use `SettingsContext` for ledger selection and data refreshing. + - **`Transactions`**: Updated to filter the transaction list by the global `currentLedgerId` and pass it to the creation form. + +## Verification Steps + +### 1. Ledger Switching +1. Navigate to the **Home** page. +2. Click the Ledger selector in the header (top left pill). +3. Select a different ledger (e.g., "Business" vs "Personal"). +4. **Verify**: The "Recent Transactions" list update to show only transactions for the selected ledger. +5. **Verify**: The Dashboard stats (Income, Expense) update to reflect the selected ledger. + +### 2. Transaction Creation with Ledger +1. Ensure you are in a specific ledger (e.g., "Personal"). +2. Click **"记一笔" (Add Transaction)**. +3. Create a new transaction. +4. **Verify**: The transaction appears in the "Personal" ledger list. +5. Switch to a "Business" ledger. +6. **Verify**: The newly created transaction does **NOT** appear in the "Business" ledger list. + +### 3. Transaction List Filtering +1. Go to the **Transactions** page. +2. **Verify**: The list only shows transactions for the currently selected ledger. +3. Switch ledger using the global context (if accessible) or go back Home to switch, then return. +4. **Verify**: The list refreshes to show the new ledger's transactions. + +### 4. Global Search +1. Press `Ctrl+K` (or `Cmd+K`) to open the Command Palette. +2. Type a search term for a transaction that exists in the *current* ledger. +3. **Verify**: The transaction appears in results. +4. Type a search term for a transaction that exists in a *different* ledger. +5. **Verify**: The transaction does **NOT** appear in results. + +### 5. Exchange Rates (Regression Test) +1. Go to **Exchange Rates** page. +2. **Verify**: Net Worth and History still load correctly (this calculates based on *global* accounts, so it should remain unaffected by ledger selection). + +## Technical Notes +- **Default Behavior**: If a transaction is created via API without a `ledger_id` (e.g., older clients), the backend now automatically assigns it to the user's **Default Ledger**. +- **Context Persistence**: The selected ledger ID is stored in User Settings, so it persists across sessions if the backend supports saving settings (which it does).