diff --git a/Memory.md b/Memory.md index 441a9bf..733559e 100644 --- a/Memory.md +++ b/Memory.md @@ -79,6 +79,15 @@ - 新增累計盈虧指標:`cumulativePnlNative = floatingPnlNative + realizedPnlNative + accumulatedDividendsNative`、`cumulativePnlPercent = cumulativePnlNative / totalBuyCostNative * 100`。 - SELL 交易的已實現盈虧計算從 CNY 基準改為 Native 基準,CNY 計算保留用於前端兼容展示。 +## Dashboard 表格化基礎重構 (Task 41) +- Dashboard 完成表格化基礎重構,徹底移除原有的卡片網格佈局,改用 shadcn/ui Table 組件構建高密度專業券商風格主表。 +- 實裝了基於 ID 的行展開狀態管理邏輯:`useState>` 控制每行的展開/收起狀態,點擊行或展開按鈕觸發 `toggleExpand`。 +- 表頭嚴格對齊專業標準:名稱/代碼 | 現價 | 市值 | 持倉 | 攤薄/成本 | 浮動盈虧 | 累計盈虧 | 操作。 +- 操作列包含展開/收起指示器與「添加」按鈕,點擊打開 `AddTransactionDialog` 並預選對應資產。 +- 展開行內嵌交易記錄子表格,展示每筆交易的類型、數量、價格、手續費、幣種與日期。 +- 重構 `AddTransactionDialog` 組件支持外部控制開關(`open`/`onOpenChange`/`defaultAssetId` props),同時保持向後兼容內部狀態管理模式。 +- Dashboard 頁面轉換為客戶端組件,使用 `useEffect` 在客戶端加載組合數據。 + ## 全面重構資產展示 UI (Task 39) - UI 全面升級,復刻專業券商級數據排版,合併攤薄/成本,引入原生幣種盈虧百分比展示。 - 徹底清理所有帶 (CNY) 和 (USD) 混雜的舊布局,所有 Native 金額根據 `baseCurrency` 渲染正確貨幣符號(USD→$、CNY/HKD→HK$、JPY→¥)。 diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 2fa46e9..b6a289d 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,14 +1,31 @@ +'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$'; - return '¥'; + if (currency === 'CNY') return '¥'; + if (currency === 'JPY') return '¥'; + return currency + ' '; } function formatNative(value: string, baseCurrency: string): string { @@ -28,8 +45,48 @@ function formatPnl(value: string, percent: string, baseCurrency: string): { text return { text, className }; } -export default async function DashboardPage() { - const { positions, totalCnyValue, chartData, totalPnlCny, unrealizedPnlCny, marketAllocation } = await getPortfolioSummary(); +interface Asset { + id: string; + symbol: string; + name: string | null; + type: string; + baseCurrency: string; + exchange: string | null; +} + +export default function DashboardPage() { + const [positions, setPositions] = useState([]); + const [totalCnyValue, setTotalCnyValue] = useState('0'); + const [totalPnlCny, setTotalPnlCny] = useState('0'); + const [unrealizedPnlCny, setUnrealizedPnlCny] = useState('0'); + const [marketAllocation, setMarketAllocation] = useState([]); + const [assets, setAssets] = useState([]); + const [expandedIds, setExpandedIds] = useState>({}); + const [dialogOpen, setDialogOpen] = useState(false); + const [selectedAssetId, setSelectedAssetId] = useState(''); + + 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); @@ -37,15 +94,6 @@ export default async function DashboardPage() { const totalPnlIsPositive = new Big(totalPnlCny).gte(0); const unrealizedIsPositive = new Big(unrealizedPnlCny).gte(0); - function DataRow({ label, value, valueClass = 'font-medium' }: { label: string; value: string; valueClass?: string }) { - return ( -
- {label} - {value} -
- ); - } - return (
@@ -81,77 +129,178 @@ export default async function DashboardPage() { -
- {positions.length === 0 ? ( - - -

暂无持仓,请先添加资产和交易记录。

-
-
- ) : ( - 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); + + + 持仓明细 + + + {positions.length === 0 ? ( +
+ 暂无持仓,请先添加资产和交易记录。 +
+ ) : ( + + + + 名称/代码 + 现價 + 市值 + 持倉 + 攤薄/成本 + 浮動盈虧 + 累計盈虧 + 操作 + + + + {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); - const costCombined = `${dilutedCostStr} / ${avgCostStr}`; + const dilutedCostStr = new Big(pos.dilutedCostNative).toFixed(2); + const avgCostStr = new Big(pos.avgCostNative).toFixed(2); - const floatingPnl = formatPnl(pos.floatingPnlNative, pos.floatingPnlPercent, pos.baseCurrency); - const cumulativePnl = formatPnl(pos.cumulativePnlNative, pos.cumulativePnlPercent, pos.baseCurrency); + let costDisplay = '-'; + if (!new Big(pos.dilutedCostNative).eq('0') || !new Big(pos.avgCostNative).eq('0')) { + costDisplay = `${dilutedCostStr} / ${avgCostStr}`; + } - return ( - - - -
- {pos.name || pos.symbol} - {pos.symbol} -
- - {pos.baseCurrency} - -
-
- -
- - - - - - - -
-
-
- ); - }) - )} - + const floatingPnl = formatPnl(pos.floatingPnlNative, pos.floatingPnlPercent, pos.baseCurrency); + const cumulativePnl = formatPnl(pos.cumulativePnlNative, pos.cumulativePnlPercent, pos.baseCurrency); + + const isExpanded = !!expandedIds[pos.assetId]; + + return ( + <> + toggleExpand(pos.assetId)}> + +
+ {pos.name || pos.symbol} + {pos.symbol} +
+
+ {latestPriceFormatted} + {marketValueFormatted} + {quantityFormatted} + {costDisplay} + {floatingPnl.text} + {cumulativePnl.text} + e.stopPropagation()}> +
+ + {isExpanded ? '收起' : '展开'} + + {isExpanded ? ( + + ) : ( + + )} + +
+
+
+ {isExpanded && ( + + +
+
+ 交易記錄 + +
+ {pos.transactions && pos.transactions.length > 0 ? ( +
+ + + 類型 + 數量 + 價格 + 手續費 + 幣種 + 日期 + + + + {pos.transactions.map((tx: any, idx: number) => { + const txDate = tx.executedAt + ? new Date(tx.executedAt).toLocaleString('zh-TW') + : '-'; + return ( + + + + {tx.txType} + + + {tx.quantity} + {tx.price} + {tx.fee || '0'} + {tx.txCurrency} + {txDate} + + ); + })} + +
+ ) : ( +

暫無交易記錄

+ )} +
+ + + )} + + ); + })} + + + )} + + - 资产分布 + 資產分布 + +
); } diff --git a/src/components/transactions/add-transaction-dialog.tsx b/src/components/transactions/add-transaction-dialog.tsx index 15ff865..13dcc42 100644 --- a/src/components/transactions/add-transaction-dialog.tsx +++ b/src/components/transactions/add-transaction-dialog.tsx @@ -32,6 +32,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; + import { Plus } from 'lucide-react'; import { toast } from 'sonner'; import { createTransaction } from '@/actions/transaction'; @@ -60,6 +61,9 @@ interface Asset { interface AddTransactionDialogProps { assets: Asset[]; + open?: boolean; + onOpenChange?: (open: boolean) => void; + defaultAssetId?: string; } const txTypeLabels: Record = { @@ -77,15 +81,23 @@ const exchangeToCurrencyMap: Record = { 'SZSE': 'CNY', }; -export function AddTransactionDialog({ assets }: AddTransactionDialogProps) { - const [open, setOpen] = useState(false); +export function AddTransactionDialog({ assets, open: openProp, onOpenChange, defaultAssetId }: AddTransactionDialogProps) { + const [internalOpen, setInternalOpen] = useState(false); const [isPending, startTransition] = useTransition(); const router = useRouter(); + const open = openProp !== undefined ? openProp : internalOpen; + const setOpen = (value: boolean) => { + if (openProp === undefined) { + setInternalOpen(value); + } + onOpenChange?.(value); + }; + const form = useForm({ resolver: zodResolver(addTransactionSchema), defaultValues: { - assetId: '', + assetId: defaultAssetId || '', txType: 'BUY' as const, quantity: '', price: '', @@ -97,6 +109,12 @@ export function AddTransactionDialog({ assets }: AddTransactionDialogProps) { const selectedAssetId = useWatch({ control: form.control, name: 'assetId' }); + useEffect(() => { + if (defaultAssetId) { + form.setValue('assetId', defaultAssetId, { shouldValidate: true }); + } + }, [defaultAssetId, form]); + useEffect(() => { if (!selectedAssetId) return; const selectedAsset = assets.find((a) => a.id === selectedAssetId); @@ -128,13 +146,16 @@ export function AddTransactionDialog({ assets }: AddTransactionDialogProps) { } return ( - - - - + <> + {!openProp && ( + + + + )} + 添加交易流水 @@ -322,5 +343,6 @@ export function AddTransactionDialog({ assets }: AddTransactionDialogProps) { + ); }