Files
keeppay/app/fund-pool/page.tsx
T
root d1b1f7fd26 feat(animation): 引入 framer-motion 实现动画效果
- 在多个组件中集成 framer-motion,增强用户界面交互体验
- 更新页面过渡效果,提升页面切换流畅度
- 优化数据表格和日历单元格的动画效果

这些更改为应用程序提供了更生动的用户体验。
2026-05-13 20:18:34 +08:00

297 lines
14 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import { PageTransition } from '@/components/page-transition'
import { NavHeader } from '@/components/nav-header'
import { motion } from 'framer-motion'
import { AnimatedCard, AnimatedCardContent, AnimatedNumber } from '@/components/animated'
import { staggerContainer, staggerItem } from '@/lib/motion'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { DataTable } from '@/components/data-table'
import { DeletePopover } from '@/components/delete-popover'
import { CardStatsSkeleton, TableSkeleton } from '@/components/loading-skeleton'
import { ExpenseDialog } from './expense-dialog'
import type { ColumnDef } from '@tanstack/react-table'
import { Plus, Settings2, Edit2 } from 'lucide-react'
import { formatMoney } from '@/lib/money-utils'
interface FundPoolExpense {
id: string
amount: number
category: string
note: string
handler: string
date: string
}
export default function FundPoolPage() {
const [refreshKey, setRefreshKey] = useState(0)
const [balance, setBalance] = useState<{
balance: number
totalAllocated: number
totalExpenses: number
maxPoolAmount: number
monthlyPercent: number
} | null>(null)
const [loading, setLoading] = useState(true)
const [config, setConfig] = useState<{ monthlyPercent: number; maxPoolAmount: number }>({
monthlyPercent: 0,
maxPoolAmount: 0,
})
const [expenses, setExpenses] = useState<FundPoolExpense[]>([])
const [configOpen, setConfigOpen] = useState(false)
const [expenseOpen, setExpenseOpen] = useState(false)
const [editingExpense, setEditingExpense] = useState<FundPoolExpense | null>(null)
const [allocMessage, setAllocMessage] = useState('')
useEffect(() => {
Promise.all([
fetch('/api/fund-pool/balance').then((r) => r.json()),
fetch('/api/fund-pool/config').then((r) => r.json()),
fetch('/api/fund-pool/expenses').then((r) => r.json()),
])
.then(([b, c, e]) => {
setBalance(b)
setConfig(c)
setExpenses(e)
})
.finally(() => setLoading(false))
.catch(() => {})
}, [refreshKey])
const handleConfigSave = async () => {
await fetch('/api/fund-pool/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
setConfigOpen(false)
setRefreshKey((n) => n + 1)
}
const handleExpenseSave = async (data: any) => {
if (editingExpense) {
await fetch(`/api/fund-pool/expenses/${editingExpense.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
} else {
await fetch('/api/fund-pool/expenses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
}
setEditingExpense(null)
setRefreshKey((n) => n + 1)
}
const handleDeleteExpense = async (id: string) => {
await fetch(`/api/fund-pool/expenses/${id}`, { method: 'DELETE' })
setRefreshKey((n) => n + 1)
}
const handleAllocate = async () => {
setAllocMessage('')
const res = await fetch('/api/fund-pool/allocate', { method: 'POST' })
const data = await res.json()
if (data.error) {
setAllocMessage(data.error)
} else {
setAllocMessage(`入账成功: ¥${formatMoney(data.amount)}`)
}
setRefreshKey((n) => n + 1)
setTimeout(() => setAllocMessage(''), 3000)
}
const columns: ColumnDef<FundPoolExpense>[] = [
{ id: 'date', header: '日期', accessorKey: 'date' },
{ id: 'category', header: '分类', accessorKey: 'category' },
{
id: 'amount',
header: '金额',
accessorKey: 'amount',
cell: ({ row }) => `¥${formatMoney(row.original.amount)}`,
},
{ id: 'note', header: '备注', accessorKey: 'note' },
{ id: 'handler', header: '经办人', accessorKey: 'handler' },
{
id: 'actions',
header: '操作',
cell: ({ row }) => (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7"
onClick={() => {
setEditingExpense(row.original)
setExpenseOpen(true)
}}
>
<Edit2 className="h-3.5 w-3.5 mr-1" />
</Button>
<DeletePopover onConfirm={() => handleDeleteExpense(row.original.id)} />
</div>
),
},
]
return (
<div className="h-full flex flex-col">
<NavHeader />
<PageTransition className="flex-1">
<div className="flex flex-col p-6 gap-4 overflow-hidden h-full">
<div className="flex items-center justify-between shrink-0">
<h1 className="text-xl font-bold"></h1>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleAllocate}>
</Button>
<Button size="sm" variant="outline" onClick={() => setConfigOpen(true)}>
<Settings2 className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
{allocMessage && (
<div className="p-2 bg-primary/10 rounded text-sm text-primary shrink-0">{allocMessage}</div>
)}
{loading ? (
<>
<CardStatsSkeleton count={4} />
<div className="flex-1 flex flex-col min-h-0 mt-4">
<TableSkeleton rows={5} />
</div>
</>
) : (
<>
<motion.div
className="flex gap-4 shrink-0"
variants={staggerContainer}
initial="hidden"
animate="visible"
>
<motion.div className="flex-1 h-full" variants={staggerItem}>
<AnimatedCard className="h-full">
<AnimatedCardContent className="p-4 h-full flex flex-col justify-center">
<div className="text-xs text-muted-foreground"></div>
<div className="text-2xl font-bold text-foreground">
¥<AnimatedNumber value={balance?.balance ?? 0} />
</div>
<div className="text-xs text-muted-foreground mt-0.5">
</div>
</AnimatedCardContent>
</AnimatedCard>
</motion.div>
<motion.div className="flex-1 h-full" variants={staggerItem}>
<AnimatedCard className="h-full">
<AnimatedCardContent className="p-4 h-full flex flex-col justify-center">
<div className="text-xs text-muted-foreground"></div>
<div className="text-2xl font-bold text-foreground">
¥<AnimatedNumber value={balance?.totalAllocated ?? 0} />
</div>
<div className="text-xs text-muted-foreground mt-0.5"></div>
</AnimatedCardContent>
</AnimatedCard>
</motion.div>
<motion.div className="flex-1 h-full" variants={staggerItem}>
<AnimatedCard className="h-full">
<AnimatedCardContent className="p-4 h-full flex flex-col justify-center">
<div className="text-xs text-muted-foreground"></div>
<div className="text-2xl font-bold text-foreground">
¥<AnimatedNumber value={balance?.totalExpenses ?? 0} />
</div>
<div className="text-xs text-muted-foreground mt-0.5"></div>
</AnimatedCardContent>
</AnimatedCard>
</motion.div>
<motion.div className="flex-1 h-full" variants={staggerItem}>
<AnimatedCard className="h-full">
<AnimatedCardContent className="p-4 h-full flex flex-col justify-center">
<div className="text-xs text-muted-foreground"></div>
<div className="text-2xl font-bold text-foreground">
¥<AnimatedNumber value={balance?.maxPoolAmount ?? 0} />
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{config.monthlyPercent}%
</div>
</AnimatedCardContent>
</AnimatedCard>
</motion.div>
</motion.div>
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-2 shrink-0">
<h2 className="text-sm font-semibold text-muted-foreground"></h2>
<Button size="sm" onClick={() => setExpenseOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<DataTable columns={columns} data={expenses} pageSize={20} tableMinWidth="700px" />
</div>
</>
)}
</div>
{configOpen && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={() => setConfigOpen(false)}
>
<div
className="bg-background rounded-lg p-6 w-[400px] shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="space-y-4">
<div>
<label className="text-sm text-muted-foreground"> (%)</label>
<Input
type="number"
value={config.monthlyPercent}
onChange={(e) =>
setConfig((p) => ({ ...p, monthlyPercent: Number(e.target.value) }))
}
/>
</div>
<div>
<label className="text-sm text-muted-foreground"> ()</label>
<Input
type="number"
value={config.maxPoolAmount}
onChange={(e) =>
setConfig((p) => ({ ...p, maxPoolAmount: Number(e.target.value) }))
}
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button variant="outline" onClick={() => setConfigOpen(false)}>
</Button>
<Button onClick={handleConfigSave}></Button>
</div>
</div>
</div>
)}
<ExpenseDialog
open={expenseOpen}
onOpenChange={(v) => {
setExpenseOpen(v)
if (!v) setEditingExpense(null)
}}
onSave={handleExpenseSave}
editRecord={editingExpense}
/>
</PageTransition>
</div>
)
}