finance-calculator/components/finance-calculator.tsx

778 lines
30 KiB
TypeScript

"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, 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"
import AdBanner from "@/components/AdBanner"
import { useLocalStorage, LocalFinanceProduct } from "@/hooks/use-local-storage"
import { useDataSync } from "@/hooks/use-data-sync"
import { DataSyncDialog } from "@/components/data-sync-dialog"
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 [serverProducts, setServerProducts] = useState<FinanceProduct[]>([])
const [isServerLoading, setIsServerLoading] = useState(false)
// 本地数据hooks
const {
products: localProducts,
isLoading: isLocalLoading,
addProduct: addLocalProduct,
updateProduct: updateLocalProduct,
removeProduct: removeLocalProduct,
hasLocalData,
getAllLocalData
} = useLocalStorage()
// 数据同步hooks
const { isSyncing, syncLocalToServer, fetchServerData } = useDataSync()
// 同步对话框状态
const [showSyncDialog, setShowSyncDialog] = useState(false)
const [hasCheckedSync, setHasCheckedSync] = useState(false)
// 获取当前使用的产品列表
const currentProducts = status === 'authenticated' ? serverProducts : localProducts
// 创建空产品的函数
function createEmptyProduct(products?: FinanceProduct[]): NewProduct {
const productList = products || currentProducts
const lastProduct = productList.length > 0 ? productList[productList.length - 1] : null
return {
principal: lastProduct ? lastProduct.principal : 0,
depositDate: null,
endDate: null,
currentNetValue: null,
annualRate: null,
currency: lastProduct ? lastProduct.currency : "RMB",
notes: null,
}
}
// 表单状态
const [newProduct, setNewProduct] = useState<NewProduct>(() => createEmptyProduct([]))
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
// 编辑相关状态
const [editingProductId, setEditingProductId] = useState<string | null>(null)
const [editingProduct, setEditingProduct] = useState<NewProduct>(() => createEmptyProduct([]))
const [isUpdating, setIsUpdating] = useState(false)
// 从服务器加载产品
async function loadServerProducts() {
if (!session?.user?.id) return
setIsServerLoading(true)
try {
const data = await fetchServerData()
setServerProducts(data)
} catch (error) {
console.error('Failed to load server products:', error)
} finally {
setIsServerLoading(false)
}
}
// 当用户登录状态改变时处理数据加载和同步
useEffect(() => {
if (status === 'authenticated') {
loadServerProducts()
// 检查是否有本地数据需要同步
if (!hasCheckedSync && hasLocalData()) {
setShowSyncDialog(true)
setHasCheckedSync(true)
}
} else if (status === 'unauthenticated') {
setServerProducts([])
setHasCheckedSync(false)
}
}, [status, session?.user?.id])
// 数据同步处理
const handleSyncConfirm = async () => {
const localData = getAllLocalData()
const success = await syncLocalToServer(localData)
if (success) {
// 重新加载服务器数据
await loadServerProducts()
}
}
const handleSyncCancel = async () => {
// 直接从服务器加载数据,不同步本地数据
await loadServerProducts()
}
// 添加产品
async function handleAddProduct() {
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 {
if (status === 'authenticated') {
// 已登录:保存到服务器
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,
}
const response = await fetch('/api/products', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestData),
})
if (response.ok) {
const responseData = await response.json()
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,
}
setServerProducts([productWithDates, ...serverProducts])
toast.success('产品添加成功')
} else {
const responseData = await response.json()
setError(responseData.error || '添加产品失败')
toast.error('添加产品失败')
}
} else {
// 未登录:保存到本地存储
addLocalProduct({
principal: newProduct.principal,
depositDate: newProduct.depositDate,
endDate: newProduct.endDate,
currentNetValue: newProduct.currentNetValue,
annualRate: newProduct.annualRate,
currency: newProduct.currency,
notes: newProduct.notes,
})
toast.success('产品已保存到本地')
}
setNewProduct(createEmptyProduct([]))
} catch (error) {
console.error('Request error:', error)
setError('网络请求失败,请重试')
toast.error('网络请求失败,请重试')
} finally {
setIsSubmitting(false)
}
}
// 删除产品
async function handleRemoveProduct(id: string) {
if (status === 'authenticated') {
// 从服务器删除
try {
const response = await fetch(`/api/products/${id}`, {
method: 'DELETE',
})
if (response.ok) {
setServerProducts(serverProducts.filter(product => product.id !== id))
toast.success('产品删除成功')
} else {
toast.error('删除产品失败')
}
} catch (error) {
toast.error('网络请求失败')
}
} else {
// 从本地存储删除
removeLocalProduct(id)
toast.success('产品已从本地删除')
}
}
// 编辑产品
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 (!editingProductId) return
setIsUpdating(true)
try {
if (status === 'authenticated') {
// 更新服务器数据
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,
}
const response = await fetch(`/api/products/${editingProductId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestData),
})
if (response.ok) {
const responseData = await response.json()
const updatedProduct = {
...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,
}
setServerProducts(serverProducts.map(product =>
product.id === editingProductId ? updatedProduct : product
))
toast.success('产品更新成功')
} else {
toast.error('更新产品失败')
}
} else {
// 更新本地数据
updateLocalProduct(editingProductId, editingProduct)
toast.success('产品已在本地更新')
}
setEditingProductId(null)
setEditingProduct(createEmptyProduct([]))
} catch (error) {
toast.error('网络请求失败')
} finally {
setIsUpdating(false)
}
}
function handleUseCurrentTime() {
setNewProduct({ ...newProduct, endDate: new Date() })
}
function handleUseCurrentTimeForEdit() {
setEditingProduct({ ...editingProduct, endDate: new Date() })
}
// 更新空产品状态
useEffect(() => {
if (currentProducts.length > 0) {
setNewProduct((prev) => ({
...prev,
principal: currentProducts[0].principal,
}))
}
}, [currentProducts.length])
// 加载中状态
if (status === 'loading' || isLocalLoading || isServerLoading) {
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">
{/* 数据同步对话框 */}
<DataSyncDialog
open={showSyncDialog}
onOpenChange={setShowSyncDialog}
localProducts={getAllLocalData()}
onSyncConfirm={handleSyncConfirm}
onSyncCancel={handleSyncCancel}
isSyncing={isSyncing}
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span></span>
{status === 'unauthenticated' && (
<span className="text-sm font-normal text-orange-600 bg-orange-50 px-2 py-1 rounded">
</span>
)}
</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 className="flex items-center justify-between">
<span></span>
{currentProducts.length > 0 && (
<span className="text-sm font-normal text-muted-foreground">
{status === 'authenticated' ? '云端数据' : '本地数据'} · {currentProducts.length}
</span>
)}
</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>
{currentProducts.length === 0 ? (
<TableRow>
<TableCell colSpan={12} className="text-center">
{status === 'authenticated' ? '暂无云端数据' : '暂无本地数据'}
</TableCell>
</TableRow>
) : (
currentProducts.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="secondary" 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-20"
/>
</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"
/>
</TableCell>
<TableCell>
<Select
value={editingProduct.currency}
onValueChange={(value) => setEditingProduct({
...editingProduct,
currency: value
})}
>
<SelectTrigger className="w-20">
<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-20"
placeholder="备注"
/>
</TableCell>
<TableCell colSpan={4}>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleSaveEdit}
disabled={isUpdating}
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCancelEdit}
disabled={isUpdating}
>
<X className="h-4 w-4" />
</Button>
</div>
</TableCell>
</>
) : (
// 显示模式的行
<>
<TableCell>{product.principal.toLocaleString()}</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.toLocaleString() : "-"}
</TableCell>
<TableCell>
{product.calculatedAnnualRate !== null
? `${product.calculatedAnnualRate.toFixed(2)}%`
: product.annualRate !== null
? `${product.annualRate.toFixed(2)}%`
: "-"}
</TableCell>
<TableCell>{product.currency}</TableCell>
<TableCell>{product.notes || "-"}</TableCell>
<TableCell className={product.profit && product.profit > 0 ? "text-green-600" : product.profit && product.profit < 0 ? "text-red-600" : ""}>
{product.profit !== null ? product.profit.toLocaleString() : "-"}
</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-2">
<Button
size="sm"
variant="outline"
onClick={() => handleEditProduct(product)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleRemoveProduct(product.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<AdBanner />
</div>
)
}