stock-portfolio_byQwen3.6/app/dashboard/page.tsx

669 lines
29 KiB
TypeScript
Raw Permalink 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 React, { useState, useEffect, useTransition } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
import { getPortfolioSummary } from '@/actions/portfolio';
import { getAssets } from '@/actions/asset';
import { recordDailySnapshot, getSnapshots, reconstructPortfolioHistory } from '@/actions/snapshots';
import { formatQuantity, formatAmount } from '@/lib/formatters';
import AllocationChart from '@/components/dashboard/allocation-chart';
import NetWorthChart from '@/components/dashboard/net-worth-chart';
import { SyncButton } from '@/components/assets/sync-button';
import { AddTransactionDialog } from '@/components/transactions/add-transaction-dialog';
import { UpdateTransactionDialog } from '@/components/transactions/update-transaction-dialog';
import { deleteTransaction } from '@/actions/transaction';
import { importHistoricalPrices } from '@/actions/market';
import { ChevronDown, ChevronUp, Plus, Edit3, Trash2, Upload, Download, Eye } from 'lucide-react';
import Big from 'big.js';
const txTypeMap: Record<string, string> = {
BUY: '买入',
SELL: '卖出',
DIVIDEND: '分红',
AIRDROP: '空投',
};
function getCurrencySymbol(currency: string): string {
if (currency === 'USD') return '$';
if (currency === 'HKD') return 'HK$';
if (currency === 'CNY') return '¥';
if (currency === 'JPY') return '¥';
return currency + ' ';
}
function formatNative(value: string, baseCurrency: string): string {
const symbol = getCurrencySymbol(baseCurrency);
const formatted = new Big(value).toFixed(2);
return `${symbol}${formatted}`;
}
function exportToCSV(positions: any[]) {
const stripTrailingZeros = (val: any): string => {
if (val === null || val === undefined || val === '') return "0";
let str = String(val);
if (str.includes('.')) {
str = str.replace(/0+$/, '');
str = str.replace(/\.$/, '');
}
return str;
};
const getMarketName = (item: any): string => {
if (item.type === 'CRYPTO' || item.assetType === 'CRYPTO') return '虚拟币';
const currency = (item.baseCurrency || '').toUpperCase();
if (currency === 'USD') return '美股';
if (currency === 'HKD') return '港股';
if (currency === 'CNY' || currency === 'RMB') return 'A股';
const symbol = (item.symbol || '').toLowerCase();
if (/^\d{5}$/.test(symbol)) return '港股';
if (/^(60|00|30)\d{4}$/.test(symbol) || symbol.startsWith('sh') || symbol.startsWith('sz')) return 'A股';
return '其他市场';
};
const headers = ["资产名称", "代码", "市场", "持仓量", "成本价", "现价", "总市值", "浮动盈亏", "累计盈亏"];
const rows = positions.map(item => [
item.name || item.symbol,
item.symbol,
getMarketName(item),
item.quantity || '0',
stripTrailingZeros(new Big(item.avgCostNative || '0').toFixed(2)),
stripTrailingZeros(item.latestPrice || '0'),
new Big(item.marketValueNative || '0').toFixed(2),
new Big(item.floatingPnlNative || '0').toFixed(2),
new Big(item.cumulativePnlNative || '0').toFixed(2),
]);
const csvContent = [
headers.join(","),
...rows.map(e => e.map(val => `"${val}"`).join(","))
].join("\n");
const blob = new Blob(["\uFEFF" + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", `portfolio_details_${new Date().toISOString().split('T')[0]}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function formatPnl(value: string, percent: string, baseCurrency: string): { text: string; className: string } {
const isPositive = new Big(value).gte(0);
const symbol = getCurrencySymbol(baseCurrency);
const absValue = new Big(value).abs().toFixed(2);
const absPercent = new Big(percent).abs().toFixed(2);
const text = `${symbol}${isPositive ? '' : '-'}${absValue} (${isPositive ? '' : '-'}${absPercent}%)`;
const className = isPositive ? 'text-red-500' : 'text-green-500';
return { text, className };
}
interface Asset {
id: string;
symbol: string;
name: string | null;
type: string;
baseCurrency: string;
exchange: string | null;
}
export default function DashboardPage() {
const [positions, setPositions] = useState<any[]>([]);
const [totalCnyValue, setTotalCnyValue] = useState('0');
const [totalPnlCny, setTotalPnlCny] = useState('0');
const [unrealizedPnlCny, setUnrealizedPnlCny] = useState('0');
const [marketAllocation, setMarketAllocation] = useState<any[]>([]);
const [assets, setAssets] = useState<Asset[]>([]);
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>({});
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedAssetId, setSelectedAssetId] = useState<string>('');
const [isPending, startTransition] = useTransition();
const [updateTarget, setUpdateTarget] = useState<any>(null);
const [deleteTarget, setDeleteTarget] = useState<any>(null);
const [snapshots, setSnapshots] = useState<any[]>([]);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importAssetId, setImportAssetId] = useState<string>('');
const [importText, setImportText] = useState('');
const [showCleared, setShowCleared] = useState(false);
const [positionsRaw, setPositionsRaw] = useState<any[]>([]);
useEffect(() => {
const filtered = showCleared
? positionsRaw
: positionsRaw.filter(pos => new Big(pos.quantity || '0').gt('1e-8'));
setPositions(filtered);
}, [showCleared, positionsRaw]);
useEffect(() => {
async function loadData() {
const summary = await getPortfolioSummary(true);
const allAssets = await getAssets();
setPositionsRaw(summary.positions);
setPositions(summary.positions);
setTotalCnyValue(summary.totalCnyValue);
setTotalPnlCny(summary.totalPnlCny);
setUnrealizedPnlCny(summary.unrealizedPnlCny);
setMarketAllocation(summary.marketAllocation);
setAssets(allAssets as Asset[]);
}
loadData();
}, []);
useEffect(() => {
async function loadSnapshots() {
const summary = await getPortfolioSummary();
await recordDailySnapshot();
const data = await getSnapshots();
const todayStr = new Date().toISOString().slice(0, 10);
const lastSnapshot = data[data.length - 1];
if (lastSnapshot && lastSnapshot.date === todayStr) {
lastSnapshot.totalValueCny = summary.totalCnyValue;
lastSnapshot.totalCostCny = summary.totalCostCny;
} else {
// 注入虚拟主键与时间戳,完美骗过 TypeScript 的强类型校验
data.push({
id: 'virtual_today_node',
date: todayStr,
totalValueCny: summary.totalCnyValue,
totalCostCny: summary.totalCostCny,
createdAt: new Date(),
updatedAt: new Date(),
});
}
setSnapshots(data);
}
loadSnapshots();
}, []);
const toggleExpand = (id: string) => {
setExpandedIds(prev => ({ ...prev, [id]: !prev[id] }));
};
const handleOpenDialog = (assetId: string) => {
setSelectedAssetId(assetId);
setDialogOpen(true);
};
const handleUpdate = (tx: any) => {
setUpdateTarget(tx);
};
function handleUpdateSubmit() {
setUpdateTarget(null);
}
function handleDelete(tx: any) {
startTransition(async () => {
const result = await deleteTransaction(tx.id);
if (result.success) {
toast.success('交易記錄已刪除');
setDeleteTarget(null);
window.location.reload();
} else if ((result as any).error) {
toast.error((result as any).error);
}
});
}
function handleOpenImportDialog(assetId: string) {
setImportAssetId(assetId);
setImportText('');
setImportDialogOpen(true);
}
function handleImportSubmit() {
startTransition(async () => {
const lines = importText.split('\n').filter(l => l.trim());
const data: Array<{ date: string; price: string }> = [];
for (const line of lines) {
const parts = line.split(',');
if (parts.length >= 2) {
const date = parts[0].trim();
const price = parts[1].trim();
if (date && price) {
data.push({ date, price });
}
}
}
if (data.length === 0) {
toast.error('未解析到有效數據,請檢查格式');
setImportDialogOpen(false);
return;
}
const result = await importHistoricalPrices(importAssetId, data);
if (result.success) {
toast.success(`成功導入 ${result.imported} 條價格記錄` + (result.errors ? `${result.errors} 條失敗` : ''));
setImportText('');
setImportDialogOpen(false);
window.location.reload();
} else {
toast.error(result.error || '導入失敗');
}
});
}
const formattedTotal = formatAmount(totalCnyValue);
const formattedTotalPnl = formatAmount(totalPnlCny);
const formattedUnrealized = formatAmount(unrealizedPnlCny);
const totalPnlIsPositive = new Big(totalPnlCny).gte(0);
const unrealizedIsPositive = new Big(unrealizedPnlCny).gte(0);
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold"> Omniledger</h1>
<p className="text-muted-foreground"></p>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
startTransition(async () => {
toast.loading('正在重构历史走势...');
const result = await reconstructPortfolioHistory();
toast.dismiss();
if (result.success) {
toast.success(`重构成功,已填充 ${result.daysReconstructed} 天历史数据`);
setSnapshots(await getSnapshots());
} else if (result.message) {
toast.info(result.message);
}
});
}}
disabled={isPending}
>
{isPending ? '重构中...' : '重构历史走势'}
</Button>
<SyncButton />
</div>
</CardHeader>
<CardContent className="pt-6 pb-6">
<div className="flex items-center gap-3">
<span className="text-2xl font-semibold text-muted-foreground">¥</span>
<span className="text-5xl font-bold">{formattedTotal}</span>
<span className="text-xl text-muted-foreground ml-2"> (CNY)</span>
</div>
<div className="mt-3 flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">:</span>
<span className={`text-lg font-semibold ${unrealizedIsPositive ? 'text-red-500' : 'text-green-500'}`}>
{formattedUnrealized}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">:</span>
<span className={`text-lg font-semibold ${totalPnlIsPositive ? 'text-red-500' : 'text-green-500'}`}>
{formattedTotalPnl}
</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base font-medium"></CardTitle>
</CardHeader>
<CardContent>
<NetWorthChart snapshots={snapshots} />
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle></CardTitle>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer text-sm text-muted-foreground hover:text-foreground transition-colors">
<input
type="checkbox"
checked={showCleared}
onChange={(e) => setShowCleared(e.target.checked)}
className="h-4 w-4 rounded border-border text-primary focus:ring-primary cursor-pointer"
/>
<Eye className="h-4 w-4" />
</label>
<Button
size="sm"
variant="outline"
onClick={() => exportToCSV(positions)}
disabled={positions.length === 0}
>
<Download className="h-4 w-4 mr-1" />
CSV
</Button>
</div>
</CardHeader>
<CardContent>
{positions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">/</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="text-right"></TableHead>
<TableHead className="text-right w-[120px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{positions.map((pos) => {
const symbol = getCurrencySymbol(pos.baseCurrency);
const latestPriceFormatted = `${symbol}${new Big(pos.latestPrice || '0').toFixed(2)}`;
const marketValueFormatted = formatNative(pos.marketValueNative, pos.baseCurrency);
const quantityFormatted = formatQuantity(pos.quantity, pos.type);
const dilutedCostStr = new Big(pos.dilutedCostNative).toFixed(2);
const avgCostStr = new Big(pos.avgCostNative).toFixed(2);
let costDisplay = '-';
if (!new Big(pos.dilutedCostNative).eq('0') || !new Big(pos.avgCostNative).eq('0')) {
costDisplay = `${dilutedCostStr} / ${avgCostStr}`;
}
const floatingPnl = formatPnl(pos.floatingPnlNative, pos.floatingPnlPercent, pos.baseCurrency);
const cumulativePnl = formatPnl(pos.cumulativePnlNative, pos.cumulativePnlPercent, pos.baseCurrency);
const isExpanded = !!expandedIds[pos.assetId];
return (
<React.Fragment key={pos.assetId}>
<TableRow className="cursor-pointer" onClick={() => toggleExpand(pos.assetId)}>
<TableCell>
<div className="flex flex-col">
<span className="font-semibold">{pos.name || pos.symbol}</span>
<span className="text-xs text-muted-foreground">{pos.symbol}</span>
</div>
</TableCell>
<TableCell className="text-right">{latestPriceFormatted}</TableCell>
<TableCell className="text-right">{marketValueFormatted}</TableCell>
<TableCell className="text-right">{quantityFormatted}</TableCell>
<TableCell className="text-right">{costDisplay}</TableCell>
<TableCell className={`text-right ${floatingPnl.className}`}>{floatingPnl.text}</TableCell>
<TableCell className={`text-right ${cumulativePnl.className}`}>{cumulativePnl.text}</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-muted-foreground">
{isExpanded ? '收起' : '展开'}
</span>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
onClick={(e) => {
e.stopPropagation();
handleOpenDialog(pos.assetId);
}}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
onClick={(e) => {
e.stopPropagation();
handleOpenImportDialog(pos.assetId);
}}
>
<Upload className="h-3 w-3 mr-1" />
</Button>
</div>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow>
<TableCell colSpan={8} className="p-0">
<div className="bg-muted/20 border-l-4 border-primary/20 p-3 ml-2">
<div className="space-y-2">
<div className="flex items-center justify-between pb-2 border-b border-border/50">
<span className="text-sm font-semibold text-foreground"></span>
<div className="flex items-center gap-1">
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={(e) => {
e.stopPropagation();
handleOpenDialog(pos.assetId);
}}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={(e) => {
e.stopPropagation();
handleOpenImportDialog(pos.assetId);
}}
>
<Upload className="h-3 w-3 mr-1" />
</Button>
</div>
</div>
{pos.transactions && pos.transactions.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right">/</TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead className="text-right w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pos.transactions.map((tx: any) => {
const txDate = tx.executedAt
? new Date(tx.executedAt).toLocaleString('zh-TW', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
: '-';
return (
<TableRow key={tx.id} className="bg-muted/10">
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{txDate}
</TableCell>
<TableCell>
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${
tx.txType === 'BUY' ? 'bg-red-100 text-red-700' :
tx.txType === 'SELL' ? 'bg-green-100 text-green-700' :
tx.txType === 'DIVIDEND' ? 'bg-blue-100 text-blue-700' :
tx.txType === 'AIRDROP' ? 'bg-purple-100 text-purple-700' :
'bg-gray-100 text-gray-700'
}`}>
{txTypeMap[tx.txType] || tx.txType}
</span>
</TableCell>
<TableCell className="text-right text-xs">
<div className="flex flex-col items-end">
<span>: {new Big(tx.quantity).toString()}</span>
<span>: {new Big(tx.price).toString()}</span>
</div>
</TableCell>
<TableCell className="text-right text-xs">{new Big(tx.fee || 0).toString()}</TableCell>
<TableCell className="text-xs text-muted-foreground">-</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 text-xs"
onClick={(e) => {
e.stopPropagation();
handleUpdate(tx);
}}
>
<Edit3 className="h-3 w-3 mr-0.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 text-xs text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(tx);
}}
>
<Trash2 className="h-3 w-3 mr-0.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
) : (
<p className="text-sm text-muted-foreground text-center py-3"></p>
)}
</div>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<AllocationChart data={marketAllocation} />
</CardContent>
</Card>
<AddTransactionDialog
assets={assets}
open={dialogOpen}
onOpenChange={setDialogOpen}
defaultAssetId={selectedAssetId}
/>
<UpdateTransactionDialog
open={!!updateTarget}
onOpenChange={(open) => !open && setUpdateTarget(null)}
assets={assets}
transaction={updateTarget}
onSuccess={handleUpdateSubmit}
/>
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
</Button>
<Button
variant="destructive"
onClick={() => deleteTarget && handleDelete(deleteTarget)}
disabled={isPending}
>
{isPending ? '刪除中...' : '刪除'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent className="sm:max-w-[550px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
Excel ,
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<textarea
value={importText}
onChange={(e) => setImportText(e.target.value)}
placeholder={`2026-04-01, 150.5\n2026-04-02, 151.2\n2026-04-03, 149.8`}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring min-h-[180px] font-mono text-sm resize-y"
/>
<p className="text-xs text-muted-foreground">
Excel `日期, 價格` `YYYY-MM-DD`
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setImportDialogOpen(false)}>
</Button>
<Button
onClick={handleImportSubmit}
disabled={isPending || !importText.trim()}
>
{isPending ? '導入中...' : '開始導入'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}