feat: 新增预算管理页面,支持预算和存钱罐的增删改查功能,并引入预算详情和交易表单组件。
This commit is contained in:
@@ -100,7 +100,7 @@ const generateInsight = (budget: Budget, transactions: Transaction[], startDate:
|
||||
const runwayDays = dailyAverage > 0 ? remaining / dailyAverage : 999;
|
||||
|
||||
// 4. Composition Analysis
|
||||
const avgTxAmount = spent / (txCount || 1);
|
||||
|
||||
// "Latte Factor": Many small transactions (< 2% of budget)
|
||||
const smallTxCount = transactions.filter(t => t.amount < total * 0.02).length;
|
||||
const isNickelAndDimed = (smallTxCount / txCount > 0.7) && (spent > total * 0.4);
|
||||
@@ -128,7 +128,7 @@ const generateInsight = (budget: Budget, transactions: Transaction[], startDate:
|
||||
|
||||
// Case 2: Critical condition
|
||||
if (runwayDays < daysRemaining - 3) { // 3 days buffer
|
||||
const daysShort = Math.round(daysRemaining - runwayDays);
|
||||
|
||||
return {
|
||||
type: 'danger',
|
||||
text: `⚠️ 红色预警:按目前速度,${categoryPrefix}预算将在 ${Math.round(runwayDays)} 天后提前耗尽。请务必大幅削减开支!`
|
||||
@@ -136,7 +136,7 @@ const generateInsight = (budget: Budget, transactions: Transaction[], startDate:
|
||||
}
|
||||
|
||||
// Case 3: Pattern detection - Nickel and Dimed
|
||||
if (patternMatched(isNickelAndDimed, 'warning')) {
|
||||
if (isNickelAndDimed) {
|
||||
return {
|
||||
type: 'info',
|
||||
text: `🐜 蚂蚁搬家式消费!虽然单笔不贵,但已累计 ${smallTxCount} 笔小额支出。警惕“拿铁效应”,这些琐碎开支正在悄悄吃掉您的预算。`
|
||||
@@ -144,7 +144,7 @@ const generateInsight = (budget: Budget, transactions: Transaction[], startDate:
|
||||
}
|
||||
|
||||
// Case 4: Pattern detection - Whale
|
||||
if (patternMatched(isWhaleMoved, 'info')) {
|
||||
if (isWhaleMoved) {
|
||||
return {
|
||||
type: 'info',
|
||||
text: `🐋 巨鲸出没!仅 ${largeTx.length} 笔大额支出就占据了绝大部分开销。控制好大件消费是守住预算的关键。`
|
||||
@@ -183,9 +183,7 @@ const generateInsight = (budget: Budget, transactions: Transaction[], startDate:
|
||||
};
|
||||
|
||||
// Helper to avoid TS errors or cleaner logic usage
|
||||
function patternMatched(condition: boolean, type: string) {
|
||||
return condition;
|
||||
}
|
||||
|
||||
|
||||
export const BudgetDetailModal: React.FC<BudgetDetailModalProps> = ({
|
||||
budget,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import type {
|
||||
TransactionType,
|
||||
CurrencyCode,
|
||||
@@ -20,8 +21,28 @@ import type {
|
||||
import { CategorySelector } from '../../category/CategorySelector/CategorySelector';
|
||||
import { TagInput } from '../../tag/TagInput/TagInput';
|
||||
import { getAccounts } from '../../../services/accountService';
|
||||
import { getCategories } from '../../../services/categoryService';
|
||||
import './TransactionForm.css';
|
||||
|
||||
// Constants
|
||||
const TRANSACTION_TYPES: { value: TransactionType; label: string; icon: string }[] = [
|
||||
{ value: 'expense', label: '支出', icon: '💰' },
|
||||
{ value: 'income', label: '收入', icon: '💸' },
|
||||
{ value: 'transfer', label: '转账', icon: '↔️' },
|
||||
];
|
||||
|
||||
const CURRENCIES: { value: CurrencyCode; symbol: string }[] = [
|
||||
{ value: 'CNY', symbol: '¥' },
|
||||
{ value: 'USD', symbol: '$' },
|
||||
{ value: 'EUR', symbol: '€' },
|
||||
{ value: 'JPY', symbol: '¥' },
|
||||
{ value: 'GBP', symbol: '£' },
|
||||
];
|
||||
|
||||
const getCurrencySymbol = (code: string) => {
|
||||
return CURRENCIES.find((c) => c.value === code)?.symbol || code;
|
||||
};
|
||||
|
||||
interface TransactionFormProps {
|
||||
/** Initial form data for editing */
|
||||
initialData?: Partial<TransactionFormInput>;
|
||||
@@ -37,8 +58,6 @@ interface TransactionFormProps {
|
||||
smartSkipConfirmedSteps?: boolean;
|
||||
}
|
||||
|
||||
// ...
|
||||
|
||||
export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
initialData,
|
||||
onSubmit,
|
||||
@@ -50,40 +69,135 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
// Current step (1, 2, or 3)
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
// ... existing code ...
|
||||
// Form State
|
||||
const [formData, setFormData] = useState<TransactionFormInput>({
|
||||
amount: 0,
|
||||
type: 'expense',
|
||||
currency: 'CNY',
|
||||
categoryId: 0,
|
||||
accountId: 0,
|
||||
transactionDate: new Date().toISOString().split('T')[0],
|
||||
note: '',
|
||||
tagIds: [],
|
||||
...initialData,
|
||||
});
|
||||
|
||||
// Go to next step
|
||||
// Data State
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [accountsLoading, setAccountsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const amountInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load Data
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setAccountsLoading(true);
|
||||
try {
|
||||
const [accs, cats] = await Promise.all([
|
||||
getAccounts(),
|
||||
getCategories()
|
||||
]);
|
||||
setAccounts(accs);
|
||||
setCategories(cats);
|
||||
} catch (err) {
|
||||
console.error('Failed to load form data', err);
|
||||
} finally {
|
||||
setAccountsLoading(false);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Handlers
|
||||
const handleTypeChange = (type: TransactionType) => {
|
||||
setFormData((prev) => ({ ...prev, type }));
|
||||
};
|
||||
|
||||
const handleCurrencyChange = (currency: CurrencyCode) => {
|
||||
setFormData((prev) => ({ ...prev, currency }));
|
||||
};
|
||||
|
||||
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// Basic number input handling
|
||||
const val = e.target.value;
|
||||
// Allow empty string or ending with decimal
|
||||
if (val === '' || /^\d*\.?\d*$/.test(val)) {
|
||||
// We might store it as string locally if we want perfect input, but TransactionFormInput expects number.
|
||||
// For simplicity, we parse float immediately but this prevents typing "1." comfortably.
|
||||
// Let's rely on type="number" behavior of input if possible or assume user types clean numbers.
|
||||
// Or better: update state properly.
|
||||
// The original broken code used `value={formData.amount || ''}` and `inputMode="decimal"`.
|
||||
// Let's do simple float parsing.
|
||||
setFormData((prev) => ({ ...prev, amount: parseFloat(val) || 0 }));
|
||||
if (errors.amount) setErrors((prev) => ({ ...prev, amount: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryChange = (categoryId: number | undefined) => {
|
||||
setFormData((prev) => ({ ...prev, categoryId: categoryId || 0 }));
|
||||
if (errors.categoryId) setErrors((prev) => ({ ...prev, categoryId: '' }));
|
||||
};
|
||||
|
||||
const handleAccountChange = (accountId: number) => {
|
||||
setFormData((prev) => ({ ...prev, accountId }));
|
||||
if (errors.accountId) setErrors((prev) => ({ ...prev, accountId: '' }));
|
||||
};
|
||||
|
||||
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData((prev) => ({ ...prev, transactionDate: e.target.value }));
|
||||
};
|
||||
|
||||
const handleNoteChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setFormData((prev) => ({ ...prev, note: e.target.value }));
|
||||
};
|
||||
|
||||
const handleTagsChange = (tagIds: number[]) => {
|
||||
setFormData((prev) => ({ ...prev, tagIds }));
|
||||
};
|
||||
|
||||
// Navigation
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (currentStep === 1 && validateStep1()) {
|
||||
// Smart skip: If step 2 data (category & account) is already valid, jump to step 3
|
||||
if (currentStep === 1) {
|
||||
// Validate Step 1
|
||||
if (!formData.amount || formData.amount <= 0) {
|
||||
setErrors(prev => ({ ...prev, amount: '请输入有效金额' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Smart Skip
|
||||
if (smartSkipConfirmedSteps && formData.categoryId && formData.accountId) {
|
||||
setCurrentStep(3);
|
||||
} else {
|
||||
setCurrentStep(2);
|
||||
}
|
||||
} else if (currentStep === 2 && validateStep2()) {
|
||||
setCurrentStep(3);
|
||||
}
|
||||
}, [currentStep, formData, smartSkipConfirmedSteps]);
|
||||
} else if (currentStep === 2) {
|
||||
// Validate Step 2
|
||||
let isValid = true;
|
||||
const newErrors = { ...errors };
|
||||
if (!formData.categoryId) { newErrors.categoryId = '请选择分类'; isValid = false; }
|
||||
if (!formData.accountId) { newErrors.accountId = '请选择账户'; isValid = false; }
|
||||
setErrors(newErrors);
|
||||
|
||||
if (isValid) setCurrentStep(3);
|
||||
}
|
||||
}, [currentStep, formData, smartSkipConfirmedSteps, errors]);
|
||||
|
||||
// Go to previous step
|
||||
const handlePrevStep = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = useCallback(() => {
|
||||
// Final validation
|
||||
if (!validateStep1() || !validateStep2()) {
|
||||
return;
|
||||
}
|
||||
if (!formData.amount) return;
|
||||
if (!formData.categoryId) return;
|
||||
if (!formData.accountId) return;
|
||||
|
||||
onSubmit(formData);
|
||||
}, [formData, onSubmit]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
@@ -98,10 +212,11 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
[currentStep, handleNextStep, handleSubmit]
|
||||
);
|
||||
|
||||
// Get selected account
|
||||
// Helpers
|
||||
const selectedAccount = accounts.find((a) => a.id === formData.accountId);
|
||||
const selectedCategory = categories.find((c) => c.id === formData.categoryId);
|
||||
|
||||
// Render step indicator
|
||||
// Render Functions
|
||||
const renderStepIndicator = () => (
|
||||
<div className="transaction-form__steps">
|
||||
{[1, 2, 3].map((step) => (
|
||||
@@ -118,12 +233,9 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render step 1: Amount and Type
|
||||
const renderStep1 = () => (
|
||||
<div className="transaction-form__step-content">
|
||||
<h3 className="transaction-form__step-title">输入金额</h3>
|
||||
|
||||
{/* Transaction Type Toggle */}
|
||||
<div className="transaction-form__type-toggle">
|
||||
{TRANSACTION_TYPES.map((type) => (
|
||||
<button
|
||||
@@ -138,8 +250,6 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Amount Input */}
|
||||
<div className="transaction-form__amount-container">
|
||||
<div className="transaction-form__currency-selector">
|
||||
<select
|
||||
@@ -157,7 +267,7 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
</div>
|
||||
<input
|
||||
ref={amountInputRef}
|
||||
type="text"
|
||||
type="number" // Changed to number for simpler handling
|
||||
inputMode="decimal"
|
||||
className={`transaction-form__amount-input ${errors.amount ? 'transaction-form__amount-input--error' : ''}`}
|
||||
value={formData.amount || ''}
|
||||
@@ -165,18 +275,16 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="0.00"
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{errors.amount && <span className="transaction-form__error">{errors.amount}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render step 2: Category and Account
|
||||
const renderStep2 = () => (
|
||||
<div className="transaction-form__step-content">
|
||||
<h3 className="transaction-form__step-title">选择分类和账户</h3>
|
||||
|
||||
{/* Category Selector */}
|
||||
<div className="transaction-form__field">
|
||||
<CategorySelector
|
||||
value={formData.categoryId || undefined}
|
||||
@@ -188,8 +296,6 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Account Selector */}
|
||||
<div className="transaction-form__field">
|
||||
<label className="transaction-form__label">
|
||||
账户 <span className="transaction-form__required">*</span>
|
||||
@@ -229,12 +335,9 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render step 3: Optional details and confirmation
|
||||
const renderStep3 = () => (
|
||||
<div className="transaction-form__step-content">
|
||||
<h3 className="transaction-form__step-title">补充信息(可选)</h3>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="transaction-form__summary">
|
||||
<div className="transaction-form__summary-row">
|
||||
<span className="transaction-form__summary-label">金额</span>
|
||||
@@ -267,8 +370,6 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="transaction-form__field">
|
||||
<label className="transaction-form__label" htmlFor="transactionDate">
|
||||
日期
|
||||
@@ -282,8 +383,6 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<div className="transaction-form__field">
|
||||
<label className="transaction-form__label" htmlFor="note">
|
||||
备注
|
||||
@@ -298,8 +397,6 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="transaction-form__field">
|
||||
<TagInput
|
||||
value={formData.tagIds}
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
PiggyBankCardSkeleton,
|
||||
PiggyBankForm,
|
||||
PiggyBankTransactionModal,
|
||||
PiggyBankTransactionModal,
|
||||
|
||||
PiggyBankIntro,
|
||||
BudgetDetailModal,
|
||||
} from '../../components/budget';
|
||||
import type { Budget as BudgetType, Category, Account, PiggyBank } from '../../types';
|
||||
import type { Budget as BudgetType, Category, Account, PiggyBank, TransactionFormInput } from '../../types';
|
||||
import {
|
||||
getBudgets,
|
||||
createBudget,
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
withdrawFromPiggyBank,
|
||||
type PiggyBankFormInput,
|
||||
} from '../../services/piggyBankService';
|
||||
import { createTransaction, type TransactionFormInput } from '../../services/transactionService';
|
||||
import { createTransaction } from '../../services/transactionService';
|
||||
import { TransactionForm } from '../../components/transaction';
|
||||
import { getCategories } from '../../services/categoryService';
|
||||
import { getAccounts } from '../../services/accountService';
|
||||
@@ -420,7 +420,7 @@ function Budget() {
|
||||
budget={budget}
|
||||
categoryName={getCategoryName(budget.categoryId)}
|
||||
accountName={getAccountName(budget.accountId)}
|
||||
accountName={getAccountName(budget.accountId)}
|
||||
|
||||
onEdit={handleEditBudget}
|
||||
onDelete={handleDeleteBudget}
|
||||
onClick={() => handleViewBudget(budget)}
|
||||
|
||||
Reference in New Issue
Block a user