feat: rebalance page with manual price input and strategy selection
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bffca88ce9
commit
9fa97e595d
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@ -9,6 +9,17 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface Target {
|
||||||
|
ticker: string;
|
||||||
|
target_ratio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Holding {
|
||||||
|
ticker: string;
|
||||||
|
quantity: number;
|
||||||
|
avg_price: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface RebalanceItem {
|
interface RebalanceItem {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@ -16,23 +27,23 @@ interface RebalanceItem {
|
|||||||
current_ratio: number;
|
current_ratio: number;
|
||||||
current_quantity: number;
|
current_quantity: number;
|
||||||
current_value: number;
|
current_value: number;
|
||||||
|
current_price: number;
|
||||||
target_value: number;
|
target_value: number;
|
||||||
diff_value: number;
|
diff_ratio: number;
|
||||||
diff_quantity: number;
|
diff_quantity: number;
|
||||||
action: string;
|
action: string;
|
||||||
|
change_vs_prev_month: number | null;
|
||||||
|
change_vs_start: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RebalanceResponse {
|
interface RebalanceResponse {
|
||||||
portfolio_id: number;
|
portfolio_id: number;
|
||||||
total_value: number;
|
total_assets: number;
|
||||||
|
available_to_buy: number | null;
|
||||||
items: RebalanceItem[];
|
items: RebalanceItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SimulationResponse extends RebalanceResponse {
|
type Strategy = 'full_rebalance' | 'additional_buy';
|
||||||
current_total: number;
|
|
||||||
additional_amount: number;
|
|
||||||
new_total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RebalancePage() {
|
export default function RebalancePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -40,27 +51,36 @@ export default function RebalancePage() {
|
|||||||
const portfolioId = params.id as string;
|
const portfolioId = params.id as string;
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [rebalance, setRebalance] = useState<RebalanceResponse | SimulationResponse | null>(null);
|
const [targets, setTargets] = useState<Target[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [holdings, setHoldings] = useState<Holding[]>([]);
|
||||||
|
const [prices, setPrices] = useState<Record<string, string>>({});
|
||||||
|
const [strategy, setStrategy] = useState<Strategy>('full_rebalance');
|
||||||
const [additionalAmount, setAdditionalAmount] = useState('');
|
const [additionalAmount, setAdditionalAmount] = useState('');
|
||||||
const [simulating, setSimulating] = useState(false);
|
const [result, setResult] = useState<RebalanceResponse | null>(null);
|
||||||
|
const [calculating, setCalculating] = useState(false);
|
||||||
const fetchRebalance = useCallback(async () => {
|
const [error, setError] = useState<string | null>(null);
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
const data = await api.get<RebalanceResponse>(`/api/portfolios/${portfolioId}/rebalance`);
|
|
||||||
setRebalance(data);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to calculate rebalance';
|
|
||||||
setError(message);
|
|
||||||
}
|
|
||||||
}, [portfolioId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
try {
|
try {
|
||||||
await api.getCurrentUser();
|
await api.getCurrentUser();
|
||||||
await fetchRebalance();
|
const [targetsData, holdingsData] = await Promise.all([
|
||||||
|
api.get<Target[]>(`/api/portfolios/${portfolioId}/targets`),
|
||||||
|
api.get<Holding[]>(`/api/portfolios/${portfolioId}/holdings`),
|
||||||
|
]);
|
||||||
|
setTargets(targetsData);
|
||||||
|
setHoldings(holdingsData);
|
||||||
|
|
||||||
|
// Initialize price fields for all tickers
|
||||||
|
const allTickers = new Set([
|
||||||
|
...targetsData.map((t) => t.ticker),
|
||||||
|
...holdingsData.map((h) => h.ticker),
|
||||||
|
]);
|
||||||
|
const initialPrices: Record<string, string> = {};
|
||||||
|
allTickers.forEach((ticker) => {
|
||||||
|
initialPrices[ticker] = '';
|
||||||
|
});
|
||||||
|
setPrices(initialPrices);
|
||||||
} catch {
|
} catch {
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
} finally {
|
} finally {
|
||||||
@ -68,32 +88,50 @@ export default function RebalancePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
init();
|
init();
|
||||||
}, [router, fetchRebalance]);
|
}, [portfolioId, router]);
|
||||||
|
|
||||||
const simulate = async () => {
|
const allPricesFilled = Object.values(prices).every((p) => p !== '' && parseFloat(p) > 0);
|
||||||
if (!additionalAmount) return;
|
|
||||||
setSimulating(true);
|
const calculate = async () => {
|
||||||
|
if (!allPricesFilled) return;
|
||||||
|
setCalculating(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setError(null);
|
const priceMap: Record<string, number> = {};
|
||||||
const data = await api.post<SimulationResponse>(
|
for (const [ticker, price] of Object.entries(prices)) {
|
||||||
`/api/portfolios/${portfolioId}/rebalance/simulate`,
|
priceMap[ticker] = parseFloat(price);
|
||||||
{ additional_amount: parseFloat(additionalAmount) }
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
strategy,
|
||||||
|
prices: priceMap,
|
||||||
|
};
|
||||||
|
if (strategy === 'additional_buy' && additionalAmount) {
|
||||||
|
body.additional_amount = parseFloat(additionalAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await api.post<RebalanceResponse>(
|
||||||
|
`/api/portfolios/${portfolioId}/rebalance/calculate`,
|
||||||
|
body
|
||||||
);
|
);
|
||||||
setRebalance(data);
|
setResult(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Simulation failed';
|
setError(err instanceof Error ? err.message : 'Calculation failed');
|
||||||
setError(message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setSimulating(false);
|
setCalculating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) =>
|
||||||
return new Intl.NumberFormat('ko-KR', {
|
new Intl.NumberFormat('ko-KR', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'KRW',
|
currency: 'KRW',
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(value);
|
}).format(value);
|
||||||
|
|
||||||
|
const formatPct = (value: number | null) => {
|
||||||
|
if (value === null || value === undefined) return '-';
|
||||||
|
return `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActionBadge = (action: string) => {
|
const getActionBadge = (action: string) => {
|
||||||
@ -102,11 +140,7 @@ export default function RebalancePage() {
|
|||||||
sell: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
sell: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||||
hold: 'bg-muted text-muted-foreground',
|
hold: 'bg-muted text-muted-foreground',
|
||||||
};
|
};
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = { buy: '매수', sell: '매도', hold: '유지' };
|
||||||
buy: '매수',
|
|
||||||
sell: '매도',
|
|
||||||
hold: '유지',
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<span className={`px-2 py-1 rounded text-xs ${styles[action] || styles.hold}`}>
|
<span className={`px-2 py-1 rounded text-xs ${styles[action] || styles.hold}`}>
|
||||||
{labels[action] || action}
|
{labels[action] || action}
|
||||||
@ -114,13 +148,19 @@ export default function RebalancePage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
const getHoldingQty = (ticker: string) =>
|
||||||
return null;
|
holdings.find((h) => h.ticker === ticker)?.quantity ?? 0;
|
||||||
}
|
|
||||||
|
if (loading) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<h1 className="text-2xl font-bold text-foreground mb-6">리밸런싱 계산</h1>
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">리밸런싱 계산</h1>
|
||||||
|
<Button variant="outline" onClick={() => router.push(`/portfolio/${portfolioId}`)}>
|
||||||
|
포트폴리오로 돌아가기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
|
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
|
||||||
@ -128,67 +168,109 @@ export default function RebalancePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Simulation Input */}
|
{/* Price Input */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardContent className="pt-6">
|
<CardHeader>
|
||||||
<div className="flex gap-4 items-end">
|
<CardTitle>현재 가격 입력</CardTitle>
|
||||||
<div className="flex-1">
|
</CardHeader>
|
||||||
<Label htmlFor="additional-amount">추가 입금액 (시뮬레이션)</Label>
|
<CardContent>
|
||||||
<Input
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
id="additional-amount"
|
{Object.keys(prices).map((ticker) => {
|
||||||
type="number"
|
const target = targets.find((t) => t.ticker === ticker);
|
||||||
value={additionalAmount}
|
return (
|
||||||
onChange={(e) => setAdditionalAmount(e.target.value)}
|
<div key={ticker}>
|
||||||
placeholder="예: 1000000"
|
<Label htmlFor={`price-${ticker}`}>
|
||||||
className="mt-2"
|
{ticker} {target ? `(목표 ${target.target_ratio}%)` : ''} - 보유 {getHoldingQty(ticker)}주
|
||||||
/>
|
</Label>
|
||||||
</div>
|
<Input
|
||||||
<Button
|
id={`price-${ticker}`}
|
||||||
onClick={simulate}
|
type="number"
|
||||||
disabled={!additionalAmount || simulating}
|
value={prices[ticker]}
|
||||||
>
|
onChange={(e) => setPrices((prev) => ({ ...prev, [ticker]: e.target.value }))}
|
||||||
{simulating ? '계산 중...' : '시뮬레이션'}
|
placeholder="현재 가격"
|
||||||
</Button>
|
className="mt-1"
|
||||||
<Button variant="outline" onClick={fetchRebalance}>
|
/>
|
||||||
초기화
|
</div>
|
||||||
</Button>
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{rebalance && (
|
{/* Strategy Selection */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>전략 선택</Label>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Button
|
||||||
|
variant={strategy === 'full_rebalance' ? 'default' : 'outline'}
|
||||||
|
onClick={() => setStrategy('full_rebalance')}
|
||||||
|
>
|
||||||
|
전체 리밸런싱
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={strategy === 'additional_buy' ? 'default' : 'outline'}
|
||||||
|
onClick={() => setStrategy('additional_buy')}
|
||||||
|
>
|
||||||
|
추가 입금 매수
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{strategy === 'additional_buy' && (
|
||||||
|
<div className="max-w-md">
|
||||||
|
<Label htmlFor="additional-amount">매수 가능 금액</Label>
|
||||||
|
<Input
|
||||||
|
id="additional-amount"
|
||||||
|
type="number"
|
||||||
|
value={additionalAmount}
|
||||||
|
onChange={(e) => setAdditionalAmount(e.target.value)}
|
||||||
|
placeholder="예: 1000000"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
onClick={calculate}
|
||||||
|
disabled={
|
||||||
|
!allPricesFilled ||
|
||||||
|
calculating ||
|
||||||
|
(strategy === 'additional_buy' && !additionalAmount)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{calculating ? '계산 중...' : '계산'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{result && (
|
||||||
<>
|
<>
|
||||||
{/* Summary */}
|
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">현재 총액</p>
|
<p className="text-sm text-muted-foreground">총 자산</p>
|
||||||
<p className="text-xl font-bold">
|
<p className="text-xl font-bold">{formatCurrency(result.total_assets)}</p>
|
||||||
{formatCurrency('current_total' in rebalance ? rebalance.current_total : rebalance.total_value)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{'additional_amount' in rebalance && (
|
{result.available_to_buy !== null && (
|
||||||
<>
|
<div>
|
||||||
<div>
|
<p className="text-sm text-muted-foreground">매수 가능</p>
|
||||||
<p className="text-sm text-muted-foreground">추가 입금</p>
|
<p className="text-xl font-bold text-blue-600">
|
||||||
<p className="text-xl font-bold text-blue-600">
|
{formatCurrency(result.available_to_buy)}
|
||||||
+{formatCurrency(rebalance.additional_amount)}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">새 총액</p>
|
|
||||||
<p className="text-xl font-bold">
|
|
||||||
{formatCurrency(rebalance.new_total)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Rebalance Table */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>리밸런싱 내역</CardTitle>
|
<CardTitle>리밸런싱 내역</CardTitle>
|
||||||
@ -198,36 +280,82 @@ export default function RebalancePage() {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-muted">
|
<thead className="bg-muted">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목</th>
|
<th scope="col" className="px-3 py-3 text-left text-sm font-medium text-muted-foreground">종목</th>
|
||||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">목표 비중</th>
|
<th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">보유량</th>
|
||||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">현재 비중</th>
|
<th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">현재가</th>
|
||||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">현재 수량</th>
|
<th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">평가금액</th>
|
||||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">조정 금액</th>
|
<th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">현재 비중</th>
|
||||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">조정 수량</th>
|
<th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">목표 비중</th>
|
||||||
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-muted-foreground">액션</th>
|
<th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">비중 차이</th>
|
||||||
|
<th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">조정 수량</th>
|
||||||
|
<th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">전월비</th>
|
||||||
|
<th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">시작일비</th>
|
||||||
|
<th scope="col" className="px-3 py-3 text-center text-sm font-medium text-muted-foreground">액션</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{rebalance.items.map((item) => (
|
{result.items.map((item) => (
|
||||||
<tr key={item.ticker}>
|
<tr key={item.ticker}>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-3">
|
||||||
<div className="font-medium">{item.ticker}</div>
|
<div className="font-medium">{item.ticker}</div>
|
||||||
{item.name && <div className="text-xs text-muted-foreground">{item.name}</div>}
|
{item.name && (
|
||||||
|
<div className="text-xs text-muted-foreground">{item.name}</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-right">{item.target_ratio.toFixed(2)}%</td>
|
<td className="px-3 py-3 text-sm text-right">
|
||||||
<td className="px-4 py-3 text-sm text-right">{item.current_ratio.toFixed(2)}%</td>
|
{item.current_quantity.toLocaleString()}
|
||||||
<td className="px-4 py-3 text-sm text-right">{item.current_quantity.toLocaleString()}</td>
|
|
||||||
<td className={`px-4 py-3 text-sm text-right ${
|
|
||||||
item.diff_value > 0 ? 'text-green-600' : item.diff_value < 0 ? 'text-red-600' : ''
|
|
||||||
}`}>
|
|
||||||
{item.diff_value > 0 ? '+' : ''}{formatCurrency(item.diff_value)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className={`px-4 py-3 text-sm text-right font-medium ${
|
<td className="px-3 py-3 text-sm text-right">
|
||||||
item.diff_quantity > 0 ? 'text-green-600' : item.diff_quantity < 0 ? 'text-red-600' : ''
|
{formatCurrency(item.current_price)}
|
||||||
}`}>
|
|
||||||
{item.diff_quantity > 0 ? '+' : ''}{item.diff_quantity}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-center">{getActionBadge(item.action)}</td>
|
<td className="px-3 py-3 text-sm text-right">
|
||||||
|
{formatCurrency(item.current_value)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-sm text-right">
|
||||||
|
{item.current_ratio.toFixed(2)}%
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-sm text-right">
|
||||||
|
{item.target_ratio.toFixed(2)}%
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`px-3 py-3 text-sm text-right ${
|
||||||
|
item.diff_ratio > 0
|
||||||
|
? 'text-green-600'
|
||||||
|
: item.diff_ratio < 0
|
||||||
|
? 'text-red-600'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.diff_ratio > 0 ? '+' : ''}
|
||||||
|
{item.diff_ratio.toFixed(2)}%
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`px-3 py-3 text-sm text-right font-medium ${
|
||||||
|
item.diff_quantity > 0
|
||||||
|
? 'text-green-600'
|
||||||
|
: item.diff_quantity < 0
|
||||||
|
? 'text-red-600'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.diff_quantity > 0 ? '+' : ''}
|
||||||
|
{item.diff_quantity}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`px-3 py-3 text-sm text-right ${
|
||||||
|
(item.change_vs_prev_month ?? 0) < 0 ? 'text-red-600' : (item.change_vs_prev_month ?? 0) > 0 ? 'text-green-600' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatPct(item.change_vs_prev_month)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`px-3 py-3 text-sm text-right ${
|
||||||
|
(item.change_vs_start ?? 0) < 0 ? 'text-red-600' : (item.change_vs_start ?? 0) > 0 ? 'text-green-600' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatPct(item.change_vs_start)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-center">{getActionBadge(item.action)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user