feat(ledger): 引入 latestPrice 字段与历史成本追踪,实装 P&L 盈亏计算引擎
This commit is contained in:
parent
27c3b76bba
commit
b9186d4699
@ -1,5 +1,6 @@
|
|||||||
import { getAssets } from '@/actions/asset';
|
import { getAssets } from '@/actions/asset';
|
||||||
import { AddAssetDialog } from '@/components/assets/add-asset-dialog';
|
import { AddAssetDialog } from '@/components/assets/add-asset-dialog';
|
||||||
|
import { UpdatePriceDialog } from '@/components/assets/update-price-dialog';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -15,33 +16,35 @@ export default async function AssetsPage() {
|
|||||||
|
|
||||||
const typeLabels: Record<string, string> = {
|
const typeLabels: Record<string, string> = {
|
||||||
STOCK: '股票',
|
STOCK: '股票',
|
||||||
CRYPTO: '加密货币',
|
CRYPTO: '加密貨幣',
|
||||||
CASH: '现金',
|
CASH: '現金',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">资产列表</h1>
|
<h1 className="text-2xl font-bold">資產列表</h1>
|
||||||
<AddAssetDialog />
|
<AddAssetDialog />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableCaption>数据库中所有已录入的资产</TableCaption>
|
<TableCaption>數據庫中所有已錄入的資產</TableCaption>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>资产代码</TableHead>
|
<TableHead>資產代碼</TableHead>
|
||||||
<TableHead>类型</TableHead>
|
<TableHead>類型</TableHead>
|
||||||
<TableHead>基础币种</TableHead>
|
<TableHead>基礎幣種</TableHead>
|
||||||
<TableHead>创建时间</TableHead>
|
<TableHead>當前市價 (Latest Price)</TableHead>
|
||||||
|
<TableHead>創建時間</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{assets.length === 0 ? (
|
{assets.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||||
暂无资产,点击"添加资产"按钮录入第一个资产
|
暫無資產,點擊"添加資產"按鈕錄入第一個資產
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
@ -50,11 +53,15 @@ export default async function AssetsPage() {
|
|||||||
<TableCell className="font-medium">{asset.symbol}</TableCell>
|
<TableCell className="font-medium">{asset.symbol}</TableCell>
|
||||||
<TableCell>{typeLabels[asset.type] || asset.type}</TableCell>
|
<TableCell>{typeLabels[asset.type] || asset.type}</TableCell>
|
||||||
<TableCell>{asset.baseCurrency}</TableCell>
|
<TableCell>{asset.baseCurrency}</TableCell>
|
||||||
|
<TableCell>{asset.latestPrice}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{asset.createdAt
|
{asset.createdAt
|
||||||
? new Date(asset.createdAt).toLocaleString('zh-CN')
|
? new Date(asset.createdAt).toLocaleString('zh-CN')
|
||||||
: '-'}
|
: '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<UpdatePriceDialog assetId={asset.id} currentPrice={asset.latestPrice || '0'} />
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { getPortfolioSummary } from '@/actions/portfolio';
|
import { getPortfolioSummary } from '@/actions/portfolio';
|
||||||
import { formatQuantity, formatAmount } from '@/lib/formatters';
|
import { formatQuantity, formatAmount } from '@/lib/formatters';
|
||||||
import AllocationChart from '@/components/dashboard/allocation-chart';
|
import AllocationChart from '@/components/dashboard/allocation-chart';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
const CHART_COLORS = [
|
const CHART_COLORS = [
|
||||||
'#3b82f6',
|
'#3b82f6',
|
||||||
@ -13,9 +14,11 @@ const CHART_COLORS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const { positions, totalCnyValue, chartData } = await getPortfolioSummary();
|
const { positions, totalCnyValue, chartData, totalPnlCny } = await getPortfolioSummary();
|
||||||
|
|
||||||
const formattedTotal = formatAmount(totalCnyValue);
|
const formattedTotal = formatAmount(totalCnyValue);
|
||||||
|
const formattedPnl = formatAmount(totalPnlCny);
|
||||||
|
const pnlIsPositive = new Big(totalPnlCny).gte(0);
|
||||||
|
|
||||||
const displayChartData = chartData.map((item) => ({
|
const displayChartData = chartData.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
@ -42,6 +45,12 @@ export default async function DashboardPage() {
|
|||||||
总资产 (CNY)
|
总资产 (CNY)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">总盈亏:</span>
|
||||||
|
<span className={`text-lg font-semibold ${pnlIsPositive ? 'text-green-500' : 'text-red-500'}`}>
|
||||||
|
{pnlIsPositive ? '+' : ''}{formattedPnl}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -53,38 +62,56 @@ export default async function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
positions.map((pos) => (
|
positions.map((pos) => {
|
||||||
<Card key={pos.assetId}>
|
const posPnl = new Big(pos.pnlCny);
|
||||||
<CardHeader>
|
const posPnlPositive = posPnl.gte(0);
|
||||||
<CardTitle className="flex items-center justify-between">
|
const formattedPosPnl = formatAmount(pos.pnlCny);
|
||||||
<span>{pos.symbol}</span>
|
|
||||||
<span className="text-sm font-normal text-muted-foreground">
|
return (
|
||||||
{pos.type}
|
<Card key={pos.assetId}>
|
||||||
</span>
|
<CardHeader>
|
||||||
</CardTitle>
|
<CardTitle className="flex items-center justify-between">
|
||||||
</CardHeader>
|
<span>{pos.symbol}</span>
|
||||||
<CardContent>
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
<div className="space-y-2">
|
{pos.type}
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">持仓数量</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{formatQuantity(pos.quantity, pos.type)}
|
|
||||||
</span>
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">持仓数量</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatQuantity(pos.quantity, pos.type)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">结算币种</span>
|
||||||
|
<span className="font-medium">{pos.baseCurrency}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">CNY 估值</span>
|
||||||
|
<span className="font-medium text-green-600">
|
||||||
|
¥{formatAmount(pos.cnyValue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">成本 (CNY)</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
¥{formatAmount(pos.totalCostCny)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">盈亏</span>
|
||||||
|
<span className={`font-semibold ${posPnlPositive ? 'text-green-500' : 'text-red-500'}`}>
|
||||||
|
{posPnlPositive ? '+' : ''}{formattedPosPnl}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
</CardContent>
|
||||||
<span className="text-muted-foreground">结算币种</span>
|
</Card>
|
||||||
<span className="font-medium">{pos.baseCurrency}</span>
|
);
|
||||||
</div>
|
})
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">CNY 估值</span>
|
|
||||||
<span className="font-medium text-green-600">
|
|
||||||
¥{formatAmount(pos.cnyValue)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { assets, assetTypeEnum } from '@/db/schema';
|
import { assets, assetTypeEnum } from '@/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const createAssetSchema = z.object({
|
const createAssetSchema = z.object({
|
||||||
@ -34,4 +36,24 @@ export async function createAsset(params: z.infer<typeof createAssetSchema>) {
|
|||||||
|
|
||||||
export async function getAssets() {
|
export async function getAssets() {
|
||||||
return db.select().from(assets);
|
return db.select().from(assets);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePriceSchema = z.object({
|
||||||
|
assetId: z.string().min(1, 'Asset ID is required'),
|
||||||
|
newPrice: z.string().min(1, 'Price is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateAssetPrice(params: z.infer<typeof updatePriceSchema>) {
|
||||||
|
const validation = updatePriceSchema.safeParse(params);
|
||||||
|
if (!validation.success) {
|
||||||
|
return { success: false, error: validation.error.issues[0].message };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.update(assets).set({ latestPrice: params.newPrice }).where(eq(assets.id, params.assetId));
|
||||||
|
revalidatePath('/dashboard');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -12,6 +12,8 @@ interface Position {
|
|||||||
quantity: string;
|
quantity: string;
|
||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
cnyValue: string;
|
cnyValue: string;
|
||||||
|
totalCostCny: string;
|
||||||
|
pnlCny: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawRate {
|
interface RawRate {
|
||||||
@ -38,19 +40,21 @@ function getRate(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateCnyValue(
|
function calculateCnyValueFromPrice(
|
||||||
quantity: Big,
|
quantity: Big,
|
||||||
|
latestPrice: string,
|
||||||
baseCurrency: string,
|
baseCurrency: string,
|
||||||
rateMap: Map<string, string>,
|
rateMap: Map<string, string>
|
||||||
cryptoPrices: Map<string, string>
|
|
||||||
): Big {
|
): Big {
|
||||||
|
const price = new Big(latestPrice || '0');
|
||||||
|
|
||||||
if (baseCurrency === 'CNY') {
|
if (baseCurrency === 'CNY') {
|
||||||
return quantity;
|
return quantity.times(price);
|
||||||
}
|
}
|
||||||
|
|
||||||
const directRate = getRate(rateMap, baseCurrency, 'CNY');
|
const directRate = getRate(rateMap, baseCurrency, 'CNY');
|
||||||
if (directRate) {
|
if (directRate) {
|
||||||
return quantity.times(directRate);
|
return quantity.times(price).times(directRate);
|
||||||
}
|
}
|
||||||
|
|
||||||
const usdToCny = getRate(rateMap, 'USD', 'CNY');
|
const usdToCny = getRate(rateMap, 'USD', 'CNY');
|
||||||
@ -58,17 +62,9 @@ function calculateCnyValue(
|
|||||||
return new Big('0');
|
return new Big('0');
|
||||||
}
|
}
|
||||||
|
|
||||||
const priceKey = `${baseCurrency}_USD`;
|
|
||||||
const cryptoPrice = cryptoPrices.get(priceKey);
|
|
||||||
if (cryptoPrice) {
|
|
||||||
const usdValue = quantity.times(cryptoPrice);
|
|
||||||
return usdValue.times(usdToCny);
|
|
||||||
}
|
|
||||||
|
|
||||||
const usdRate = getRate(rateMap, baseCurrency, 'USD');
|
const usdRate = getRate(rateMap, baseCurrency, 'USD');
|
||||||
if (usdRate) {
|
if (usdRate) {
|
||||||
const usdValue = quantity.times(usdRate);
|
return quantity.times(price).times(usdRate).times(usdToCny);
|
||||||
return usdValue.times(usdToCny);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Big('0');
|
return new Big('0');
|
||||||
@ -79,11 +75,13 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
.select({
|
.select({
|
||||||
txType: transactions.txType,
|
txType: transactions.txType,
|
||||||
quantity: transactions.quantity,
|
quantity: transactions.quantity,
|
||||||
|
price: transactions.price,
|
||||||
|
exchangeRate: transactions.exchangeRate,
|
||||||
assetId: transactions.assetId,
|
assetId: transactions.assetId,
|
||||||
assetSymbol: assets.symbol,
|
assetSymbol: assets.symbol,
|
||||||
assetType: assets.type,
|
assetType: assets.type,
|
||||||
assetBaseCurrency: assets.baseCurrency,
|
assetBaseCurrency: assets.baseCurrency,
|
||||||
assetPrice: transactions.price,
|
assetLatestPrice: assets.latestPrice,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.leftJoin(assets, eq(assets.id, transactions.assetId))
|
.leftJoin(assets, eq(assets.id, transactions.assetId))
|
||||||
@ -96,6 +94,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
quantity: Big;
|
quantity: Big;
|
||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
latestPrice: string;
|
latestPrice: string;
|
||||||
|
totalCostCny: Big;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
for (const tx of allTransactions) {
|
for (const tx of allTransactions) {
|
||||||
@ -109,22 +108,28 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
type: tx.assetType || 'CASH',
|
type: tx.assetType || 'CASH',
|
||||||
quantity: new Big('0'),
|
quantity: new Big('0'),
|
||||||
baseCurrency: tx.assetBaseCurrency || '',
|
baseCurrency: tx.assetBaseCurrency || '',
|
||||||
latestPrice: tx.assetPrice || '0',
|
latestPrice: tx.assetLatestPrice || '0',
|
||||||
|
totalCostCny: new Big('0'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const holding = holdings.get(tx.assetId)!;
|
const holding = holdings.get(tx.assetId)!;
|
||||||
|
|
||||||
if (tx.txType === 'BUY' || tx.txType === 'AIRDROP') {
|
if (tx.txType === 'BUY') {
|
||||||
holding.quantity = holding.quantity.plus(tx.quantity);
|
holding.quantity = holding.quantity.plus(tx.quantity);
|
||||||
|
const costPerUnit = tx.quantity.times(tx.price);
|
||||||
|
const costCny = costPerUnit.times(tx.exchangeRate || '1');
|
||||||
|
holding.totalCostCny = holding.totalCostCny.plus(costCny);
|
||||||
} else if (tx.txType === 'SELL') {
|
} else if (tx.txType === 'SELL') {
|
||||||
holding.quantity = holding.quantity.minus(tx.quantity);
|
holding.quantity = holding.quantity.minus(tx.quantity);
|
||||||
|
} else if (tx.txType === 'AIRDROP') {
|
||||||
|
holding.quantity = holding.quantity.plus(tx.quantity);
|
||||||
} else if (tx.txType === 'DIVIDEND') {
|
} else if (tx.txType === 'DIVIDEND') {
|
||||||
holding.quantity = holding.quantity.plus(tx.quantity);
|
holding.quantity = holding.quantity.plus(tx.quantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tx.assetPrice) {
|
if (tx.assetLatestPrice) {
|
||||||
holding.latestPrice = tx.assetPrice;
|
holding.latestPrice = tx.assetLatestPrice;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,56 +141,25 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
|
|
||||||
const rateMap = buildRateMap(rates);
|
const rateMap = buildRateMap(rates);
|
||||||
|
|
||||||
const cryptoSymbols = new Set(['BTC', 'ETH', 'SOL', 'BNB', 'XRP', 'ADA', 'DOGE', 'AVAX', 'MATIC', 'DOT']);
|
|
||||||
const cryptoPrices = new Map<string, string>();
|
|
||||||
for (const [_, holding] of holdings) {
|
|
||||||
if (holding.type === 'CRYPTO' && cryptoSymbols.has(holding.symbol.toUpperCase())) {
|
|
||||||
const priceKey = `${holding.symbol}_USD`;
|
|
||||||
const usdRate = getRate(rateMap, holding.symbol, 'USD');
|
|
||||||
if (usdRate) {
|
|
||||||
cryptoPrices.set(priceKey, usdRate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: Position[] = [];
|
const result: Position[] = [];
|
||||||
let totalCnyValue = new Big('0');
|
let totalCnyValue = new Big('0');
|
||||||
|
let totalPnlCny = new Big('0');
|
||||||
|
|
||||||
for (const [_, holding] of holdings) {
|
for (const [_, holding] of holdings) {
|
||||||
if (holding.quantity.lte(0)) continue;
|
if (holding.quantity.lte(0)) continue;
|
||||||
|
|
||||||
let cnyValue: Big;
|
const cnyValue = calculateCnyValueFromPrice(
|
||||||
|
holding.quantity,
|
||||||
if (holding.type === 'CRYPTO') {
|
holding.latestPrice,
|
||||||
const symbol = holding.symbol.toUpperCase();
|
holding.baseCurrency,
|
||||||
const btcToUsd = getRate(rateMap, symbol, 'USD');
|
rateMap
|
||||||
const usdToCny = getRate(rateMap, 'USD', 'CNY');
|
);
|
||||||
|
|
||||||
if (btcToUsd && usdToCny) {
|
|
||||||
const usdValue = holding.quantity.times(holding.latestPrice || '1');
|
|
||||||
cnyValue = usdValue.times(usdToCny);
|
|
||||||
} else {
|
|
||||||
cnyValue = new Big('0');
|
|
||||||
}
|
|
||||||
} else if (holding.baseCurrency === 'CNY') {
|
|
||||||
cnyValue = holding.quantity.times(holding.latestPrice || '1');
|
|
||||||
} else {
|
|
||||||
const directRate = getRate(rateMap, holding.baseCurrency, 'CNY');
|
|
||||||
if (directRate) {
|
|
||||||
cnyValue = holding.quantity.times(holding.latestPrice || '1').times(directRate);
|
|
||||||
} else {
|
|
||||||
const usdRate = getRate(rateMap, holding.baseCurrency, 'USD');
|
|
||||||
const usdToCny = getRate(rateMap, 'USD', 'CNY');
|
|
||||||
if (usdRate && usdToCny) {
|
|
||||||
cnyValue = holding.quantity.times(holding.latestPrice || '1').times(usdRate).times(usdToCny);
|
|
||||||
} else {
|
|
||||||
cnyValue = new Big('0');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
totalCnyValue = totalCnyValue.plus(cnyValue);
|
totalCnyValue = totalCnyValue.plus(cnyValue);
|
||||||
|
|
||||||
|
const pnlCny = cnyValue.minus(holding.totalCostCny);
|
||||||
|
totalPnlCny = totalPnlCny.plus(pnlCny);
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
assetId: holding.assetId,
|
assetId: holding.assetId,
|
||||||
symbol: holding.symbol,
|
symbol: holding.symbol,
|
||||||
@ -193,6 +167,8 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
quantity: holding.quantity.toString(),
|
quantity: holding.quantity.toString(),
|
||||||
baseCurrency: holding.baseCurrency,
|
baseCurrency: holding.baseCurrency,
|
||||||
cnyValue: cnyValue.toString(),
|
cnyValue: cnyValue.toString(),
|
||||||
|
totalCostCny: holding.totalCostCny.toString(),
|
||||||
|
pnlCny: pnlCny.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,6 +183,11 @@ export async function getPortfolioSummary() {
|
|||||||
new Big('0')
|
new Big('0')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const totalPnlCny = positions.reduce(
|
||||||
|
(sum, pos) => sum.plus(new Big(pos.pnlCny)),
|
||||||
|
new Big('0')
|
||||||
|
);
|
||||||
|
|
||||||
const chartData = positions.map((pos, index) => ({
|
const chartData = positions.map((pos, index) => ({
|
||||||
name: pos.symbol,
|
name: pos.symbol,
|
||||||
value: new Big(pos.cnyValue),
|
value: new Big(pos.cnyValue),
|
||||||
@ -223,6 +204,7 @@ export async function getPortfolioSummary() {
|
|||||||
return {
|
return {
|
||||||
positions,
|
positions,
|
||||||
totalCnyValue: totalCnyValue.toString(),
|
totalCnyValue: totalCnyValue.toString(),
|
||||||
|
totalPnlCny: totalPnlCny.toString(),
|
||||||
chartData,
|
chartData,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
101
src/components/assets/update-price-dialog.tsx
Normal file
101
src/components/assets/update-price-dialog.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { updateAssetPrice } from '@/actions/asset';
|
||||||
|
|
||||||
|
const updatePriceSchema = z.object({
|
||||||
|
newPrice: z.string().min(1, '價格不能為空'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type UpdatePriceForm = z.infer<typeof updatePriceSchema>;
|
||||||
|
|
||||||
|
interface UpdatePriceDialogProps {
|
||||||
|
assetId: string;
|
||||||
|
currentPrice: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdatePriceDialog({ assetId, currentPrice }: UpdatePriceDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const form = useForm<UpdatePriceForm>({
|
||||||
|
resolver: zodResolver(updatePriceSchema),
|
||||||
|
defaultValues: {
|
||||||
|
newPrice: currentPrice,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(values: UpdatePriceForm) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await updateAssetPrice({ assetId, newPrice: values.newPrice });
|
||||||
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<RefreshCw className="h-3 w-3 mr-1" />
|
||||||
|
更新市價
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>更新現價</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
輸入該資產的最新市場單價
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="newPrice"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>最新價格</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="輸入價格數值" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? '提交中...' : '確認更新'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -20,6 +20,7 @@ export const assets = pgTable("assets", {
|
|||||||
symbol: varchar("symbol", { length: 20 }).notNull().unique(),
|
symbol: varchar("symbol", { length: 20 }).notNull().unique(),
|
||||||
type: assetTypeEnum("type").notNull(),
|
type: assetTypeEnum("type").notNull(),
|
||||||
baseCurrency: varchar("base_currency", { length: 10 }).notNull(),
|
baseCurrency: varchar("base_currency", { length: 10 }).notNull(),
|
||||||
|
latestPrice: numeric("latest_price", { precision: 36, scale: 18 }).default('0').notNull(),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true, mode: "date" })
|
createdAt: timestamp("created_at", { withTimezone: true, mode: "date" })
|
||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user