finance-calculator/components/finance-calculator.tsx
2025-06-14 02:55:54 +08:00

756 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useState, useEffect } from "react"
import { useSession } from "next-auth/react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Label } from "@/components/ui/label"
import { CalendarIcon, PlusCircle, Trash2, LogIn, Edit, Check, X } from "lucide-react"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { format } from "date-fns"
import { zhCN } from "date-fns/locale"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { toast } from "sonner"
interface FinanceProduct {
id: string
principal: number
depositDate: Date | null
endDate: Date | null
currentNetValue: number | null
annualRate: number | null
profit: number | null
dailyProfit: number | null
monthlyProfit: number | null
calculatedAnnualRate: number | null
averageAnnualProfit: number | null
currency: string
notes: string | null
createdAt?: Date
updatedAt?: Date
}
interface NewProduct {
principal: number
depositDate: Date | null
endDate: Date | null
currentNetValue: number | null
annualRate: number | null
currency: string
notes: string | null
}
export default function FinanceCalculator() {
const { data: session, status } = useSession()
const [products, setProducts] = useState<FinanceProduct[]>([])
const [newProduct, setNewProduct] = useState<NewProduct>(createEmptyProduct())
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
// 编辑相关状态
const [editingProductId, setEditingProductId] = useState<string | null>(null)
const [editingProduct, setEditingProduct] = useState<NewProduct>(createEmptyProduct())
const [isUpdating, setIsUpdating] = useState(false)
function createEmptyProduct(): NewProduct {
const lastProduct = products.length > 0 ? products[products.length - 1] : null
return {
principal: lastProduct ? lastProduct.principal : 0,
depositDate: null,
endDate: null,
currentNetValue: null,
annualRate: null,
currency: lastProduct ? lastProduct.currency : "RMB",
notes: null,
}
}
// 从数据库加载产品
async function loadProducts() {
if (!session?.user?.id) return
setIsLoading(true)
try {
const response = await fetch('/api/products')
if (response.ok) {
const data = await response.json()
// 转换日期字符串为Date对象
const productsWithDates = data.map((product: any) => ({
...product,
depositDate: product.depositDate ? new Date(product.depositDate) : null,
endDate: product.endDate ? new Date(product.endDate) : null,
createdAt: product.createdAt ? new Date(product.createdAt) : undefined,
updatedAt: product.updatedAt ? new Date(product.updatedAt) : undefined,
}))
setProducts(productsWithDates)
} else {
toast.error('加载产品数据失败')
}
} catch (error) {
toast.error('加载产品数据失败')
} finally {
setIsLoading(false)
}
}
// 当用户登录状态改变时加载产品
useEffect(() => {
if (status === 'authenticated') {
loadProducts()
} else if (status === 'unauthenticated') {
setProducts([])
}
}, [status, session?.user?.id])
async function handleAddProduct() {
if (!session?.user?.id) {
toast.error('请先登录')
return
}
if (newProduct.principal <= 0) {
setError("本金必须大于0")
return
}
// 更新验证逻辑:如果有完整的时间和净值信息,则不需要年化利率
const hasCompleteInfo = newProduct.depositDate && newProduct.endDate && newProduct.principal && newProduct.currentNetValue
const hasEitherValue = newProduct.currentNetValue !== null || newProduct.annualRate !== null
if (!hasCompleteInfo && !hasEitherValue) {
setError("请提供足够的信息:要么提供当前净值或年化利率,要么提供完整的时间和净值信息以自动计算年化利率")
return
}
setIsSubmitting(true)
setError(null)
try {
const requestData = {
principal: newProduct.principal,
depositDate: newProduct.depositDate?.toISOString() || null,
endDate: newProduct.endDate?.toISOString() || null,
currentNetValue: newProduct.currentNetValue,
annualRate: newProduct.annualRate,
currency: newProduct.currency,
notes: newProduct.notes,
}
console.log('Sending data to API:', requestData)
const response = await fetch('/api/products', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestData),
})
const responseData = await response.json()
console.log('API response:', responseData)
if (response.ok) {
// 转换日期字符串为Date对象
const productWithDates = {
...responseData,
depositDate: responseData.depositDate ? new Date(responseData.depositDate) : null,
endDate: responseData.endDate ? new Date(responseData.endDate) : null,
createdAt: responseData.createdAt ? new Date(responseData.createdAt) : undefined,
updatedAt: responseData.updatedAt ? new Date(responseData.updatedAt) : undefined,
}
setProducts([productWithDates, ...products])
setNewProduct(createEmptyProduct())
toast.success('产品添加成功')
} else {
console.error('API error response:', responseData)
if (responseData.details && Array.isArray(responseData.details)) {
// 显示详细的验证错误
const errorMessages = responseData.details.map((detail: any) =>
`${detail.field}: ${detail.message}`
).join(', ')
setError(`数据验证失败: ${errorMessages}`)
} else {
setError(responseData.error || '添加产品失败')
}
toast.error('添加产品失败')
}
} catch (error) {
console.error('Request error:', error)
setError('网络请求失败,请重试')
toast.error('网络请求失败,请重试')
} finally {
setIsSubmitting(false)
}
}
// 开始编辑产品
function handleEditProduct(product: FinanceProduct) {
setEditingProductId(product.id)
setEditingProduct({
principal: product.principal,
depositDate: product.depositDate,
endDate: product.endDate,
currentNetValue: product.currentNetValue,
annualRate: product.annualRate,
currency: product.currency,
notes: product.notes,
})
}
// 取消编辑
function handleCancelEdit() {
setEditingProductId(null)
setEditingProduct(createEmptyProduct())
}
// 保存编辑
async function handleSaveEdit() {
if (!session?.user?.id || !editingProductId) {
toast.error('请先登录')
return
}
if (editingProduct.principal <= 0) {
toast.error("本金必须大于0")
return
}
setIsUpdating(true)
try {
const requestData = {
principal: editingProduct.principal,
depositDate: editingProduct.depositDate?.toISOString() || null,
endDate: editingProduct.endDate?.toISOString() || null,
currentNetValue: editingProduct.currentNetValue,
annualRate: editingProduct.annualRate,
currency: editingProduct.currency,
notes: editingProduct.notes,
}
console.log('Updating product with data:', requestData)
const response = await fetch(`/api/products/${editingProductId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestData),
})
const responseData = await response.json()
console.log('Update API response:', responseData)
if (response.ok) {
// 转换日期字符串为Date对象
const updatedProductWithDates = {
...responseData,
depositDate: responseData.depositDate ? new Date(responseData.depositDate) : null,
endDate: responseData.endDate ? new Date(responseData.endDate) : null,
createdAt: responseData.createdAt ? new Date(responseData.createdAt) : undefined,
updatedAt: responseData.updatedAt ? new Date(responseData.updatedAt) : undefined,
}
// 更新产品列表
setProducts(products.map(p =>
p.id === editingProductId ? updatedProductWithDates : p
))
// 清除编辑状态
setEditingProductId(null)
setEditingProduct(createEmptyProduct())
toast.success('产品更新成功')
} else {
console.error('Update API error response:', responseData)
if (responseData.details && Array.isArray(responseData.details)) {
// 显示详细的验证错误
const errorMessages = responseData.details.map((detail: any) =>
`${detail.field}: ${detail.message}`
).join(', ')
toast.error(`数据验证失败: ${errorMessages}`)
} else {
toast.error(responseData.error || '更新产品失败')
}
}
} catch (error) {
console.error('Update request error:', error)
toast.error('网络请求失败,请重试')
} finally {
setIsUpdating(false)
}
}
async function handleRemoveProduct(id: string) {
if (!session?.user?.id) {
toast.error('请先登录')
return
}
try {
const response = await fetch(`/api/products/${id}`, {
method: 'DELETE',
})
if (response.ok) {
setProducts(products.filter((product) => product.id !== id))
toast.success('产品删除成功')
} else {
toast.error('删除产品失败')
}
} catch (error) {
toast.error('删除产品失败')
}
}
function handleUseCurrentTime() {
setNewProduct({
...newProduct,
endDate: new Date(),
})
}
function handleUseCurrentTimeForEdit() {
setEditingProduct({
...editingProduct,
endDate: new Date(),
})
}
// 当产品列表变化时,更新新产品的默认本金
useEffect(() => {
if (products.length > 0) {
setNewProduct((prev) => ({
...prev,
principal: products[0].principal,
}))
}
}, [products.length])
// 如果用户未登录,显示登录提示
if (status === 'unauthenticated') {
return (
<div className="space-y-8">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<LogIn className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-muted-foreground text-center mb-4">
使
</p>
</CardContent>
</Card>
</div>
)
}
// 加载中状态
if (status === 'loading' || isLoading) {
return (
<div className="space-y-8">
<Card>
<CardContent className="py-12">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<span className="ml-2">...</span>
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-8">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="principal"></Label>
<Input
id="principal"
type="number"
value={newProduct.principal || ""}
onChange={(e) => setNewProduct({ ...newProduct, principal: Number.parseFloat(e.target.value) || 0 })}
placeholder="输入本金"
/>
</div>
<div className="space-y-2">
<Label htmlFor="depositDate"></Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!newProduct.depositDate && "text-muted-foreground",
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{newProduct.depositDate ? format(newProduct.depositDate, "PPP", { locale: zhCN }) : "选择日期"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={newProduct.depositDate || undefined}
onSelect={(date) => setNewProduct({ ...newProduct, depositDate: date || null })}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label htmlFor="endDate"></Label>
<div className="flex gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!newProduct.endDate && "text-muted-foreground",
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{newProduct.endDate ? format(newProduct.endDate, "PPP", { locale: zhCN }) : "选择日期"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={newProduct.endDate || undefined}
onSelect={(date) => setNewProduct({ ...newProduct, endDate: date || null })}
initialFocus
/>
</PopoverContent>
</Popover>
<Button variant="secondary" onClick={handleUseCurrentTime}>
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="currentNetValue"></Label>
<Input
id="currentNetValue"
type="number"
value={newProduct.currentNetValue !== null ? newProduct.currentNetValue : ""}
onChange={(e) =>
setNewProduct({
...newProduct,
currentNetValue: e.target.value ? Number.parseFloat(e.target.value) : null,
})
}
placeholder="输入当前净值"
/>
</div>
<div className="space-y-2">
<Label htmlFor="annualRate"> (%)</Label>
<Input
id="annualRate"
type="number"
value={newProduct.annualRate !== null ? newProduct.annualRate : ""}
onChange={(e) =>
setNewProduct({
...newProduct,
annualRate: e.target.value ? Number.parseFloat(e.target.value) : null,
})
}
placeholder="输入年化利率(可自动计算)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="currency"></Label>
<Select value={newProduct.currency} onValueChange={(value) => setNewProduct({ ...newProduct, currency: value })}>
<SelectTrigger>
<SelectValue placeholder="选择货币" />
</SelectTrigger>
<SelectContent>
<SelectItem value="RMB"> (RMB)</SelectItem>
<SelectItem value="USD"> (USD)</SelectItem>
<SelectItem value="EUR"> (EUR)</SelectItem>
<SelectItem value="JPY"> (JPY)</SelectItem>
<SelectItem value="GBP"> (GBP)</SelectItem>
<SelectItem value="HKD"> (HKD)</SelectItem>
<SelectItem value="SGD"> (SGD)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="notes"></Label>
<Input
id="notes"
type="text"
value={newProduct.notes || ""}
onChange={(e) => setNewProduct({ ...newProduct, notes: e.target.value || null })}
placeholder="输入备注"
/>
</div>
<div className="flex items-end">
<Button onClick={handleAddProduct} className="w-full" disabled={isSubmitting}>
<PlusCircle className="mr-2 h-4 w-4" />
{isSubmitting ? "添加中..." : "添加产品"}
</Button>
</div>
</div>
{error && (
<Alert variant="destructive" className="mt-4">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> (%)</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[120px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.length === 0 ? (
<TableRow>
<TableCell colSpan={12} className="text-center">
</TableCell>
</TableRow>
) : (
products.map((product) => (
<TableRow key={product.id}>
{editingProductId === product.id ? (
// 编辑模式的行
<>
<TableCell>
<Input
type="number"
value={editingProduct.principal || ""}
onChange={(e) => setEditingProduct({
...editingProduct,
principal: Number.parseFloat(e.target.value) || 0
})}
className="w-20"
/>
</TableCell>
<TableCell>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<CalendarIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={editingProduct.depositDate || undefined}
onSelect={(date) => setEditingProduct({
...editingProduct,
depositDate: date || null
})}
initialFocus
/>
</PopoverContent>
</Popover>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<CalendarIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={editingProduct.endDate || undefined}
onSelect={(date) => setEditingProduct({
...editingProduct,
endDate: date || null
})}
initialFocus
/>
</PopoverContent>
</Popover>
<Button
variant="ghost"
size="sm"
onClick={handleUseCurrentTimeForEdit}
>
</Button>
</div>
</TableCell>
<TableCell>
<Input
type="number"
value={editingProduct.currentNetValue !== null ? editingProduct.currentNetValue : ""}
onChange={(e) => setEditingProduct({
...editingProduct,
currentNetValue: e.target.value ? Number.parseFloat(e.target.value) : null
})}
className="w-24"
/>
</TableCell>
<TableCell>
<Input
type="number"
value={editingProduct.annualRate !== null ? editingProduct.annualRate : ""}
onChange={(e) => setEditingProduct({
...editingProduct,
annualRate: e.target.value ? Number.parseFloat(e.target.value) : null
})}
className="w-20"
placeholder="自动计算"
/>
</TableCell>
<TableCell>
<Select value={editingProduct.currency} onValueChange={(value) => setEditingProduct({ ...editingProduct, currency: value })}>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="RMB">RMB</SelectItem>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="JPY">JPY</SelectItem>
<SelectItem value="GBP">GBP</SelectItem>
<SelectItem value="HKD">HKD</SelectItem>
<SelectItem value="SGD">SGD</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input
type="text"
value={editingProduct.notes || ""}
onChange={(e) => setEditingProduct({
...editingProduct,
notes: e.target.value || null
})}
className="w-32"
placeholder="备注"
/>
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={handleSaveEdit}
disabled={isUpdating}
>
<Check className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleCancelEdit}
disabled={isUpdating}
>
<X className="h-4 w-4" />
</Button>
</div>
</TableCell>
</>
) : (
// 普通显示模式的行
<>
<TableCell>{product.principal.toFixed(2)}</TableCell>
<TableCell>
{product.depositDate ? format(product.depositDate, "yyyy-MM-dd", { locale: zhCN }) : "-"}
</TableCell>
<TableCell>
{product.endDate ? format(product.endDate, "yyyy-MM-dd", { locale: zhCN }) : "-"}
</TableCell>
<TableCell>
{product.currentNetValue !== null ? product.currentNetValue.toFixed(2) : "-"}
</TableCell>
<TableCell>{product.annualRate !== null ? product.annualRate.toFixed(2) : "-"}</TableCell>
<TableCell>{product.currency}</TableCell>
<TableCell>{product.notes}</TableCell>
<TableCell
className={cn(
product.profit && product.profit > 0
? "text-green-600"
: product.profit && product.profit < 0
? "text-red-600"
: "",
)}
>
{product.profit !== null ? product.profit.toFixed(2) : "-"}
</TableCell>
<TableCell>{product.dailyProfit !== null ? product.dailyProfit.toFixed(2) : "-"}</TableCell>
<TableCell>{product.monthlyProfit !== null ? product.monthlyProfit.toFixed(2) : "-"}</TableCell>
<TableCell>{product.averageAnnualProfit !== null ? product.averageAnnualProfit.toFixed(2) : "-"}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleEditProduct(product)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveProduct(product.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
)
}