diff --git a/app/dashboard/assets/assets-client.tsx b/app/dashboard/assets/assets-client.tsx new file mode 100644 index 0000000..e656bab --- /dev/null +++ b/app/dashboard/assets/assets-client.tsx @@ -0,0 +1,260 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { + Table, + TableBody, + TableCaption, + 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 { + 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 { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { Edit, Trash2 } from 'lucide-react'; +import Big from 'big.js'; +import { updateAsset, deleteAsset } from '@/actions/asset'; +import { AddAssetDialog } from '@/components/assets/add-asset-dialog'; +import { SyncButton } from '@/components/assets/sync-button'; + +interface Asset { + id: string; + symbol: string; + name: string | null; + type: string; + exchange: string | null; + baseCurrency: string; + latestPrice: string; + createdAt: Date | null; +} + +export default function AssetsPageClient({ assets }: { assets: Asset[] }) { + const [isPending, startTransition] = useTransition(); + const [deleteTarget, setDeleteTarget] = useState(null); + const [editTarget, setEditTarget] = useState(null); + + const typeLabels: Record = { + STOCK: '股票', + CRYPTO: '加密貨幣', + CASH: '現金', + }; + + const editForm = useForm<{ symbol: string; name: string; baseCurrency: string }>({ + resolver: zodResolver(z.object({ + symbol: z.string().min(1, '资产代码不能为空'), + name: z.string(), + baseCurrency: z.string().min(2, '基础币种至少2个字符'), + })), + defaultValues: { symbol: '', name: '', baseCurrency: '' }, + }); + + function handleEditClick(asset: Asset) { + setEditTarget(asset); + editForm.reset({ symbol: asset.symbol, name: asset.name || '', baseCurrency: asset.baseCurrency }); + } + + function handleEditSubmit(values: { symbol: string; name: string; baseCurrency: string }) { + if (!editTarget) return; + startTransition(async () => { + const result = await updateAsset({ + id: editTarget.id, + symbol: values.symbol, + baseCurrency: values.baseCurrency, + }); + if (result.success) { + setEditTarget(null); + editForm.reset(); + window.location.reload(); + } + }); + } + + function handleDelete(asset: Asset) { + startTransition(async () => { + await deleteAsset(asset.id); + setDeleteTarget(null); + window.location.reload(); + }); + } + + return ( +
+
+

资产列表

+
+ + +
+
+ +
+ + 数据库中所有已录入的资产 + + + 名称 + 资产代码 + 类型 + 基础币种 + 当前市价 (Latest Price) + 创建时间 + 操作 + + + + {assets.length === 0 ? ( + + + 暂无资产,点击"添加资产"按钮录入第一个资产 + + + ) : ( + assets.map((asset) => ( + + {asset.name || '-'} + {asset.symbol} + {typeLabels[asset.type] || asset.type} + {asset.baseCurrency} + {asset.latestPrice ? new Big(asset.latestPrice).toString() : '-'} + + {asset.createdAt + ? new Date(asset.createdAt).toLocaleString('zh-CN') + : '-'} + + +
+ + +
+
+
+ )) + )} +
+
+
+ + !open && setEditTarget(null)}> + + + 编辑资产 + 修改资产的基本信息 + +
+ + ( + + 资产代码 + + + + + + )} + /> + ( + + 股票名称 + + + + + + )} + /> + ( + + 基础币种 + + + + + + )} + /> + + + + + +
+
+ + !open && setDeleteTarget(null)}> + + + 确认删除 + + 确定要删除资产 {deleteTarget?.symbol} 吗?此操作不可撤销。 + + + + + + + + +
+ ); +} diff --git a/app/dashboard/assets/page.tsx b/app/dashboard/assets/page.tsx index 8679f11..d4abe91 100644 --- a/app/dashboard/assets/page.tsx +++ b/app/dashboard/assets/page.tsx @@ -1,73 +1,7 @@ import { getAssets } from '@/actions/asset'; -import { AddAssetDialog } from '@/components/assets/add-asset-dialog'; -import { SyncButton } from '@/components/assets/sync-button'; -import { - Table, - TableBody, - TableCaption, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import Big from 'big.js'; +import AssetsPageClient from './assets-client'; export default async function AssetsPage() { const assets = await getAssets(); - - const typeLabels: Record = { - STOCK: '股票', - CRYPTO: '加密貨幣', - CASH: '現金', - }; - - return ( -
-
-

資產列表

-
- - -
-
- -
- - 數據庫中所有已錄入的資產 - - - 資產代碼 - 類型 - 基礎幣種 - 當前市價 (Latest Price) - 創建時間 - - - - {assets.length === 0 ? ( - - - 暫無資產,點擊"添加資產"按鈕錄入第一個資產 - - - ) : ( - assets.map((asset) => ( - - {asset.symbol} - {typeLabels[asset.type] || asset.type} - {asset.baseCurrency} - {asset.latestPrice ? new Big(asset.latestPrice).toString() : '-'} - - {asset.createdAt - ? new Date(asset.createdAt).toLocaleString('zh-CN') - : '-'} - - - )) - )} - -
-
-
- ); + return ; } diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index b30ec1d..170ae52 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -71,7 +71,10 @@ export default async function DashboardPage() { - {pos.symbol} +
+ {pos.name || pos.symbol} + {pos.symbol} +
{pos.type} diff --git a/app/dashboard/transactions/page.tsx b/app/dashboard/transactions/page.tsx index 441486e..e7128f3 100644 --- a/app/dashboard/transactions/page.tsx +++ b/app/dashboard/transactions/page.tsx @@ -1,16 +1,6 @@ import { getAssets } from '@/actions/asset'; import { getTransactions } from '@/actions/transaction'; -import { AddTransactionDialog } from '@/components/transactions/add-transaction-dialog'; -import { formatAmount, formatQuantity } from '@/lib/formatters'; -import { - Table, - TableBody, - TableCaption, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; +import TransactionsPageClient from './transactions-client'; export default async function TransactionsPage() { const [assets, transactions] = await Promise.all([ @@ -18,74 +8,5 @@ export default async function TransactionsPage() { getTransactions(), ]); - const typeLabels: Record = { - BUY: '买入', - SELL: '卖出', - DIVIDEND: '分红', - AIRDROP: '空投', - FEE: '手续费', - }; - - const assetMap = new Map(assets.map((a) => [a.id, { symbol: a.symbol, type: a.type }])); - - return ( -
-
-

交易流水

- -
- -
- - - 共 {transactions.length} 条交易记录 - - - - 标的 - 类型 - 数量 - 价格 - 手续费 - 币种 - 执行时间 - - - - {transactions.length === 0 ? ( - - - 暂无流水,点击"添加流水"按钮录入第一笔交易 - - - ) : ( - transactions.map((tx) => { - const assetInfo = assetMap.get(tx.assetId); - const symbol = assetInfo?.symbol || tx.assetId; - const assetType = assetInfo?.type || 'CASH'; - return ( - - {symbol} - {typeLabels[tx.txType] || tx.txType} - {formatQuantity(tx.quantity, assetType)} - {formatAmount(tx.price)} - {formatAmount(tx.fee)} - {tx.txCurrency} - - {tx.executedAt - ? new Date(tx.executedAt).toLocaleString('zh-CN') - : '-'} - - - ); - }) - )} - -
-
-
- ); + return ; } diff --git a/app/dashboard/transactions/transactions-client.tsx b/app/dashboard/transactions/transactions-client.tsx new file mode 100644 index 0000000..779f0f7 --- /dev/null +++ b/app/dashboard/transactions/transactions-client.tsx @@ -0,0 +1,338 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { + Table, + TableBody, + TableCaption, + 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 { + 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 { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { Edit, Trash2 } from 'lucide-react'; +import { formatAmount, formatQuantity } from '@/lib/formatters'; +import { deleteTransaction, updateTransaction } from '@/actions/transaction'; +import { AddTransactionDialog } from '@/components/transactions/add-transaction-dialog'; + +interface Asset { + id: string; + symbol: string; + name: string | null; + type: string; + baseCurrency: string; +} + +interface Transaction { + id: string; + assetId: string; + txType: string; + quantity: string; + price: string; + fee: string; + txCurrency: string; + executedAt: Date; +} + +export default function TransactionsPageClient({ + assets, + transactions, +}: { + assets: Asset[]; + transactions: Transaction[]; +}) { + const [isPending, startTransition] = useTransition(); + const [deleteTarget, setDeleteTarget] = useState(null); + const [editTarget, setEditTarget] = useState(null); + + const typeLabels: Record = { + BUY: '买入', + SELL: '卖出', + DIVIDEND: '分红', + AIRDROP: '空投', + FEE: '手续费', + }; + + const assetMap = new Map(assets.map((a) => [a.id, { symbol: a.symbol, name: a.name, type: a.type }])); + + const editForm = useForm<{ quantity: string; price: string; fee: string; txCurrency: string; executedAt: string }>({ + resolver: zodResolver(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(), + })), + defaultValues: { quantity: '', price: '', fee: '0', txCurrency: 'USD', executedAt: '' }, + }); + + function handleEditClick(tx: Transaction) { + setEditTarget(tx); + editForm.reset({ + quantity: tx.quantity.toString(), + price: tx.price.toString(), + fee: tx.fee.toString(), + txCurrency: tx.txCurrency, + executedAt: tx.executedAt + ? new Date(tx.executedAt).toISOString().slice(0, 16) + : '', + }); + } + + function handleEditSubmit(values: { quantity: string; price: string; fee: string; txCurrency: string; executedAt: string }) { + if (!editTarget) return; + startTransition(async () => { + const result = await updateTransaction({ + id: editTarget.id, + quantity: values.quantity, + price: values.price, + fee: values.fee, + txCurrency: values.txCurrency, + executedAt: new Date(values.executedAt), + }); + if (result.success) { + setEditTarget(null); + editForm.reset(); + window.location.reload(); + } + }); + } + + function handleDelete(tx: Transaction) { + startTransition(async () => { + await deleteTransaction(tx.id); + setDeleteTarget(null); + window.location.reload(); + }); + } + + return ( +
+
+

交易流水

+ +
+ +
+ + + 共 {transactions.length} 条交易记录 + + + + 名称 + 标的 + 类型 + 数量 + 价格 + 手续费 + 币种 + 执行时间 + 操作 + + + + {transactions.length === 0 ? ( + + + 暂无流水,点击"添加流水"按钮录入第一笔交易 + + + ) : ( + transactions.map((tx) => { + const assetInfo = assetMap.get(tx.assetId); + const symbol = assetInfo?.symbol || tx.assetId; + const name = assetInfo?.name || null; + const assetType = assetInfo?.type || 'CASH'; + return ( + + {name || '-'} + {symbol} + {typeLabels[tx.txType] || tx.txType} + {formatQuantity(tx.quantity, assetType)} + {formatAmount(tx.price)} + {formatAmount(tx.fee)} + {tx.txCurrency} + + {tx.executedAt + ? new Date(tx.executedAt).toLocaleString('zh-CN') + : '-'} + + +
+ + +
+
+
+ ); + }) + )} +
+
+
+ + !open && setEditTarget(null)}> + + + 编辑交易 + 修改交易记录详情 + +
+ +
+ ( + + 数量 + + + + + + )} + /> + ( + + 价格 + + + + + + )} + /> +
+
+ ( + + 手续费 + + + + + + )} + /> + ( + + 交易币种 + + + + )} + /> +
+ ( + + 执行时间 + + + + + + )} + /> + + + + + +
+
+ + !open && setDeleteTarget(null)}> + + + 确认删除 + + 确定要删除这笔交易记录吗?此操作不可撤销。 + + + + + + + + +
+ ); +} diff --git a/src/actions/market.ts b/src/actions/market.ts index ac30794..7e76ca6 100644 --- a/src/actions/market.ts +++ b/src/actions/market.ts @@ -37,8 +37,9 @@ export async function syncAllStockPrices() { const latestPrice = dataArr[3]; if (latestPrice && !isNaN(Number(latestPrice)) && Number(latestPrice) > 0) { + const stockName = dataArr[1] || null; await db.update(assets) - .set({ latestPrice: latestPrice }) + .set({ latestPrice: latestPrice, name: stockName }) .where(eq(assets.id, asset.id)); successCount++; } diff --git a/src/actions/portfolio.ts b/src/actions/portfolio.ts index 027b8a7..53d2395 100644 --- a/src/actions/portfolio.ts +++ b/src/actions/portfolio.ts @@ -8,6 +8,7 @@ import { desc, eq } from 'drizzle-orm'; interface Position { assetId: string; symbol: string; + name: string | null; type: string; quantity: string; baseCurrency: string; @@ -82,6 +83,7 @@ export async function getPortfolioPositions(): Promise { txCurrency: transactions.txCurrency, assetId: transactions.assetId, assetSymbol: assets.symbol, + assetName: assets.name, assetType: assets.type, assetBaseCurrency: assets.baseCurrency, assetLatestPrice: assets.latestPrice, @@ -101,6 +103,7 @@ export async function getPortfolioPositions(): Promise { const holdings = new Map { holdings.set(tx.assetId, { assetId: tx.assetId, symbol: tx.assetSymbol || tx.assetId, + name: tx.assetName, type: tx.assetType || 'CASH', quantity: new Big('0'), baseCurrency: tx.assetBaseCurrency || '', @@ -190,6 +194,7 @@ export async function getPortfolioPositions(): Promise { result.push({ assetId: holding.assetId, symbol: holding.symbol, + name: holding.name, type: holding.type, quantity: holding.quantity.toString(), baseCurrency: holding.baseCurrency, diff --git a/src/components/assets/add-asset-dialog.tsx b/src/components/assets/add-asset-dialog.tsx index 39d36a8..a065432 100644 --- a/src/components/assets/add-asset-dialog.tsx +++ b/src/components/assets/add-asset-dialog.tsx @@ -1,10 +1,11 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState, useTransition, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useRouter } from 'next/navigation'; +import { useWatch } from 'react-hook-form'; import { Dialog, DialogContent, @@ -61,10 +62,26 @@ export function AddAssetDialog() { symbol: '', type: 'STOCK' as const, exchange: 'US', - baseCurrency: '', + baseCurrency: 'USD', }, }); + const exchangeValue = useWatch({ control: form.control, name: 'exchange' }); + + useEffect(() => { + if (!exchangeValue) return; + const currencyMap: Record = { + 'US': 'USD', + 'HKEX': 'HKD', + 'SSE': 'CNY', + 'SZSE': 'CNY', + }; + const currency = currencyMap[exchangeValue]; + if (currency) { + form.setValue('baseCurrency', currency, { shouldValidate: true }); + } + }, [exchangeValue, form]); + function onSubmit(values: AddAssetForm) { startTransition(async () => { const result = await createAsset(values); diff --git a/src/components/transactions/add-transaction-dialog.tsx b/src/components/transactions/add-transaction-dialog.tsx index 99dbe86..86174f4 100644 --- a/src/components/transactions/add-transaction-dialog.tsx +++ b/src/components/transactions/add-transaction-dialog.tsx @@ -1,10 +1,11 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState, useTransition, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useRouter } from 'next/navigation'; +import { useWatch } from 'react-hook-form'; import { Dialog, DialogContent, @@ -49,8 +50,10 @@ type AddTransactionForm = z.infer; interface Asset { id: string; symbol: string; + name: string | null; type: string; baseCurrency: string; + exchange: string | null; } interface AddTransactionDialogProps { @@ -65,6 +68,13 @@ const txTypeLabels: Record = { FEE: '手续费 (FEE)', }; +const exchangeToCurrencyMap: Record = { + 'US': 'USD', + 'HKEX': 'HKD', + 'SSE': 'CNY', + 'SZSE': 'CNY', +}; + export function AddTransactionDialog({ assets }: AddTransactionDialogProps) { const [open, setOpen] = useState(false); const [isPending, startTransition] = useTransition(); @@ -83,6 +93,17 @@ export function AddTransactionDialog({ assets }: AddTransactionDialogProps) { }, }); + const selectedAssetId = useWatch({ control: form.control, name: 'assetId' }); + + useEffect(() => { + if (!selectedAssetId) return; + const selectedAsset = assets.find((a) => a.id === selectedAssetId); + if (selectedAsset) { + const currency = exchangeToCurrencyMap[selectedAsset.exchange || ''] || selectedAsset.baseCurrency || 'USD'; + form.setValue('txCurrency', currency, { shouldValidate: true }); + } + }, [selectedAssetId, assets, form]); + function onSubmit(values: AddTransactionForm) { startTransition(async () => { const result = await createTransaction({ @@ -136,7 +157,7 @@ export function AddTransactionDialog({ assets }: AddTransactionDialogProps) { ) : ( assets.map((asset) => ( - {asset.symbol} + {asset.symbol}{asset.name ? ` (${asset.name})` : ''} )) )}