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

422 lines
19 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 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 { 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 { UpdateTransactionDialog } from '@/components/transactions/update-transaction-dialog';
import { deleteTransaction } from '@/actions/transaction';
import { ChevronDown, ChevronUp, Plus, Edit3, Trash2 } 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 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);
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 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.error) {
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>
<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-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></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 (
<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>
</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>
<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></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>
</div>
);
}