diff --git a/Memory.md b/Memory.md index 733559e..4a70c55 100644 --- a/Memory.md +++ b/Memory.md @@ -79,6 +79,15 @@ - 新增累計盈虧指標:`cumulativePnlNative = floatingPnlNative + realizedPnlNative + accumulatedDividendsNative`、`cumulativePnlPercent = cumulativePnlNative / totalBuyCostNative * 100`。 - SELL 交易的已實現盈虧計算從 CNY 基準改為 Native 基準,CNY 計算保留用於前端兼容展示。 +## Dashboard 流水下鑽明細與行內 CRUD (Task 41b) +- 完成 Dashboard 流水下鑽功能,支持在資產列表中直接查看、修改和刪除歷史交易流水。 +- 在主行 `TableRow` 下方,根據 `expandedIds[pos.assetId]` 條件渲染第二個子行,使用 `` 確保子行佔滿整行寬度。 +- 構建流水明細次級表格:遍歷 `pos.transactions` 數組,表頭為「交易日期 | 類型 | 價格/數量 | 手續費 | 備註 | 操作」,精確渲染每筆交易的歷史數據。 +- 實裝 `UpdateTransactionDialog` 組件:「修改」按鈕打開彈窗並回顯該筆流水數據(數量、價格、手續費、幣種、執行時間),提交後調用 `updateTransaction` Action 並刷新頁面。 +- 實裝行內刪除功能:「刪除」按鈕彈出確認對話框,確認後調用 `deleteTransaction` Action 並刷新頁面。 +- 在 `portfolio.ts` 的 `TransactionRecord` 接口中補充 `id` 和 `fee` 字段,並在下發數據時包含完整的流水 ID,以支持行內 CRUD 操作。 +- UI 優化:為展開的子表格添加左側彩色邊框 (`border-l-4 border-primary/20`) 與左側縮進 (`ml-2`) 增強層級感;展開/收起按鈕文字根據狀態動態切換(「收起」/「展開」)。 + ## Dashboard 表格化基礎重構 (Task 41) - Dashboard 完成表格化基礎重構,徹底移除原有的卡片網格佈局,改用 shadcn/ui Table 組件構建高密度專業券商風格主表。 - 實裝了基於 ID 的行展開狀態管理邏輯:`useState>` 控制每行的展開/收起狀態,點擊行或展開按鈕觸發 `toggleExpand`。 diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index b6a289d..75e8948 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useTransition } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Table, @@ -11,13 +11,24 @@ import { 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 { ChevronDown, ChevronUp, Plus, Edit3 } from 'lucide-react'; +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'; function getCurrencySymbol(currency: string): string { @@ -64,6 +75,9 @@ export default function DashboardPage() { const [expandedIds, setExpandedIds] = useState>({}); const [dialogOpen, setDialogOpen] = useState(false); const [selectedAssetId, setSelectedAssetId] = useState(''); + const [isPending, startTransition] = useTransition(); + const [updateTarget, setUpdateTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); useEffect(() => { async function loadData() { @@ -88,6 +102,27 @@ export default function DashboardPage() { 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); @@ -214,65 +249,108 @@ export default function DashboardPage() { {isExpanded && ( - -
-
- 交易記錄 - + +
+
+
+ 流水明細 + +
+ {pos.transactions && pos.transactions.length > 0 ? ( + + + + 交易日期 + 類型 + 價格/數量 + 手續費 + 備註 + 操作 + + + + {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 ( + + + {txDate} + + + + {tx.txType} + + + +
+ 量: {tx.quantity} + 價: {tx.price} +
+
+ {tx.fee || '0'} + - + +
+ + +
+
+
+ ); + })} +
+
+ ) : ( +

暫無交易記錄

+ )}
- {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} - - ); - })} - -
- ) : ( -

暫無交易記錄

- )}
@@ -301,6 +379,36 @@ export default function DashboardPage() { onOpenChange={setDialogOpen} defaultAssetId={selectedAssetId} /> + + !open && setUpdateTarget(null)} + transaction={updateTarget} + onSuccess={handleUpdateSubmit} + /> + + !open && setDeleteTarget(null)}> + + + 確認刪除 + + 確定要刪除這筆交易記錄嗎?此操作不可撤銷。 + + + + + + + +
); } diff --git a/src/actions/portfolio.ts b/src/actions/portfolio.ts index abd5062..a8337e5 100644 --- a/src/actions/portfolio.ts +++ b/src/actions/portfolio.ts @@ -41,9 +41,11 @@ interface Position { } interface TransactionRecord { + id: string; txType: string; quantity: string; price: string; + fee: string; txCurrency: string; executedAt: Date | null; } @@ -137,9 +139,11 @@ function getTodayInShanghai(): Date { export async function getPortfolioPositions(): Promise { const allTransactions = await db .select({ + id: transactions.id, txType: transactions.txType, quantity: transactions.quantity, price: transactions.price, + fee: transactions.fee, exchangeRate: transactions.exchangeRate, txCurrency: transactions.txCurrency, assetId: transactions.assetId, @@ -279,9 +283,11 @@ export async function getPortfolioPositions(): Promise { } holding.transactions.push({ + id: tx.id, txType: tx.txType, quantity: tx.quantity, price: tx.price, + fee: tx.fee, txCurrency: tx.txCurrency, executedAt: tx.executedAt, }); diff --git a/src/components/transactions/update-transaction-dialog.tsx b/src/components/transactions/update-transaction-dialog.tsx new file mode 100644 index 0000000..cb641d8 --- /dev/null +++ b/src/components/transactions/update-transaction-dialog.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; +import { toast } from 'sonner'; +import { updateTransaction } from '@/actions/transaction'; + +const updateTransactionSchema = z.object({ + quantity: z.string().regex(/^-?\d+(\.\d+)?$/, '数量必须是数字'), + price: z.string().regex(/^-?\d+(\.\d+)?$/, '价格必须是数字'), + fee: z.string().regex(/^-?\d+(\.\d+)?$/, '手续费必须是数字').default('0'), + txCurrency: z.string().min(1, '交易币种不能为空'), + executedAt: z.string(), +}); + +type UpdateForm = z.infer; + +interface UpdateTransactionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + transaction: { + id: string; + txType: string; + quantity: string; + price: string; + fee: string; + txCurrency: string; + executedAt: Date | null; + } | null; + onSuccess: () => void; +} + +export function UpdateTransactionDialog({ + open, + onOpenChange, + transaction, + onSuccess, +}: UpdateTransactionDialogProps) { + const [isPending, startTransition] = useTransition(); + + const form = useForm({ + resolver: zodResolver(updateTransactionSchema), + defaultValues: { + quantity: '', + price: '', + fee: '0', + txCurrency: 'USD', + executedAt: '', + }, + }); + + useState(() => { + if (transaction && open) { + form.reset({ + quantity: transaction.quantity.toString(), + price: transaction.price.toString(), + fee: transaction.fee.toString(), + txCurrency: transaction.txCurrency, + executedAt: transaction.executedAt + ? new Date(transaction.executedAt).toISOString().slice(0, 16) + : '', + }); + } + }); + + function handleSubmit(values: UpdateForm) { + if (!transaction) return; + startTransition(async () => { + const result = await updateTransaction({ + id: transaction.id, + quantity: values.quantity, + price: values.price, + fee: values.fee, + txCurrency: values.txCurrency, + executedAt: new Date(values.executedAt), + }); + if (result.success) { + toast.success('交易记录已更新'); + onOpenChange(false); + onSuccess(); + } else if (result.error) { + toast.error(result.error); + } + }); + } + + return ( + + + + 編輯交易 + 修改交易記錄詳情 + +
+ +
+ ( + + 數量 + + + + + + )} + /> + ( + + 價格 + + + + + + )} + /> +
+
+ ( + + 手續費 + + + + + + )} + /> + ( + + 交易幣種 + + + + )} + /> +
+ ( + + 執行時間 + + + + + + )} + /> + + + + + +
+
+ ); +}