d1b1f7fd26
- 在多个组件中集成 framer-motion,增强用户界面交互体验 - 更新页面过渡效果,提升页面切换流畅度 - 优化数据表格和日历单元格的动画效果 这些更改为应用程序提供了更生动的用户体验。
297 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|