stock-portfolio/src/app/page.tsx
kennethcheng 4bad47f83d v1.0.4 (2026-04-12)
- 🐛 总资产计算修复:修复了多市场持仓汇总时货币转换错误的问题
- 🐛 浮动盈亏修复:修复了成本基数和市值货币单位不一致导致的错误
- 💹 持仓分析修复:确保所有持仓数据统一转换为 USD 后再汇总
- 📈 港股价格获取:改用腾讯行情接口(r_hk前缀)获取港股实时价格(小鹏汽车等)
- 📈 A股价格获取:改用腾讯行情接口(sz/sh前缀)获取A股实时价格(海尔智家、中证现金流ETF等)
- 📈 美股价格获取:改用腾讯行情接口(s_us前缀)获取美股实时价格
- 🔍 证券名称显示:搜索和选择证券时同时显示代码和中文名称
- 📋 证券数据库扩展:新增小鹏汽车(09868)、海尔智家(600690)、中证现金流ETF(159235)、Alphabet(GOOGL)
- ✏️ 交易流水编辑:支持编辑已创建的交易记录
- 💰 持仓货币随市场:持仓明细和分析中各市场使用对应货币显示(港股用HKD、A股用CNY、美股用USD)
2026-04-12 08:22:52 +08:00

1503 lines
61 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 { useEffect, useState, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import {
PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend,
} from 'recharts'
import {
Wallet, TrendingUp, TrendingDown, Plus, ArrowUpRight, ArrowDownRight,
Bitcoin, Building2, Globe2, RefreshCw, DollarSign, Search, Check,
Download, Upload, Trash2, Edit,
} from 'lucide-react'
import {
fetchAccounts, fetchTransactions, fetchPositions,
fetchSecurities, createTransaction, deleteTransaction, updateTransaction, formatCurrency, formatPercent,
marketLabels, transactionTypeLabels
} from '@/lib/api'
import { Account, Position, Transaction, MarketType, TransactionType, Security } from '@/types'
import { toast } from 'sonner'
import {
exportTransactionsToCSV, exportPositionsToCSV, downloadCSV,
TRANSACTION_IMPORT_TEMPLATE, parseImportCSV, validateImportTransaction, ImportTransaction
} from '@/lib/import-export'
// 市场图标映射
const marketIcons: Record<MarketType, React.ReactNode> = {
US: <Globe2 className="h-4 w-4" />,
CN: <Building2 className="h-4 w-4" />,
HK: <Building2 className="h-4 w-4" />,
CRYPTO: <Bitcoin className="h-4 w-4" />,
}
// 市场颜色配置
const marketColors: Record<MarketType, string> = {
US: '#3b82f6',
CN: '#ef4444',
HK: '#f97316',
CRYPTO: '#eab308',
}
// 持仓分析数据结构
interface PositionAnalytics {
symbol: string
name: string
marketType: string
accountName: string
quantity: number
avgCost: number
currentPrice: number
change: number
changePercent: number
costBasis: number
marketValue: number
marketValueUSD: number
pnl: number
pnlPercent: number
pnlUSD: number
currency: string
isCrypto: boolean
}
// 分析汇总数据结构
interface AnalyticsSummary {
totalCostBasis: number
totalMarketValue: number
totalPnL: number
totalPnLPercent: number
positionCount: number
}
// 货币转换函数(以 USD 为基准)
const EXCHANGE_RATES: Record<string, number> = {
USD: 1,
CNY: 7.24, // 1 USD = 7.24 CNY
HKD: 7.78, // 1 USD = 7.78 HKD
}
function convertCurrency(amount: number, from: string, to: string): number {
if (from === to) return amount
// 先转换为 USD再转换为目标货币
const usdAmount = amount / EXCHANGE_RATES[from]
return usdAmount * EXCHANGE_RATES[to]
}
// 主页面组件
export default function Dashboard() {
// 状态定义
const [accounts, setAccounts] = useState<Account[]>([])
const [transactions, setTransactions] = useState<Transaction[]>([])
const [positions, setPositions] = useState<Position[]>([])
const [securities, setSecurities] = useState<Security[]>([])
const [analytics, setAnalytics] = useState<{ prices: Record<string, any>; positions: PositionAnalytics[]; summary: AnalyticsSummary } | null>(null)
const [loading, setLoading] = useState(true)
const [selectedAccountId, setSelectedAccountId] = useState<string>('')
const [showTxDialog, setShowTxDialog] = useState(false)
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const [showImportDialog, setShowImportDialog] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [positionToDelete, setPositionToDelete] = useState<PositionAnalytics | null>(null)
const [symbolSearch, setSymbolSearch] = useState('')
const [filteredSecurities, setFilteredSecurities] = useState<Security[]>([])
const [importFile, setImportFile] = useState<File | null>(null)
const [importData, setImportData] = useState<ImportTransaction[]>([])
// 显示货币状态(默认 CNY
const [displayCurrency, setDisplayCurrency] = useState<'CNY' | 'USD' | 'HKD'>('CNY')
// 交易记录删除状态
const [transactionToDelete, setTransactionToDelete] = useState<Transaction | null>(null)
const [showDeleteTxDialog, setShowDeleteTxDialog] = useState(false)
// 交易记录编辑状态
const [transactionToEdit, setTransactionToEdit] = useState<Transaction | null>(null)
const [showEditTxDialog, setShowEditTxDialog] = useState(false)
const [editTxForm, setEditTxForm] = useState({
type: 'BUY' as TransactionType,
symbol: '',
quantity: '',
price: '',
amount: '',
fee: '0',
currency: 'USD',
notes: '',
executedAt: new Date().toISOString().slice(0, 16),
})
// 交易表单状态
const [txForm, setTxForm] = useState({
type: 'BUY' as TransactionType,
symbol: '',
quantity: '',
price: '',
amount: '', // 仅用于出金/入金
fee: '0',
currency: 'USD',
notes: '',
executedAt: new Date().toISOString().slice(0, 16),
})
// 获取当前选中的账户对象
const selectedAccount = accounts.find(a => a.id === selectedAccountId)
// 加载数据
const loadData = useCallback(async () => {
try {
setLoading(true)
// 并行加载所有数据
const [accountsData, transactionsData, positionsData, securitiesData, analyticsData] = await Promise.all([
fetchAccounts(),
fetchTransactions({ limit: 50 }),
fetchPositions(),
fetchSecurities(),
fetch('/api/dashboard/analytics').then(r => r.json()).catch(() => null),
])
setAccounts(accountsData)
setTransactions(transactionsData.data || [])
setPositions(positionsData)
setSecurities(securitiesData)
setAnalytics(analyticsData)
// 默认选中第一个账户
if (accountsData.length > 0 && !selectedAccountId) {
setSelectedAccountId(accountsData[0].id)
}
} catch (error) {
toast.error('加载数据失败')
} finally {
setLoading(false)
}
}, [selectedAccountId])
useEffect(() => {
loadData()
}, [loadData])
// 搜索证券(根据输入实时过滤)
useEffect(() => {
if (symbolSearch.length >= 1) {
const filtered = securities.filter(s =>
s.symbol.toLowerCase().includes(symbolSearch.toLowerCase()) ||
s.name.toLowerCase().includes(symbolSearch.toLowerCase())
).slice(0, 8)
setFilteredSecurities(filtered)
} else {
setFilteredSecurities([])
}
}, [symbolSearch, securities])
// 获取证券显示文本(代码+名称)
const getSecurityDisplayText = (symbol: string) => {
if (!symbol) return ''
const sec = securities.find(s => s.symbol === symbol)
return sec ? `${sec.symbol} ${sec.name}` : symbol
}
// 选择证券后自动填充价格和币种
const handleSelectSecurity = (symbol: string) => {
const price = analytics?.prices[symbol]?.price || 0
const sec = securities.find(s => s.symbol === symbol)
setTxForm(prev => ({
...prev,
symbol,
price: price > 0 ? price.toString() : prev.price,
currency: sec?.currency || prev.currency,
}))
setSymbolSearch('')
setFilteredSecurities([])
}
// 当数量或价格变化时,自动计算成交总额
const calculatedAmount = txForm.quantity && txForm.price
? (parseFloat(txForm.quantity) * parseFloat(txForm.price)).toFixed(2)
: ''
// 提交交易记录
const handleSubmitTx = async () => {
try {
if (!selectedAccountId) {
toast.error('请先选择账户')
return
}
const amount = txForm.quantity && txForm.price
? (parseFloat(txForm.quantity) * parseFloat(txForm.price)).toFixed(2)
: txForm.amount
await createTransaction({
accountId: selectedAccountId,
type: txForm.type,
symbol: txForm.symbol || undefined,
quantity: txForm.quantity ? parseFloat(txForm.quantity) : undefined,
price: txForm.price ? parseFloat(txForm.price) : undefined,
amount: parseFloat(amount || '0'),
fee: parseFloat(txForm.fee) || 0,
currency: txForm.currency,
notes: txForm.notes || undefined,
executedAt: txForm.executedAt,
})
toast.success('交易记录成功')
setShowTxDialog(false)
setShowConfirmDialog(false)
resetTxForm()
loadData()
} catch (error) {
toast.error('创建交易失败')
}
}
// 删除持仓
const handleDeletePosition = async () => {
if (!positionToDelete) return
try {
// 通过卖出全部来删除持仓
const position = positions.find(p => p.symbol === positionToDelete.symbol)
if (!position) {
toast.error('未找到持仓记录')
return
}
await createTransaction({
accountId: selectedAccountId || position.accountId,
type: 'SELL',
symbol: positionToDelete.symbol,
quantity: parseFloat(positionToDelete.quantity.toString()),
price: parseFloat(positionToDelete.currentPrice.toString()),
amount: parseFloat((positionToDelete.quantity * positionToDelete.currentPrice).toFixed(2)),
fee: 0,
currency: positionToDelete.currency,
notes: '删除持仓',
executedAt: new Date().toISOString(),
})
toast.success('持仓已删除')
setShowDeleteDialog(false)
setPositionToDelete(null)
loadData()
} catch (error) {
toast.error('删除持仓失败')
}
}
// 打开删除确认对话框
const openDeleteDialog = (pos: PositionAnalytics) => {
setPositionToDelete(pos)
setShowDeleteDialog(true)
}
// 打开删除交易记录对话框
const openDeleteTxDialog = (tx: Transaction) => {
setTransactionToDelete(tx)
setShowDeleteTxDialog(true)
}
// 删除交易记录
const handleDeleteTransaction = async () => {
if (!transactionToDelete) return
try {
await deleteTransaction(transactionToDelete.id)
toast.success('交易记录已删除')
setShowDeleteTxDialog(false)
setTransactionToDelete(null)
loadData()
} catch (error) {
toast.error('删除交易记录失败')
}
}
// 打开编辑交易记录对话框
const openEditTxDialog = (tx: Transaction) => {
setTransactionToEdit(tx)
setEditTxForm({
type: tx.type,
symbol: tx.symbol || '',
quantity: tx.quantity ? parseFloat(tx.quantity.toString()).toString() : '',
price: tx.price ? parseFloat(tx.price.toString()).toString() : '',
amount: parseFloat(tx.amount.toString()).toString(),
fee: parseFloat(tx.fee.toString()).toString(),
currency: tx.currency,
notes: tx.notes || '',
executedAt: new Date(tx.executedAt).toISOString().slice(0, 16),
})
setShowEditTxDialog(true)
}
// 提交编辑交易记录
const handleEditTransaction = async () => {
if (!transactionToEdit) return
try {
const amount = editTxForm.quantity && editTxForm.price
? (parseFloat(editTxForm.quantity) * parseFloat(editTxForm.price)).toFixed(2)
: editTxForm.amount
await updateTransaction(transactionToEdit.id, {
type: editTxForm.type,
symbol: editTxForm.symbol || undefined,
quantity: editTxForm.quantity ? parseFloat(editTxForm.quantity) : undefined,
price: editTxForm.price ? parseFloat(editTxForm.price) : undefined,
amount: parseFloat(amount || '0'),
fee: parseFloat(editTxForm.fee) || 0,
currency: editTxForm.currency,
notes: editTxForm.notes || undefined,
executedAt: editTxForm.executedAt,
})
toast.success('交易记录已更新')
setShowEditTxDialog(false)
setTransactionToEdit(null)
loadData()
} catch (error) {
toast.error('更新交易记录失败')
}
}
// 重置交易表单
const resetTxForm = () => {
setTxForm({
type: 'BUY',
symbol: '',
quantity: '',
price: '',
amount: '',
fee: '0',
currency: selectedAccount?.baseCurrency || 'USD',
notes: '',
executedAt: new Date().toISOString().slice(0, 16),
})
}
// 打开交易对话框
const openTxDialog = () => {
resetTxForm()
setShowTxDialog(true)
}
// 获取确认对话框显示信息
const getConfirmInfo = () => {
const typeLabel = transactionTypeLabels[txForm.type]
const symbolLabel = txForm.symbol ? `${txForm.symbol} ` : ''
const qtyLabel = txForm.quantity ? `${txForm.quantity}` : ''
const priceLabel = txForm.price ? `@ ${formatCurrency(parseFloat(txForm.price), txForm.currency)}` : ''
const total = txForm.quantity && txForm.price
? (parseFloat(txForm.quantity) * parseFloat(txForm.price)).toFixed(2)
: txForm.amount
return {
account: selectedAccount?.name || '未选择',
market: selectedAccount ? marketLabels[selectedAccount.marketType] : '',
action: `${typeLabel} ${symbolLabel}${qtyLabel}${priceLabel}`,
total: total ? `合计 ${formatCurrency(parseFloat(total), txForm.currency)}` : '',
fee: txForm.fee && parseFloat(txForm.fee) > 0 ? `手续费: ${formatCurrency(parseFloat(txForm.fee), txForm.currency)}` : '',
time: new Date(txForm.executedAt).toLocaleString('zh-CN'),
}
}
// 导出交易记录为 CSV
const handleExportTransactions = () => {
const csv = exportTransactionsToCSV(transactions)
const date = new Date().toISOString().slice(0, 10)
downloadCSV(csv, `transactions_${date}.csv`)
toast.success('交易记录已导出')
}
// 导出持仓记录为 CSV
const handleExportPositions = () => {
const csv = exportPositionsToCSV(positions.map(p => ({
...p,
name: analytics?.positions?.find(ap => ap.symbol === p.symbol)?.name || p.symbol,
currentPrice: analytics?.prices[p.symbol]?.price || parseFloat(p.averageCost),
value: parseFloat(p.quantity) * (analytics?.prices[p.symbol]?.price || parseFloat(p.averageCost)),
valueInUSD: parseFloat(p.quantity) * (analytics?.prices[p.symbol]?.price || parseFloat(p.averageCost)),
pnl: 0,
pnlPercent: 0,
})))
const date = new Date().toISOString().slice(0, 10)
downloadCSV(csv, `positions_${date}.csv`)
toast.success('持仓记录已导出')
}
// 下载导入模板 CSV
const handleDownloadTemplate = () => {
downloadCSV(TRANSACTION_IMPORT_TEMPLATE, 'transaction_import_template.csv')
}
// 处理文件导入
const handleFileImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setImportFile(file)
const reader = new FileReader()
reader.onload = (event) => {
const content = event.target?.result as string
const { headers, rows } = parseImportCSV(content)
// 验证每一行数据
const validated = rows.map(row => validateImportTransaction(row, headers))
setImportData(validated)
}
reader.readAsText(file)
}
// 执行批量导入
const handleExecuteImport = async () => {
if (!selectedAccountId) {
toast.error('请先选择导入目标账户')
return
}
const validTxs = importData.filter(tx => tx.errors.length === 0)
if (validTxs.length === 0) {
toast.error('没有有效的交易记录')
return
}
try {
const response = await fetch('/api/import/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transactions: validTxs.map(tx => ({
type: tx.type,
symbol: tx.symbol || null,
quantity: tx.quantity || null,
price: tx.price || null,
amount: tx.amount,
fee: tx.fee || '0',
currency: tx.currency,
notes: tx.notes || null,
executedAt: tx.executedAt,
})),
accountId: selectedAccountId,
}),
})
const result = await response.json()
toast.success(`导入完成: ${result.success} 成功, ${result.failed} 失败`)
setShowImportDialog(false)
setImportData([])
setImportFile(null)
loadData()
} catch (error) {
toast.error('导入失败')
}
}
// 市场分布数据(用于饼图)
const marketDistribution = analytics?.summary ? [
{ name: '美股', value: analytics.summary.totalMarketValue * 0.6, color: marketColors.US },
{ name: 'A股', value: analytics.summary.totalMarketValue * 0.2, color: marketColors.CN },
{ name: '港股', value: analytics.summary.totalMarketValue * 0.15, color: marketColors.HK },
{ name: '加密', value: analytics.summary.totalMarketValue * 0.05, color: marketColors.CRYPTO },
].filter(item => item.value > 0) : []
// 加载中状态
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
return (
<div className="min-h-screen bg-background">
{/* 顶部导航栏 */}
<header className="border-b bg-card/50 backdrop-blur sticky top-0 z-50">
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Wallet className="h-6 w-6 text-primary" />
<h1 className="text-xl font-bold"></h1>
</div>
<div className="flex items-center gap-2">
{/* 显示货币选择 */}
<Select value={displayCurrency} onValueChange={(v) => setDisplayCurrency(v as 'CNY' | 'USD' | 'HKD')}>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CNY">CNY ¥</SelectItem>
<SelectItem value="USD">USD $</SelectItem>
<SelectItem value="HKD">HKD $</SelectItem>
</SelectContent>
</Select>
{/* 账户选择下拉框 */}
<Select value={selectedAccountId} onValueChange={(v) => v && setSelectedAccountId(v)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="选择账户">
{selectedAccount ? (
<div className="flex items-center gap-2">
{marketIcons[selectedAccount.marketType]}
<span>{selectedAccount.name}</span>
</div>
) : '选择账户'}
</SelectValue>
</SelectTrigger>
<SelectContent>
{accounts.map((acc) => (
<SelectItem key={acc.id} value={acc.id}>
<div className="flex items-center gap-2">
{marketIcons[acc.marketType]}
<span>{acc.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* 导入按钮 */}
<Button variant="outline" size="sm" onClick={() => setShowImportDialog(true)}>
<Upload className="h-4 w-4 mr-2" />
</Button>
{/* 导出按钮 */}
<Button variant="outline" size="sm" onClick={handleExportPositions}>
<Download className="h-4 w-4 mr-2" />
</Button>
{/* 记录交易按钮 */}
<Button onClick={openTxDialog}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
</header>
<main className="container mx-auto px-4 py-6 space-y-6">
{/* 资产概览卡片 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* 总资产卡片 */}
<Card className="bg-gradient-to-br from-blue-600 to-blue-700 text-white border-0">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium opacity-90"> ({displayCurrency})</CardTitle>
<Wallet className="h-4 w-4 opacity-80" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatCurrency(convertCurrency(analytics?.summary?.totalMarketValue || 0, 'USD', displayCurrency), displayCurrency)}</div>
<p className="text-xs opacity-80 mt-1">
{formatCurrency(convertCurrency(analytics?.summary?.totalCostBasis || 0, 'USD', displayCurrency), displayCurrency)}
</p>
</CardContent>
</Card>
{/* 浮动盈亏卡片 */}
<Card className="bg-gradient-to-br from-emerald-600 to-emerald-700 text-white border-0">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium opacity-90"></CardTitle>
<TrendingUp className="h-4 w-4 opacity-80" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatCurrency(convertCurrency(analytics?.summary?.totalPnL || 0, 'USD', displayCurrency), displayCurrency)}</div>
<p className={`text-xs mt-1 flex items-center gap-1 ${(analytics?.summary?.totalPnL || 0) >= 0 ? 'text-emerald-200' : 'text-red-200'}`}>
{(analytics?.summary?.totalPnL || 0) >= 0 ? <ArrowUpRight className="h-3 w-3" /> : <ArrowDownRight className="h-3 w-3" />}
{formatPercent(analytics?.summary?.totalPnLPercent || 0)}
</p>
</CardContent>
</Card>
{/* 持仓市值卡片 */}
<Card className="bg-gradient-to-br from-purple-600 to-purple-700 text-white border-0">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium opacity-90"></CardTitle>
<DollarSign className="h-4 w-4 opacity-80" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatCurrency(convertCurrency(analytics?.summary?.totalMarketValue || 0, 'USD', displayCurrency), displayCurrency)}</div>
<p className="text-xs opacity-80 mt-1">{analytics?.summary?.positionCount || 0} </p>
</CardContent>
</Card>
{/* 账户数量卡片 */}
<Card className="bg-gradient-to-br from-orange-600 to-orange-700 text-white border-0">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium opacity-90"></CardTitle>
<Building2 className="h-4 w-4 opacity-80" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{accounts.length}</div>
<p className="text-xs opacity-80 mt-1"> {accounts.length} </p>
</CardContent>
</Card>
</div>
{/* 图表区域 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 市场分布饼图 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
{marketDistribution.length > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={marketDistribution}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
dataKey="value"
>
{marketDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value) => formatCurrency(value as number)}
contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)' }}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
) : (
<div className="h-[240px] flex items-center justify-center text-muted-foreground">
</div>
)}
</CardContent>
</Card>
{/* 持仓分析卡片 */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{analytics?.positions?.slice(0, 4).map((pos) => (
<div key={pos.symbol} className="p-4 rounded-lg bg-muted/50">
<div className="flex items-center gap-2 mb-2">
{marketIcons[pos.marketType as MarketType]}
<span className="font-medium">{pos.symbol}</span>
<span className="text-xs text-muted-foreground">{pos.currency}</span>
</div>
<div className="text-lg font-bold">{formatCurrency(pos.marketValue, pos.currency)}</div>
<Separator className="my-2" />
<div className={`text-sm flex items-center gap-1 ${pos.pnl >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{pos.pnl >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{formatCurrency(Math.abs(pos.pnl), pos.currency)} ({formatPercent(pos.pnlPercent)})
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* 标签页区域:持仓明细、交易流水、分析 */}
<Tabs defaultValue="positions" className="space-y-4">
<TabsList>
<TabsTrigger value="positions"></TabsTrigger>
<TabsTrigger value="transactions"></TabsTrigger>
<TabsTrigger value="analytics"></TabsTrigger>
</TabsList>
{/* 持仓明细标签页 */}
<TabsContent value="positions">
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{analytics?.positions && analytics.positions.length > 0 ? analytics.positions?.map((pos) => (
<TableRow key={pos.symbol}>
<TableCell>
<div className="font-medium">{pos.symbol}</div>
<div className="text-xs text-muted-foreground">{pos.name}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="gap-1">
{marketIcons[pos.marketType as MarketType]}
{marketLabels[pos.marketType as MarketType]}
</Badge>
</TableCell>
<TableCell className="text-right font-mono">
{pos.quantity.toFixed(pos.isCrypto ? 6 : 0)}
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(pos.avgCost, pos.currency)}
</TableCell>
<TableCell className="text-right font-mono">
<div className="flex items-center justify-end gap-2">
{formatCurrency(pos.currentPrice, pos.currency)}
{pos.change !== 0 && (
<span className={`text-xs ${pos.change >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{pos.change >= 0 ? '+' : ''}{pos.changePercent.toFixed(2)}%
</span>
)}
</div>
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(pos.marketValue, pos.currency)}
</TableCell>
<TableCell className={`text-right font-mono ${pos.pnl >= 0 ? 'text-green-500' : 'text-red-500'}`}>
<div className="flex items-center justify-end gap-1">
{pos.pnl >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{formatCurrency(Math.abs(pos.pnl))}
<span className="text-xs">({formatPercent(pos.pnlPercent)})</span>
</div>
</TableCell>
<TableCell>
<Button variant="ghost" size="sm" onClick={() => openDeleteDialog(pos)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
)) : (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
{/* 交易流水标签页 */}
<TabsContent value="transactions">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle></CardTitle>
<Button variant="outline" size="sm" onClick={handleExportTransactions}>
<Download className="h-4 w-4 mr-2" />
CSV
</Button>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.length > 0 ? transactions.map((tx) => (
<TableRow key={tx.id}>
<TableCell className="text-sm">
{new Date(tx.executedAt).toLocaleDateString('zh-CN')}
</TableCell>
<TableCell>
<Badge
variant={
tx.type === 'BUY' || tx.type === 'DEPOSIT' || tx.type === 'DIVIDEND'
? 'default'
: 'secondary'
}
>
{transactionTypeLabels[tx.type]}
</Badge>
</TableCell>
<TableCell className="font-mono">{tx.symbol || '-'}</TableCell>
<TableCell className="text-right font-mono">
{tx.quantity ? parseFloat(tx.quantity).toFixed(4) : '-'}
</TableCell>
<TableCell className="text-right font-mono">
{tx.price ? formatCurrency(parseFloat(tx.price), tx.currency) : '-'}
</TableCell>
<TableCell className="text-right font-mono font-medium">
{formatCurrency(parseFloat(tx.amount), tx.currency)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{marketIcons[tx.account?.marketType || 'US']}
<span className="text-sm">{tx.account?.name}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEditTxDialog(tx)}>
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => openDeleteTxDialog(tx)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
)) : (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
{/* 分析标签页 */}
<TabsContent value="analytics">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 资产分布条形图 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{analytics?.positions?.map((pos) => {
const percent = analytics?.summary?.totalMarketValue && analytics.summary.totalMarketValue > 0
? (pos.marketValueUSD / analytics.summary.totalMarketValue) * 100
: 0
return (
<div key={pos.symbol} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
{marketIcons[pos.marketType as MarketType]}
<span className="font-medium">{pos.symbol}</span>
<span className="text-xs text-muted-foreground">{pos.currency}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{percent.toFixed(1)}%</span>
<span className="font-mono">{formatCurrency(pos.marketValue, pos.currency)}</span>
</div>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${percent}%`,
backgroundColor: marketColors[pos.marketType as MarketType]
}}
/>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
{/* 盈亏排行榜 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{analytics?.positions
?.sort((a, b) => b.pnlUSD - a.pnlUSD)
.map((pos, index) => (
<div key={pos.symbol} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-3">
<span className="text-lg font-bold text-muted-foreground">#{index + 1}</span>
<div>
<div className="font-medium">{pos.symbol}</div>
<div className="text-xs text-muted-foreground">{pos.name}</div>
</div>
</div>
<div className={`text-right font-mono ${pos.pnl >= 0 ? 'text-green-500' : 'text-red-500'}`}>
<div className="flex items-center gap-1 justify-end">
{pos.pnl >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{formatCurrency(Math.abs(pos.pnl), pos.currency)}
</div>
<div className="text-xs">{formatPercent(pos.pnlPercent)}</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</main>
{/* 记录交易对话框 */}
<Dialog open={showTxDialog} onOpenChange={setShowTxDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
{/* 账户选择 */}
<div className="space-y-2">
<Label></Label>
<Select value={selectedAccountId} onValueChange={(v) => {
if (v) {
setSelectedAccountId(v)
const selected = accounts.find(a => a.id === v)
if (selected) {
setTxForm(prev => ({ ...prev, currency: selected.baseCurrency }))
}
}
}}>
<SelectTrigger>
<SelectValue>
{selectedAccount ? selectedAccount.name : '选择账户'}
</SelectValue>
</SelectTrigger>
<SelectContent>
{accounts.map((acc) => (
<SelectItem key={acc.id} value={acc.id}>
{acc.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 交易类型选择 */}
<div className="space-y-2">
<Label></Label>
<Select value={txForm.type} onValueChange={(v) => setTxForm({ ...txForm, type: v as TransactionType })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(['BUY', 'SELL', 'DEPOSIT', 'WITHDRAW', 'DIVIDEND'] as TransactionType[]).map((t) => (
<SelectItem key={t} value={t}>
{transactionTypeLabels[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 证券代码搜索(买入/卖出/分红时显示) */}
{['BUY', 'SELL', 'DIVIDEND'].includes(txForm.type) && (
<div className="space-y-2">
<Label></Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="pl-9"
placeholder="搜索证券代码或名称"
value={symbolSearch || getSecurityDisplayText(txForm.symbol)}
onChange={(e) => {
setSymbolSearch(e.target.value)
setTxForm({ ...txForm, symbol: e.target.value.toUpperCase() })
}}
/>
{/* 搜索结果下拉列表 */}
{filteredSecurities.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-popover border rounded-md shadow-lg max-h-48 overflow-auto">
{filteredSecurities.map((sec) => (
<button
key={sec.id}
className="w-full px-3 py-2 text-left hover:bg-accent flex items-center justify-between"
onClick={() => handleSelectSecurity(sec.symbol)}
>
<div>
<span className="font-medium">{sec.symbol}</span>
<span className="text-muted-foreground ml-2">{sec.name}</span>
</div>
<Badge variant="outline" className="text-xs">
{marketLabels[sec.market]}
</Badge>
</button>
))}
</div>
)}
</div>
</div>
)}
{/* 数量和价格输入(买入/卖出/分红时显示) */}
{['BUY', 'SELL', 'DIVIDEND'].includes(txForm.type) && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
type="number"
step="any"
placeholder="0"
value={txForm.quantity}
onChange={(e) => setTxForm({ ...txForm, quantity: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="number"
step="any"
placeholder="0.00"
value={txForm.price}
onChange={(e) => setTxForm({ ...txForm, price: e.target.value })}
/>
</div>
</div>
)}
{/* 成交总额(自动计算) */}
{['BUY', 'SELL', 'DIVIDEND'].includes(txForm.type) && (
<div className="space-y-2">
<Label></Label>
<div className="p-3 bg-muted rounded-lg">
<span className="text-2xl font-bold">
{calculatedAmount ? formatCurrency(parseFloat(calculatedAmount), txForm.currency) : '—'}
</span>
{txForm.quantity && txForm.price && (
<span className="text-sm text-muted-foreground ml-2">
({txForm.quantity} × {formatCurrency(parseFloat(txForm.price), txForm.currency)})
</span>
)}
</div>
</div>
)}
{/* 入金/出金金额输入 */}
{['DEPOSIT', 'WITHDRAW'].includes(txForm.type) && (
<div className="space-y-2">
<Label></Label>
<Input
type="number"
step="any"
placeholder="0.00"
value={txForm.amount}
onChange={(e) => setTxForm({ ...txForm, amount: e.target.value })}
/>
</div>
)}
{/* 手续费输入 */}
<div className="space-y-2">
<Label></Label>
<Input
type="number"
step="any"
placeholder="0.00"
value={txForm.fee}
onChange={(e) => setTxForm({ ...txForm, fee: e.target.value })}
/>
</div>
{/* 交易时间 */}
<div className="space-y-2">
<Label></Label>
<Input
type="datetime-local"
value={txForm.executedAt}
onChange={(e) => setTxForm({ ...txForm, executedAt: e.target.value })}
/>
</div>
{/* 备注 */}
<div className="space-y-2">
<Label></Label>
<Input
placeholder="可选备注"
value={txForm.notes}
onChange={(e) => setTxForm({ ...txForm, notes: e.target.value })}
/>
</div>
{/* 提交按钮 */}
<Button className="w-full" onClick={() => {
if (!selectedAccountId) {
toast.error('请先选择账户')
return
}
if (['BUY', 'SELL', 'DIVIDEND'].includes(txForm.type) && (!txForm.quantity || !txForm.price)) {
toast.error('请输入数量和价格')
return
}
if (['DEPOSIT', 'WITHDRAW'].includes(txForm.type) && !txForm.amount) {
toast.error('请输入金额')
return
}
setShowConfirmDialog(true)
}}>
</Button>
</div>
</DialogContent>
</Dialog>
{/* 交易确认对话框 */}
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="py-4 space-y-3">
{(() => {
const info = getConfirmInfo()
return (
<>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{info.account}</span>
<Badge variant="outline" className="text-xs">{info.market}</Badge>
</div>
<Separator />
<div className="text-center py-2">
<div className="text-2xl font-bold">{info.action}</div>
</div>
<div className="flex items-center justify-between text-sm bg-muted/50 p-3 rounded-lg">
<span className="text-muted-foreground"></span>
<span className="text-xl font-bold">{info.total}</span>
</div>
{info.fee && (
<div className="text-sm text-muted-foreground text-center">
{info.fee}
</div>
)}
<Separator />
<div className="text-xs text-muted-foreground text-center">
: {info.time}
</div>
</>
)
})()}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setShowConfirmDialog(false)}>
</Button>
<Button onClick={handleSubmitTx}>
<Check className="h-4 w-4 mr-1" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 编辑交易记录对话框 */}
<Dialog open={showEditTxDialog} onOpenChange={setShowEditTxDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
{/* 交易类型选择 */}
<div className="space-y-2">
<Label></Label>
<Select value={editTxForm.type} onValueChange={(v) => setEditTxForm({ ...editTxForm, type: v as TransactionType })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(['BUY', 'SELL', 'DEPOSIT', 'WITHDRAW', 'DIVIDEND'] as TransactionType[]).map((t) => (
<SelectItem key={t} value={t}>
{transactionTypeLabels[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 交易时间 */}
<div className="space-y-2">
<Label></Label>
<Input
type="datetime-local"
value={editTxForm.executedAt}
onChange={(e) => setEditTxForm({ ...editTxForm, executedAt: e.target.value })}
/>
</div>
</div>
{/* 证券代码(买入/卖出/分红时显示) */}
{['BUY', 'SELL', 'DIVIDEND'].includes(editTxForm.type) && (
<div className="space-y-2">
<Label></Label>
<Input
placeholder="输入证券代码"
value={editTxForm.symbol}
onChange={(e) => setEditTxForm({ ...editTxForm, symbol: e.target.value.toUpperCase() })}
/>
</div>
)}
{/* 数量和价格输入(买入/卖出/分红时显示) */}
{['BUY', 'SELL', 'DIVIDEND'].includes(editTxForm.type) && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
type="number"
step="any"
placeholder="0"
value={editTxForm.quantity}
onChange={(e) => setEditTxForm({ ...editTxForm, quantity: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="number"
step="any"
placeholder="0.00"
value={editTxForm.price}
onChange={(e) => setEditTxForm({ ...editTxForm, price: e.target.value })}
/>
</div>
</div>
)}
{/* 金额输入(入金/出金时显示) */}
{['DEPOSIT', 'WITHDRAW'].includes(editTxForm.type) && (
<div className="space-y-2">
<Label></Label>
<Input
type="number"
step="any"
placeholder="0.00"
value={editTxForm.amount}
onChange={(e) => setEditTxForm({ ...editTxForm, amount: e.target.value })}
/>
</div>
)}
{/* 手续费输入 */}
<div className="space-y-2">
<Label></Label>
<Input
type="number"
step="any"
placeholder="0.00"
value={editTxForm.fee}
onChange={(e) => setEditTxForm({ ...editTxForm, fee: e.target.value })}
/>
</div>
{/* 备注 */}
<div className="space-y-2">
<Label></Label>
<Input
placeholder="可选备注"
value={editTxForm.notes}
onChange={(e) => setEditTxForm({ ...editTxForm, notes: e.target.value })}
/>
</div>
{/* 提交按钮 */}
<Button className="w-full" onClick={handleEditTransaction}>
</Button>
</div>
</DialogContent>
</Dialog>
{/* 删除交易记录确认对话框 */}
<Dialog open={showDeleteTxDialog} onOpenChange={setShowDeleteTxDialog}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{transactionToDelete && (
<div className="py-4 space-y-3">
<div className="bg-muted/50 p-3 rounded-lg space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{transactionTypeLabels[transactionToDelete.type]}</span>
</div>
{transactionToDelete.symbol && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono">{transactionToDelete.symbol}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono font-medium">{formatCurrency(parseFloat(transactionToDelete.amount), transactionToDelete.currency)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="text-sm">{new Date(transactionToDelete.executedAt).toLocaleString('zh-CN')}</span>
</div>
</div>
<div className="text-sm text-destructive text-center">
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => {
setShowDeleteTxDialog(false)
setTransactionToDelete(null)
}}>
</Button>
<Button variant="destructive" onClick={handleDeleteTransaction}>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除持仓确认对话框 */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{positionToDelete && (
<div className="py-4 space-y-3">
<div className="text-center py-2">
<div className="text-lg font-bold">{positionToDelete.symbol}</div>
<div className="text-sm text-muted-foreground">{positionToDelete.name}</div>
</div>
<div className="bg-muted/50 p-3 rounded-lg space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono">{positionToDelete.quantity.toFixed(positionToDelete.isCrypto ? 6 : 0)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono">{formatCurrency(positionToDelete.currentPrice, positionToDelete.currency)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono font-bold">{formatCurrency(positionToDelete.marketValue, positionToDelete.currency)}</span>
</div>
</div>
<div className="text-sm text-destructive text-center">
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => {
setShowDeleteDialog(false)
setPositionToDelete(null)
}}>
</Button>
<Button variant="destructive" onClick={handleDeletePosition}>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 导入对话框 */}
<Dialog open={showImportDialog} onOpenChange={setShowImportDialog}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="py-4 space-y-4">
{/* 下载模板按钮 */}
<div className="flex items-center gap-4">
<Button variant="outline" onClick={handleDownloadTemplate}>
<Download className="h-4 w-4 mr-2" />
</Button>
<span className="text-sm text-muted-foreground"></span>
</div>
<Separator />
{/* 文件选择 */}
<div className="space-y-2">
<Label> CSV </Label>
<Input
type="file"
accept=".csv"
onChange={handleFileImport}
/>
</div>
{/* 导入预览表格 */}
{importFile && (
<div className="space-y-2">
<div className="text-sm font-medium"> ({importData.length} )</div>
<div className="max-h-64 overflow-auto border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{importData.slice(0, 10).map((tx, index) => (
<TableRow key={index}>
<TableCell>
{tx.errors.length > 0 ? (
<Badge variant="destructive" className="text-xs">
{tx.errors.length}
</Badge>
) : (
<Badge variant="default" className="text-xs"></Badge>
)}
</TableCell>
<TableCell className="text-sm">{tx.executedAt}</TableCell>
<TableCell className="text-sm">{tx.type}</TableCell>
<TableCell className="text-sm">{tx.symbol || '-'}</TableCell>
<TableCell className="text-right font-mono">{tx.amount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{importData.length > 10 && (
<div className="p-2 text-center text-sm text-muted-foreground">
{importData.length - 10} ...
</div>
)}
</div>
{importData.some(tx => tx.errors.length > 0) && (
<div className="text-sm text-destructive">
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => {
setShowImportDialog(false)
setImportData([])
setImportFile(null)
}}>
</Button>
<Button
onClick={handleExecuteImport}
disabled={importData.filter(tx => tx.errors.length === 0).length === 0}
>
{importData.filter(tx => tx.errors.length === 0).length}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
</div>
)
}