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";
|
2026-02-13 15:44:41 +09:00
|
|
|
import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
|
2026-02-12 14:20:07 +09:00
|
|
|
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();
|
2026-02-13 15:44:41 +09:00
|
|
|
const setPendingTarget = useTradeNavigationStore(
|
|
|
|
|
(state) => state.setPendingTarget,
|
|
|
|
|
);
|
2026-02-13 12:17:35 +09:00
|
|
|
// [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 17:16:41 +09:00
|
|
|
선택 종목 정보
|
2026-02-12 14:20:07 +09:00
|
|
|
</CardTitle>
|
|
|
|
|
<CardDescription>
|
2026-02-12 17:16:41 +09:00
|
|
|
보유 종목을 선택하면 자세한 정보가 표시됩니다.
|
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 17:16:41 +09:00
|
|
|
선택 종목 정보
|
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"
|
2026-02-13 15:44:41 +09:00
|
|
|
onClick={() => {
|
|
|
|
|
setPendingTarget({
|
|
|
|
|
symbol: holding.symbol,
|
|
|
|
|
name: holding.name,
|
|
|
|
|
market: holding.market,
|
|
|
|
|
});
|
|
|
|
|
router.push("/trade");
|
|
|
|
|
}}
|
2026-02-13 12:17:35 +09:00
|
|
|
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
|
2026-02-12 17:16:41 +09:00
|
|
|
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 17:16:41 +09:00
|
|
|
빠른 주문(준비 중)
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|