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

307 lines
14 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 { 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 { getPortfolioSummary } from '@/actions/portfolio';
import { getAssets } from '@/actions/asset';
import { formatQuantity, formatAmount } from '@/lib/formatters';
import AllocationChart from '@/components/dashboard/allocation-chart';
import { SyncButton } from '@/components/assets/sync-button';
import { AddTransactionDialog } from '@/components/transactions/add-transaction-dialog';
import { ChevronDown, ChevronUp, Plus, Edit3 } from 'lucide-react';
import Big from 'big.js';
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 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 sign = isPositive ? '+' : '';
const text = `${sign}${symbol}${absValue} (${sign}${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>('');
useEffect(() => {
async function loadData() {
const summary = await getPortfolioSummary();
const allAssets = await getAssets();
setPositions(summary.positions);
setTotalCnyValue(summary.totalCnyValue);
setTotalPnlCny(summary.totalPnlCny);
setUnrealizedPnlCny(summary.unrealizedPnlCny);
setMarketAllocation(summary.marketAllocation);
setAssets(allAssets as Asset[]);
}
loadData();
}, []);
const toggleExpand = (id: string) => {
setExpandedIds(prev => ({ ...prev, [id]: !prev[id] }));
};
const handleOpenDialog = (assetId: string) => {
setSelectedAssetId(assetId);
setDialogOpen(true);
};
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>
<SyncButton />
</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-green-500' : 'text-red-500'}`}>
{unrealizedIsPositive ? '+' : ''}{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-green-500' : 'text-red-500'}`}>
{totalPnlIsPositive ? '+' : ''}{formattedTotalPnl}
</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</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 (
<>
<TableRow key={pos.assetId} 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>
</div>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow>
<TableCell colSpan={8} className="bg-muted/30 p-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground"></span>
<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>
</div>
{pos.transactions && pos.transactions.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<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>
</TableRow>
</TableHeader>
<TableBody>
{pos.transactions.map((tx: any, idx: number) => {
const txDate = tx.executedAt
? new Date(tx.executedAt).toLocaleString('zh-TW')
: '-';
return (
<TableRow key={idx}>
<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' :
'bg-gray-100 text-gray-700'
}`}>
{tx.txType}
</span>
</TableCell>
<TableCell className="text-right">{tx.quantity}</TableCell>
<TableCell className="text-right">{tx.price}</TableCell>
<TableCell className="text-right">{tx.fee || '0'}</TableCell>
<TableCell className="text-right">{tx.txCurrency}</TableCell>
<TableCell className="text-right text-xs text-muted-foreground">{txDate}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</TableCell>
</TableRow>
)}
</>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<AllocationChart data={marketAllocation} />
</CardContent>
</Card>
<AddTransactionDialog
assets={assets}
open={dialogOpen}
onOpenChange={setDialogOpen}
defaultAssetId={selectedAssetId}
/>
</div>
);
}