diff --git a/src/components/budget/BudgetDetailModal/BudgetDetailModal.css b/src/components/budget/BudgetDetailModal/BudgetDetailModal.css new file mode 100644 index 0000000..e9ff9f4 --- /dev/null +++ b/src/components/budget/BudgetDetailModal/BudgetDetailModal.css @@ -0,0 +1,342 @@ +/** + * BudgetDetailModal Styles + */ + +.budget-detail-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.budget-detail-modal { + background-color: var(--color-bg); + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + width: 100%; + max-width: 600px; + max-height: 85vh; + display: flex; + flex-direction: column; + overflow: hidden; + animation: modalFadeIn 0.2s ease-out; +} + +@keyframes modalFadeIn { + from { + opacity: 0; + transform: scale(0.95); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +.budget-detail-modal__header { + padding: 1.5rem; + border-bottom: 1px solid var(--color-border); + display: flex; + justify-content: space-between; + align-items: center; + background-color: var(--color-bg-secondary); +} + +.budget-detail-modal__title-section { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.budget-detail-modal__title { + margin: 0; + font-size: 1.25rem; + font-weight: 700; + color: var(--color-text); +} + +.budget-detail-modal__period { + font-size: 0.875rem; + color: var(--color-text-secondary); +} + +.budget-detail-modal__close { + background: none; + border: none; + cursor: pointer; + color: var(--color-text-secondary); + padding: 0.5rem; + border-radius: 50%; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.budget-detail-modal__close:hover { + background-color: rgba(0, 0, 0, 0.05); + color: var(--color-text); +} + +.budget-detail-modal__content { + flex: 1; + overflow-y: auto; + padding: 1.5rem; +} + +.budget-detail-modal__summary { + display: flex; + justify-content: space-between; + margin-bottom: 2rem; + padding: 1rem; + background: var(--color-bg-secondary); + border-radius: 12px; + border: 1px solid var(--color-border); +} + +.budget-detail-modal__stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + flex: 1; +} + +.budget-detail-modal__stat-label { + font-size: 0.75rem; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.budget-detail-modal__stat-value { + font-size: 1.125rem; + font-weight: 700; + color: var(--color-text); + font-family: 'Outfit', sans-serif; +} + +.budget-detail-modal__stat-value--spent { + color: var(--color-primary); +} + +.budget-detail-modal__stat-value--remaining { + color: var(--color-success); +} + +.budget-detail-modal__stat-value--negative { + color: var(--color-error); +} + +.budget-detail-modal__stat-divider { + width: 1px; + background-color: var(--color-border); + height: 40px; + align-self: center; +} + +.budget-detail-modal__transactions h4 { + margin: 0 0 1rem 0; + font-size: 1rem; + color: var(--color-text); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.budget-detail-modal__list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.budget-detail-modal__empty { + text-align: center; + padding: 2rem; + color: var(--color-text-secondary); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.budget-detail-modal__loading { + display: flex; + justify-content: center; + padding: 2rem; +} + +.budget-spinner { + width: 24px; + height: 24px; + border: 2px solid var(--color-border); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Chart */ +.budget-detail-modal__chart { + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--color-bg); + border-radius: 12px; + border: 1px dashed var(--color-border); +} + +.budget-detail-modal__chart-bars { + display: flex; + align-items: flex-end; + justify-content: space-around; + height: 100px; + gap: 0.5rem; + padding-bottom: 0.5rem; +} + +.budget-detail-modal__chart-col { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + height: 100%; + justify-content: flex-end; + gap: 0.25rem; +} + +.budget-detail-modal__chart-bar { + width: 80%; + max-width: 20px; + background: linear-gradient(180deg, var(--color-primary-light) 0%, var(--color-primary) 100%); + border-radius: 4px; + min-height: 4px; + opacity: 0.8; + transition: all 0.2s; +} + +.budget-detail-modal__chart-bar:hover { + opacity: 1; + transform: scaleY(1.05); +} + +.budget-detail-modal__quick-add-btn { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.375rem 0.75rem; + background-color: rgba(var(--color-primary-rgb), 0.1); + color: var(--color-primary); + border: 1px solid transparent; + border-radius: 999px; + font-size: 0.75rem; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.budget-detail-modal__quick-add-btn:hover { + background-color: var(--color-primary); + color: white; + transform: translateY(-1px); +} + +/* AI Insight */ +.budget-detail-modal__ai-insight { + display: flex; + gap: 0.75rem; + padding: 1rem; + margin-bottom: 1.5rem; + border-radius: 12px; + background: var(--glass-panel-bg); + border: 1px solid transparent; + align-items: flex-start; + transition: all 0.3s ease; +} + +.budget-detail-modal__ai-insight--normal { + background: rgba(var(--color-primary-rgb), 0.05); + border-color: rgba(var(--color-primary-rgb), 0.1); +} + +.budget-detail-modal__ai-insight--normal .budget-detail-modal__ai-icon { + color: var(--color-primary); +} + +.budget-detail-modal__ai-insight--warning { + background: rgba(245, 158, 11, 0.05); + border-color: rgba(245, 158, 11, 0.2); +} + +.budget-detail-modal__ai-insight--warning .budget-detail-modal__ai-icon { + color: #F59E0B; +} + +.budget-detail-modal__ai-insight--danger { + background: rgba(239, 68, 68, 0.05); + border-color: rgba(239, 68, 68, 0.2); +} + +.budget-detail-modal__ai-insight--danger .budget-detail-modal__ai-icon { + color: #EF4444; +} + +.budget-detail-modal__ai-insight--success { + background: rgba(16, 185, 129, 0.05); + border-color: rgba(16, 185, 129, 0.2); +} + +.budget-detail-modal__ai-insight--success .budget-detail-modal__ai-icon { + color: #10B981; +} + +.budget-detail-modal__ai-insight--info { + background: rgba(59, 130, 246, 0.05); + border-color: rgba(59, 130, 246, 0.2); +} + +.budget-detail-modal__ai-insight--info .budget-detail-modal__ai-icon { + color: #3B82F6; +} + +.budget-detail-modal__ai-icon { + margin-top: 2px; + display: flex; + align-items: center; + justify-content: center; +} + +.budget-detail-modal__ai-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.budget-detail-modal__ai-title { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.8; +} + +.budget-detail-modal__ai-text { + font-size: 0.9rem; + line-height: 1.5; + color: var(--color-text); + margin: 0; +} \ No newline at end of file diff --git a/src/components/budget/BudgetDetailModal/BudgetDetailModal.tsx b/src/components/budget/BudgetDetailModal/BudgetDetailModal.tsx new file mode 100644 index 0000000..232841a --- /dev/null +++ b/src/components/budget/BudgetDetailModal/BudgetDetailModal.tsx @@ -0,0 +1,383 @@ +/** + * BudgetDetailModal Component + * Shows details and transaction history for a specific budget + */ + +import React, { useEffect, useState } from 'react'; +import { Icon } from '@iconify/react'; +import type { Budget, Transaction } from '../../../types'; +import { getTransactions } from '../../../services/transactionService'; +import TransactionItem from '../../transaction/TransactionItem/TransactionItem'; +import { formatCurrency } from '../../../utils/format'; +import { getPeriodTypeLabel } from '../../../services/budgetService'; +import './BudgetDetailModal.css'; + +interface BudgetDetailModalProps { + budget: Budget; + onClose: () => void; + onEdit?: () => void; + onAddTransaction?: () => void; + refreshTrigger?: number; + categoryName?: string; +} + +const toLocalDateString = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +const getPeriodDates = (periodType: string, customStartDate?: string) => { + const now = new Date(); + + // Default logic + let start = new Date(now); + let end = new Date(now); + start.setHours(0, 0, 0, 0); + end.setHours(23, 59, 59, 999); + + if (periodType === 'daily') { + // Current day + } else if (periodType === 'weekly') { + // Start of week (Monday) + const day = start.getDay() || 7; // Sunday is 0, make it 7 + start.setDate(start.getDate() - day + 1); + end.setDate(start.getDate() + 6); + } else if (periodType === 'monthly') { + let startDay = 1; + if (customStartDate) { + const d = new Date(customStartDate); + if (!isNaN(d.getTime())) startDay = d.getDate(); + } + + if (now.getDate() >= startDay) { + start.setDate(startDay); + end = new Date(start); + end.setMonth(end.getMonth() + 1); + end.setDate(startDay - 1); + } else { + start.setMonth(start.getMonth() - 1); + start.setDate(startDay); + end = new Date(start); + end.setMonth(end.getMonth() + 1); + end.setDate(startDay - 1); + } + } else if (periodType === 'yearly') { + start.setMonth(0, 1); + end.setMonth(11, 31); + } + + return { + startDate: toLocalDateString(start), + endDate: toLocalDateString(end) + }; +}; + +const generateInsight = (budget: Budget, transactions: Transaction[], startDate: string, endDate: string, categoryName?: string) => { + // 1. Basic Metrics + const total = budget.amount; + const spent = budget.spent || 0; + const remaining = total - spent; + const ratio = spent / total; // % used + const txCount = transactions.length; + + // 2. Time Metrics + const start = new Date(startDate).getTime(); + const end = new Date(endDate).getTime(); + const now = new Date().getTime(); + + const totalDays = Math.max(1, (end - start) / (1000 * 60 * 60 * 24)); + const daysPassed = Math.max(1, (now - start) / (1000 * 60 * 60 * 24)); + const daysRemaining = Math.max(0, totalDays - daysPassed); + + const timeRatio = daysPassed / totalDays; // % time passed + + // 3. Analytics & Projections + const dailyAverage = spent / daysPassed; + const projectedTotal = dailyAverage * totalDays; + const projectedOverspend = projectedTotal - total; + 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); + + // "Whale": Few large transactions (> 20% of budget) + const largeTx = transactions.filter(t => t.amount > total * 0.2); + const isWhaleMoved = largeTx.length > 0 && (largeTx.reduce((s, t) => s + t.amount, 0) > spent * 0.6); + + const categoryPrefix = categoryName ? `【${categoryName}】` : ''; + + // --- Decision Logic --- + + // Case 0: No spending + if (spent === 0) { + if (daysPassed > totalDays * 0.5) { + return { type: 'success', text: `哇!周期过半${categoryPrefix}还没有任何支出?真是省钱大神!继续保持!🏆` }; + } + return { type: 'normal', text: `新周期开始,预算充足。建议规划好每日可用额度约 ${formatCurrency(total / totalDays, 'CNY')}。📅` }; + } + + // Case 1: Budget Exceeded + if (remaining <= 0) { + return { type: 'danger', text: `🚨 ${categoryPrefix}预算已耗尽!目前超支 ${formatCurrency(Math.abs(remaining), 'CNY')}。请立即停止非必要消费!` }; + } + + // 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)} 天后提前耗尽。请务必大幅削减开支!` + }; + } + + // Case 3: Pattern detection - Nickel and Dimed + if (patternMatched(isNickelAndDimed, 'warning')) { + return { + type: 'info', + text: `🐜 蚂蚁搬家式消费!虽然单笔不贵,但已累计 ${smallTxCount} 笔小额支出。警惕“拿铁效应”,这些琐碎开支正在悄悄吃掉您的预算。` + }; + } + + // Case 4: Pattern detection - Whale + if (patternMatched(isWhaleMoved, 'info')) { + return { + type: 'info', + text: `🐋 巨鲸出没!仅 ${largeTx.length} 笔大额支出就占据了绝大部分开销。控制好大件消费是守住预算的关键。` + }; + } + + // Case 5: Projected to Overspend + if (projectedTotal > total * 1.05) { // 5% tolerance + return { + type: 'warning', + text: `📉 趋势不妙:虽然现在还有余额,但预估期末将超支 ${formatCurrency(projectedOverspend, 'CNY')}。建议将日均消费控制在 ${formatCurrency(remaining / daysRemaining, 'CNY')} 以内。` + }; + } + + // Case 6: Early Spree + if (ratio > 0.4 && timeRatio < 0.2) { + return { + type: 'warning', + text: `🔥 开局太猛了!刚开始就花了近一半预算。如果是为了囤货或交租那没问题,否则需要“急刹车”了。` + }; + } + + // Case 7: Healthy + if (projectedTotal < total * 0.85) { + const projectedSavings = total - projectedTotal; + return { + type: 'success', + text: `✨ 表现完美!按当前节奏,${categoryPrefix}期末预计能结余 ${formatCurrency(projectedSavings, 'CNY')}。继续保持这份从容!` + }; + } + + return { + type: 'normal', + text: `✅ 一切尽在掌握。当前消费节奏稳健,继续保持,本周期安全无忧。` + }; +}; + +// Helper to avoid TS errors or cleaner logic usage +function patternMatched(condition: boolean, type: string) { + return condition; +} + +export const BudgetDetailModal: React.FC = ({ + budget, + onClose, + onEdit, + onAddTransaction, + refreshTrigger = 0, + categoryName +}) => { + const [transactions, setTransactions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadTransactions = async () => { + setIsLoading(true); + try { + // Determine date range based on budget period + const { startDate, endDate } = getPeriodDates(budget.periodType, budget.startDate); + + const response = await getTransactions({ + startDate, + endDate, + categoryId: budget.categoryId, + accountId: budget.accountId, + type: 'expense', // Budgets are usually for expenses + pageSize: 50 // Limit to reasonable number + }); + setTransactions(response.items); + } catch (error) { + console.error('Failed to load budget transactions:', error); + } finally { + setIsLoading(false); + } + }; + + loadTransactions(); + }, [budget, refreshTrigger]); + + const remaining = (budget.amount || 0) - (budget.spent || 0); + + return ( +
+
e.stopPropagation()}> +
+
+

{budget.name}

+ + {getPeriodTypeLabel(budget.periodType)}周期 + +
+
+ {onEdit && ( + + )} + +
+
+ +
+
+
+ 总预算 + + {formatCurrency(budget.amount, 'CNY')} + +
+
+
+ 已使用 + + {formatCurrency(budget.spent || 0, 'CNY')} + + + {((budget.spent || 0) / budget.amount * 100).toFixed(0)}% + +
+
+
+ 剩余 + + {formatCurrency(remaining, 'CNY')} + +
+
+ + {/* AI Insight */} + {(() => { + const insight = generateInsight(budget, transactions, + getPeriodDates(budget.periodType, budget.startDate).startDate, + getPeriodDates(budget.periodType, budget.startDate).endDate, + categoryName + ); + + return ( +
+
+ +
+
+ AI 预算助理 +

{insight.text}

+
+
+ ); + })()} + + {/* Simple Trend Chart */} + {!isLoading && transactions.length > 0 && ( +
+
+ {(() => { + // Calculate daily layout + const dailyMap = new Map(); + transactions.forEach(t => { + const d = t.transactionDate.split('T')[0].slice(5); // MM-DD + dailyMap.set(d, (dailyMap.get(d) || 0) + t.amount); + }); + const dailyData = Array.from(dailyMap.entries()) + .map(([date, amount]) => ({ date, amount })) + .sort((a, b) => a.date.localeCompare(b.date)); + // Limit to last 7 days or substantial days if too many + const chartData = dailyData.slice(-14); + const maxVal = Math.max(...chartData.map(d => d.amount), 1); + + return chartData.map(d => ( +
+
+ {d.date} +
+ )); + })()} +
+
+ )} + +
+
+

+ + 周期内支出明细 +

+ {onAddTransaction && ( + + )} +
+ + {isLoading ? ( +
+
+
+ ) : transactions.length > 0 ? ( +
+ {transactions.map(transaction => ( + + ))} +
+ ) : ( +
+ +

本周期内暂无关联支出

+
+ )} +
+
+
+
+ ); +}; + +export default BudgetDetailModal; diff --git a/src/components/budget/PiggyBankCard/PiggyBankCard.tsx b/src/components/budget/PiggyBankCard/PiggyBankCard.tsx index f6af27e..99242ec 100644 --- a/src/components/budget/PiggyBankCard/PiggyBankCard.tsx +++ b/src/components/budget/PiggyBankCard/PiggyBankCard.tsx @@ -35,9 +35,11 @@ export const PiggyBankCard: React.FC = ({ }) => { const [showActions, setShowActions] = useState(false); const typeLabel = getPiggyBankTypeLabel(piggyBank.type); - const remaining = piggyBank.targetAmount - piggyBank.currentAmount; - const progress = piggyBank.targetAmount > 0 - ? Math.min((piggyBank.currentAmount / piggyBank.targetAmount) * 100, 100) + const currentAmount = piggyBank.currentAmount || 0; + const targetAmount = piggyBank.targetAmount || 0; + const remaining = targetAmount - currentAmount; + const progress = targetAmount > 0 + ? Math.min((currentAmount / targetAmount) * 100, 100) : 0; const isCompleted = progress >= 100; const daysRemaining = calculateDaysRemaining(piggyBank.targetDate); @@ -95,9 +97,9 @@ export const PiggyBankCard: React.FC = ({
{isCompleted ? ( - + ) : ( - + )}
@@ -146,7 +148,16 @@ export const PiggyBankCard: React.FC = ({
diff --git a/src/components/budget/PiggyBankTransactionModal/PiggyBankTransactionModal.css b/src/components/budget/PiggyBankTransactionModal/PiggyBankTransactionModal.css index 67ad7eb..ef07d52 100644 --- a/src/components/budget/PiggyBankTransactionModal/PiggyBankTransactionModal.css +++ b/src/components/budget/PiggyBankTransactionModal/PiggyBankTransactionModal.css @@ -81,6 +81,52 @@ color: var(--color-text-secondary); } +.piggy-bank-transaction-modal__source-account { + margin-top: 1rem; + padding: 0.75rem; + background-color: var(--color-bg); + border-radius: 8px; + border: 1px dashed var(--color-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.piggy-bank-transaction-modal__source-account.warning { + background-color: #FEF3C7; + border-color: #F59E0B; + flex-direction: column; + gap: 0.25rem; + align-items: flex-start; +} + +.piggy-bank-transaction-modal__source-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--color-text-secondary); + font-weight: 500; +} + +.piggy-bank-transaction-modal__source-details { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; +} + +.piggy-bank-transaction-modal__source-preview { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + color: var(--color-success); + background: rgba(34, 197, 94, 0.1); + padding: 0 0.375rem; + border-radius: 4px; +} + .piggy-bank-transaction-modal__form { padding: 1.5rem; display: flex; @@ -143,6 +189,79 @@ margin-top: -0.25rem; } +/* Progress Preview */ +.piggy-bank-transaction-modal__progress-container { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.piggy-bank-transaction-modal__progress-labels { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: var(--color-text-secondary); +} + +.piggy-bank-transaction-modal__progress-val { + font-weight: 600; + color: var(--color-text); + display: flex; + gap: 0.25rem; +} + +.piggy-bank-transaction-modal__progress-preview { + color: var(--color-primary); +} + +.piggy-bank-transaction-modal__progress-track { + position: relative; + height: 8px; + background-color: var(--color-border); + border-radius: 4px; + overflow: hidden; +} + +.piggy-bank-transaction-modal__progress-fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + background-color: var(--color-primary); + border-radius: 4px; +} + +.piggy-bank-transaction-modal__progress-projected { + position: absolute; + top: 0; + height: 100%; + background-color: var(--color-success); + opacity: 0.6; + border-radius: 0 4px 4px 0; + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-size: 1rem 1rem; + animation: progress-stripe 1s linear infinite; +} + +.piggy-bank-transaction-modal__progress-withdraw { + position: absolute; + top: 0; + height: 100%; + background-color: var(--color-error); + opacity: 0.8; +} + +@keyframes progress-stripe { + from { + background-position: 1rem 0; + } + + to { + background-position: 0 0; + } +} + .piggy-bank-transaction-modal__quick-amounts { display: grid; grid-template-columns: repeat(4, 1fr); @@ -270,4 +389,4 @@ .piggy-bank-transaction-modal__button { width: 100%; } -} +} \ No newline at end of file diff --git a/src/components/budget/PiggyBankTransactionModal/PiggyBankTransactionModal.tsx b/src/components/budget/PiggyBankTransactionModal/PiggyBankTransactionModal.tsx index f3743d6..33f9585 100644 --- a/src/components/budget/PiggyBankTransactionModal/PiggyBankTransactionModal.tsx +++ b/src/components/budget/PiggyBankTransactionModal/PiggyBankTransactionModal.tsx @@ -4,8 +4,9 @@ */ import React, { useState } from 'react'; -import type { PiggyBank } from '../../../types'; +import type { PiggyBank, Account } from '../../../types'; import { formatCurrency } from '../../../utils/format'; +import { Icon } from '@iconify/react'; import './PiggyBankTransactionModal.css'; interface PiggyBankTransactionModalProps { @@ -13,6 +14,7 @@ interface PiggyBankTransactionModalProps { type: 'deposit' | 'withdraw'; onSubmit: (amount: number, note?: string) => void; onCancel: () => void; + linkedAccount?: Account; // Added prop isLoading?: boolean; } @@ -21,6 +23,7 @@ export const PiggyBankTransactionModal: React.FC type, onSubmit, onCancel, + linkedAccount, isLoading = false, }) => { const [amount, setAmount] = useState(''); @@ -54,6 +57,11 @@ export const PiggyBankTransactionModal: React.FC return; } + if (isDeposit && linkedAccount && numAmount > linkedAccount.balance) { + setError(`账户余额不足 (可用: ${formatCurrency(linkedAccount.balance, linkedAccount.currency)})`); + return; + } + onSubmit(numAmount, note.trim() || undefined); }; @@ -100,6 +108,49 @@ export const PiggyBankTransactionModal: React.FC 当前: {formatCurrency(piggyBank.currentAmount, 'CNY')}
+ + {piggyBank.targetAmount > 0 && ( +
+
+ 进度 + + {Math.min((piggyBank.currentAmount / piggyBank.targetAmount) * 100, 100).toFixed(1)}% + {amount && !isNaN(parseFloat(amount)) && parseFloat(amount) > 0 && ( + + {' '}→ {Math.min(((piggyBank.currentAmount + (isDeposit ? parseFloat(amount) : -parseFloat(amount))) / piggyBank.targetAmount) * 100, 100).toFixed(1)}% + + )} + +
+
+ {/* Base Progress */} +
+ {/* Projected Add/Remove (Visualized only for deposit for simplicity in MVP, or handle both) */} + {amount && !isNaN(parseFloat(amount)) && parseFloat(amount) > 0 && isDeposit && ( +
+ )} + {/* Projected Withdraw */} + {amount && !isNaN(parseFloat(amount)) && parseFloat(amount) > 0 && !isDeposit && ( +
+ )} +
+
+ )}
@@ -124,8 +175,49 @@ export const PiggyBankTransactionModal: React.FC {error && {error}}
+ {linkedAccount ? ( +
+
+ + {isDeposit ? '资金来源:' : '资金退回至:'} +
+
+
+ {linkedAccount.name} + + (余: {formatCurrency(linkedAccount.balance, linkedAccount.currency)}) + +
+ {amount && !isNaN(parseFloat(amount)) && ( +
+ + + {isDeposit + ? formatCurrency(linkedAccount.balance - parseFloat(amount), linkedAccount.currency) + : formatCurrency(linkedAccount.balance + parseFloat(amount), linkedAccount.currency) + } + +
+ )} +
+
+ ) : ( +
+ +
+ 未关联父账户 + + {isDeposit ? '将作为额外收入存入' : '将作为额外支出取出'},不影响其他账户余额 + +
+
+ )} + {quickAmounts.length > 0 && ( -
+
{quickAmounts.map((value) => (