Files
auto-trade/features/dashboard/components/StockDetailPreview.tsx

229 lines
8.5 KiB
TypeScript
Raw Normal View History

2026-02-13 12:17:35 +09:00
/**
* @file StockDetailPreview.tsx
* @description
* @remarks
* - [] Components / UI
* - [ ] -> ->
* - [ ] DashboardContainer(realtimeSelectedHolding) -> StockDetailPreview -> Metric(UI)
* - [ ] DashboardContainer.tsx, dashboard.types.ts, use-price-flash.ts
* @author jihoon87.lee
*/
import { BarChartBig, ExternalLink, MousePointerClick } from "lucide-react";
2026-02-12 14:20:07 +09:00
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
2026-02-13 12:17:35 +09:00
import { useRouter } from "next/navigation";
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
2026-02-12 14:20:07 +09:00
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
import {
formatCurrency,
formatPercent,
getChangeToneClass,
} from "@/features/dashboard/utils/dashboard-format";
import { cn } from "@/lib/utils";
interface StockDetailPreviewProps {
2026-02-13 12:17:35 +09:00
/** 선택된 종목 정보 (없으면 null) */
2026-02-12 14:20:07 +09:00
holding: DashboardHoldingItem | null;
2026-02-13 12:17:35 +09:00
/** 현재 총 자산 (비중 계산용) */
2026-02-12 14:20:07 +09:00
totalAmount: number;
}
/**
2026-02-13 12:17:35 +09:00
* []
* , , .
*
* @param props StockDetailPreviewProps
* @see DashboardContainer.tsx - HoldingsList
2026-02-12 14:20:07 +09:00
*/
export function StockDetailPreview({
holding,
totalAmount,
}: StockDetailPreviewProps) {
2026-02-13 12:17:35 +09:00
const router = useRouter();
// [State/Hook] 실시간 가격 변동 애니메이션 상태 관리
// @remarks 종목이 선택되지 않았을 때를 대비해 safe value(0)를 전달하며, 종목 변경 시 효과를 초기화하도록 symbol 전달
const currentPrice = holding?.currentPrice ?? 0;
const priceFlash = usePriceFlash(currentPrice, holding?.symbol);
// [Step 1] 종목이 선택되지 않은 경우 초기 안내 화면 렌더링
2026-02-12 14:20:07 +09:00
if (!holding) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BarChartBig className="h-4 w-4 text-brand-600 dark:text-brand-400" />
2026-02-12 14:20:07 +09:00
</CardTitle>
<CardDescription>
.
2026-02-12 14:20:07 +09:00
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
.
</p>
</CardContent>
</Card>
);
}
2026-02-13 12:17:35 +09:00
// [Step 2] 수익/손실 여부에 따른 UI 톤(색상) 결정
2026-02-12 14:20:07 +09:00
const profitToneClass = getChangeToneClass(holding.profitLoss);
2026-02-13 12:17:35 +09:00
// [Step 3] 총 자산 대비 비중 계산
2026-02-12 14:20:07 +09:00
const allocationRate =
2026-02-13 12:17:35 +09:00
totalAmount > 0
? Math.min((holding.evaluationAmount / totalAmount) * 100, 100)
: 0;
2026-02-12 14:20:07 +09:00
return (
<Card>
2026-02-13 12:17:35 +09:00
{/* ========== 카드 헤더: 종목명 및 기본 정보 ========== */}
2026-02-12 14:20:07 +09:00
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<BarChartBig className="h-4 w-4 text-brand-600 dark:text-brand-400" />
2026-02-12 14:20:07 +09:00
</CardTitle>
2026-02-13 12:17:35 +09:00
<CardDescription className="flex items-center gap-1.5 flex-wrap">
<button
type="button"
onClick={() =>
router.push(
`/trade?symbol=${holding.symbol}&name=${encodeURIComponent(holding.name)}`,
)
}
className={cn(
"group flex items-center gap-1.5 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5",
"text-sm font-bold text-brand-700 transition-all cursor-pointer",
"hover:border-brand-400 hover:bg-brand-100 hover:shadow-sm",
"dark:border-brand-800/60 dark:bg-brand-900/40 dark:text-brand-400 dark:hover:border-brand-600 dark:hover:bg-brand-900/60",
)}
title={`${holding.name} 종목 상세 거래로 이동`}
>
<span className="truncate">{holding.name}</span>
<span className="text-[10px] font-medium opacity-70">
({holding.symbol})
</span>
<ExternalLink className="h-3 w-3 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</button>
<span className="text-xs text-muted-foreground">
· {holding.market}
</span>
2026-02-12 14:20:07 +09:00
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
2026-02-13 12:17:35 +09:00
{/* ========== 실시간 주요 지표 영역 (Grid) ========== */}
2026-02-12 14:20:07 +09:00
<div className="grid grid-cols-2 gap-2 text-sm">
2026-02-13 12:17:35 +09:00
<Metric
label="보유 수량"
value={`${holding.quantity.toLocaleString("ko-KR")}`}
/>
<Metric
label="매입 평균가"
value={`${formatCurrency(holding.averagePrice)}`}
/>
<Metric
label="현재가"
value={`${formatCurrency(holding.currentPrice)}`}
flash={priceFlash}
/>
2026-02-12 14:20:07 +09:00
<Metric
label="수익률"
value={formatPercent(holding.profitRate)}
valueClassName={profitToneClass}
/>
<Metric
label="현재 손익"
2026-02-12 14:20:07 +09:00
value={`${formatCurrency(holding.profitLoss)}`}
valueClassName={profitToneClass}
/>
<Metric
label="평가금액"
value={`${formatCurrency(holding.evaluationAmount)}`}
/>
</div>
2026-02-13 12:17:35 +09:00
{/* ========== 자산 비중 그래프 영역 ========== */}
2026-02-12 14:20:07 +09:00
<div className="rounded-xl border border-border/70 bg-background/70 p-3">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span> </span>
<span>{formatPercent(allocationRate)}</span>
</div>
<div className="mt-2 h-2 rounded-full bg-muted">
<div
className="h-2 rounded-full bg-linear-to-r from-brand-500 to-brand-700"
style={{ width: `${allocationRate}%` }}
/>
</div>
</div>
2026-02-13 12:17:35 +09:00
{/* ========== 추가 기능 예고 영역 (Placeholder) ========== */}
2026-02-12 14:20:07 +09:00
<div className="rounded-xl border border-dashed border-border/80 bg-muted/30 p-3">
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground/80">
<MousePointerClick className="h-4 w-4 text-brand-500" />
( )
2026-02-12 14:20:07 +09:00
</p>
<p className="mt-1 text-xs text-muted-foreground">
/ .
</p>
</div>
</CardContent>
</Card>
);
}
interface MetricProps {
2026-02-13 12:17:35 +09:00
/** 지표 레이블 */
2026-02-12 14:20:07 +09:00
label: string;
2026-02-13 12:17:35 +09:00
/** 표시될 값 */
2026-02-12 14:20:07 +09:00
value: string;
2026-02-13 12:17:35 +09:00
/** 값 텍스트 추가 스타일 */
2026-02-12 14:20:07 +09:00
valueClassName?: string;
2026-02-13 12:17:35 +09:00
/** 가격 변동 애니메이션 상태 */
flash?: { type: "up" | "down"; val: number; id: number } | null;
2026-02-12 14:20:07 +09:00
}
/**
2026-02-13 12:17:35 +09:00
* []
* , Flash .
*
* @param props MetricProps
* @see StockDetailPreview.tsx -
2026-02-12 14:20:07 +09:00
*/
2026-02-13 12:17:35 +09:00
function Metric({ label, value, valueClassName, flash }: MetricProps) {
2026-02-12 14:20:07 +09:00
return (
2026-02-13 12:17:35 +09:00
<div className="relative overflow-hidden rounded-xl border border-border/70 bg-background/70 p-3 transition-colors">
{/* 시세 변동 시 나타나는 일시적인 수치 표시 (Flash) */}
{flash && (
<span
key={flash.id}
className={cn(
"pointer-events-none absolute right-2 top-2 text-xs font-bold animate-in fade-in slide-in-from-bottom-1 fill-mode-forwards duration-300",
flash.type === "up" ? "text-red-500" : "text-blue-500",
)}
>
{flash.type === "up" ? "+" : ""}
{flash.val.toLocaleString()}
</span>
)}
{/* 지표 레이블 및 본체 값 */}
2026-02-12 14:20:07 +09:00
<p className="text-xs text-muted-foreground">{label}</p>
2026-02-13 12:17:35 +09:00
<p
className={cn(
"mt-1 text-sm font-semibold text-foreground transition-colors",
valueClassName,
)}
>
2026-02-12 14:20:07 +09:00
{value}
</p>
</div>
);
}