feat: add finance module with yearly income/expense tracking
- FinanceEntry model (income/expense x fixed/extra) - CRUD API endpoints for finance entries - FinancePage with summary cards and drill-down - Updated navigation with Finance tab - Updated todos default filter to active
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { financeApi, type FinanceEntry } from '../api'
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
type EntryType = 'income' | 'expense'
|
||||
type Category = 'fixed' | 'extra'
|
||||
|
||||
interface Summary {
|
||||
incomeFixed: number
|
||||
incomeExtra: number
|
||||
expenseFixed: number
|
||||
expenseExtra: number
|
||||
}
|
||||
|
||||
export default function FinancePage() {
|
||||
const [year, setYear] = useState(currentYear)
|
||||
const [entries, setEntries] = useState<FinanceEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedType, setExpandedType] = useState<EntryType | null>(null)
|
||||
const [expandedCat, setExpandedCat] = useState<'fixed' | 'extra' | null>(null)
|
||||
|
||||
// 新增表单
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [newEntry, setNewEntry] = useState({
|
||||
month: 0,
|
||||
type: 'expense' as EntryType,
|
||||
category: 'fixed' as Category,
|
||||
description: '',
|
||||
amount: '',
|
||||
})
|
||||
|
||||
useEffect(() => { loadData(year) }, [year])
|
||||
|
||||
const loadData = async (y: number) => {
|
||||
setLoading(true)
|
||||
const data = await financeApi.getByYear(y)
|
||||
setEntries(data)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const computeSummary = (): Summary => {
|
||||
const s = { incomeFixed: 0, incomeExtra: 0, expenseFixed: 0, expenseExtra: 0 }
|
||||
entries.forEach(e => {
|
||||
if (e.type === 'income' && e.category === 'fixed') s.incomeFixed += e.amount
|
||||
if (e.type === 'income' && e.category === 'extra') s.incomeExtra += e.amount
|
||||
if (e.type === 'expense' && e.category === 'fixed') s.expenseFixed += e.amount
|
||||
if (e.type === 'expense' && e.category === 'extra') s.expenseExtra += e.amount
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
const fmt = (n: number) => n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
const summary = computeSummary()
|
||||
const years = Array.from({ length: 5 }, (_, i) => currentYear - i)
|
||||
|
||||
const filteredEntries = entries.filter(e =>
|
||||
expandedType === null || e.type === expandedType
|
||||
).filter(e =>
|
||||
expandedCat === null || e.category === expandedCat
|
||||
)
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newEntry.description || !newEntry.amount) return
|
||||
const entry = await financeApi.create({
|
||||
year,
|
||||
month: newEntry.month,
|
||||
type: newEntry.type,
|
||||
category: newEntry.category,
|
||||
description: newEntry.description,
|
||||
amount: parseFloat(newEntry.amount),
|
||||
})
|
||||
setEntries([...entries, entry])
|
||||
setShowAdd(false)
|
||||
setNewEntry({ month: 0, type: 'expense', category: 'fixed', description: '', amount: '' })
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await financeApi.delete(id)
|
||||
setEntries(entries.filter(e => e.id !== id))
|
||||
}
|
||||
|
||||
const monthLabel = (m: number) => m === 0 ? '全年' : `${m}月`
|
||||
|
||||
const Card = ({ label, amount, color, onClick }: {
|
||||
label: string
|
||||
amount: number
|
||||
color: string
|
||||
onClick: () => void
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex-1 min-w-0 p-4 rounded-2xl border border-slate-100 text-left hover:shadow-md transition ${color}`}
|
||||
>
|
||||
<div className="text-xs text-slate-500 mb-1">{label}</div>
|
||||
<div className="text-lg font-bold text-slate-800 truncate">¥{fmt(amount)}</div>
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 顶部:年份选择 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-800">💰 经济</h1>
|
||||
<select
|
||||
value={year}
|
||||
onChange={e => setYear(Number(e.target.value))}
|
||||
className="px-3 py-1.5 border border-slate-200 rounded-lg text-sm"
|
||||
>
|
||||
{years.map(y => <option key={y} value={y}>{y}年</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 收支总览卡片 */}
|
||||
<div className="mb-6 space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<Card
|
||||
label="💼 固定收入" amount={summary.incomeFixed}
|
||||
color="bg-blue-50"
|
||||
onClick={() => {
|
||||
setExpandedType(expandedType === 'income' ? null : 'income')
|
||||
setExpandedCat(expandedType === 'income' ? (expandedCat === 'fixed' ? null : 'fixed') : 'fixed')
|
||||
}}
|
||||
/>
|
||||
<Card
|
||||
label="💵 额外收入" amount={summary.incomeExtra}
|
||||
color="bg-green-50"
|
||||
onClick={() => {
|
||||
setExpandedType(expandedType === 'income' ? null : 'income')
|
||||
setExpandedCat(expandedType === 'income' ? (expandedCat === 'extra' ? null : 'extra') : 'extra')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Card
|
||||
label="🏠 固定支出" amount={summary.expenseFixed}
|
||||
color="bg-red-50"
|
||||
onClick={() => {
|
||||
setExpandedType(expandedType === 'expense' ? null : 'expense')
|
||||
setExpandedCat(expandedType === 'expense' ? (expandedCat === 'fixed' ? null : 'fixed') : 'fixed')
|
||||
}}
|
||||
/>
|
||||
<Card
|
||||
label="🛒 额外支出" amount={summary.expenseExtra}
|
||||
color="bg-orange-50"
|
||||
onClick={() => {
|
||||
setExpandedType(expandedType === 'expense' ? null : 'expense')
|
||||
setExpandedCat(expandedType === 'expense' ? (expandedCat === 'extra' ? null : 'extra') : 'extra')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 结算 */}
|
||||
<div className="flex items-center justify-between p-4 bg-slate-100 rounded-2xl">
|
||||
<span className="text-sm text-slate-600">年结余</span>
|
||||
<span className={`text-xl font-bold ${summary.incomeFixed + summary.incomeExtra - summary.expenseFixed - summary.expenseExtra >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
¥{fmt(summary.incomeFixed + summary.incomeExtra - summary.expenseFixed - summary.expenseExtra)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 明细列表 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-bold text-slate-800">明细 {filteredEntries.length}条</h2>
|
||||
<button
|
||||
onClick={() => setShowAdd(!showAdd)}
|
||||
className="text-sm text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{showAdd ? '取消添加' : '+ 添加条目'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="p-3 bg-slate-50 rounded-xl mb-3 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={newEntry.type}
|
||||
onChange={e => setNewEntry({ ...newEntry, type: e.target.value as EntryType })}
|
||||
className="px-2 py-1.5 border border-slate-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="expense">支出</option>
|
||||
<option value="income">收入</option>
|
||||
</select>
|
||||
<select
|
||||
value={newEntry.category}
|
||||
onChange={e => setNewEntry({ ...newEntry, category: e.target.value as Category })}
|
||||
className="px-2 py-1.5 border border-slate-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="fixed">固定</option>
|
||||
<option value="extra">额外</option>
|
||||
</select>
|
||||
<select
|
||||
value={newEntry.month}
|
||||
onChange={e => setNewEntry({ ...newEntry, month: Number(e.target.value) })}
|
||||
className="px-2 py-1.5 border border-slate-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value={0}>全年</option>
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map(m => (
|
||||
<option key={m} value={m}>{m}月</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntry.description}
|
||||
onChange={e => setNewEntry({ ...newEntry, description: e.target.value })}
|
||||
placeholder="描述(如:工资、房租)"
|
||||
className="w-full px-3 py-1.5 border border-slate-200 rounded-lg text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={newEntry.amount}
|
||||
onChange={e => setNewEntry({ ...newEntry, amount: e.target.value })}
|
||||
placeholder="金额"
|
||||
className="flex-1 px-3 py-1.5 border border-slate-200 rounded-lg text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="px-4 py-1.5 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-600"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-slate-400 text-sm py-6">加载中...</div>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<div className="text-center text-slate-400 text-sm py-6">暂无记录</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-96 overflow-y-auto">
|
||||
{filteredEntries.map(entry => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 group ${
|
||||
entry.type === 'income' ? 'bg-green-50/50' : 'bg-red-50/50'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
entry.type === 'income' ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'
|
||||
}`}>
|
||||
{entry.type === 'income' ? '收' : '支'}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
entry.category === 'fixed' ? 'bg-blue-50 text-blue-500' : 'bg-orange-50 text-orange-500'
|
||||
}`}>
|
||||
{entry.category === 'fixed' ? '固定' : '额外'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 w-12">{monthLabel(entry.month)}</span>
|
||||
<span className="flex-1 text-sm text-slate-700 truncate">{entry.description}</span>
|
||||
<span className={`text-sm font-medium ${entry.type === 'income' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{entry.type === 'income' ? '+' : '-'}¥{fmt(entry.amount)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDelete(entry.id)}
|
||||
className="text-slate-300 hover:text-red-500 text-xs opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export default function HomePage() {
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [newPriority, setNewPriority] = useState(2)
|
||||
const [newDueDate, setNewDueDate] = useState('')
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
const [filter, setFilter] = useState<Filter>('active')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const today = new Date()
|
||||
|
||||
Reference in New Issue
Block a user