트레이딩창 UI 배치 및 UX 수정 및 기획서 추가
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { fetchDashboardBalance } from "@/features/dashboard/apis/dashboard.api";
|
||||
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import { TradeAccessGate } from "@/features/trade/components/guards/TradeAccessGate";
|
||||
import { TradeDashboardContent } from "@/features/trade/components/layout/TradeDashboardContent";
|
||||
@@ -36,6 +38,8 @@ export function TradeContainer() {
|
||||
// [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신)
|
||||
const [realtimeOrderBook, setRealtimeOrderBook] =
|
||||
useState<DashboardStockOrderBookResponse | null>(null);
|
||||
// [State] 선택 종목과 매칭할 보유 종목 목록
|
||||
const [holdings, setHoldings] = useState<DashboardHoldingItem[]>([]);
|
||||
const { verifiedCredentials, isKisVerified, _hasHydrated } =
|
||||
useKisRuntimeStore(
|
||||
useShallow((state) => ({
|
||||
@@ -60,6 +64,7 @@ export function TradeContainer() {
|
||||
} = useStockSearch();
|
||||
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
|
||||
useStockOverview();
|
||||
const selectedSymbol = selectedStock?.symbol;
|
||||
|
||||
/**
|
||||
* [Effect] query string이 남아 들어온 경우 즉시 깨끗한 /trade URL로 정리합니다.
|
||||
@@ -83,7 +88,7 @@ export function TradeContainer() {
|
||||
const pendingTarget = consumePendingTarget();
|
||||
if (!pendingTarget) return;
|
||||
|
||||
if (selectedStock?.symbol === pendingTarget.symbol) {
|
||||
if (selectedSymbol === pendingTarget.symbol) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,7 +108,7 @@ export function TradeContainer() {
|
||||
verifiedCredentials,
|
||||
_hasHydrated,
|
||||
consumePendingTarget,
|
||||
selectedStock?.symbol,
|
||||
selectedSymbol,
|
||||
loadOverview,
|
||||
setKeyword,
|
||||
appendSearchHistory,
|
||||
@@ -112,6 +117,54 @@ export function TradeContainer() {
|
||||
const canTrade = isKisVerified && !!verifiedCredentials;
|
||||
const canSearch = canTrade;
|
||||
|
||||
/**
|
||||
* @description 상단 보유 요약 노출을 위해 잔고를 조회합니다.
|
||||
* @summary UI 흐름: TradeContainer -> loadHoldingsSnapshot -> fetchDashboardBalance -> holdings 상태 업데이트
|
||||
* @see features/dashboard/apis/dashboard.api.ts - fetchDashboardBalance 잔고 API를 재사용합니다.
|
||||
*/
|
||||
const loadHoldingsSnapshot = useCallback(async () => {
|
||||
if (!verifiedCredentials?.accountNo?.trim()) {
|
||||
setHoldings([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const balance = await fetchDashboardBalance(verifiedCredentials);
|
||||
setHoldings(balance.holdings);
|
||||
} catch {
|
||||
// 상단 요약은 보조 정보이므로 실패 시 화면 전체를 막지 않고 빈 상태로 처리합니다.
|
||||
setHoldings([]);
|
||||
}
|
||||
}, [verifiedCredentials]);
|
||||
|
||||
/**
|
||||
* [Effect] 보유종목 스냅샷 주기 갱신
|
||||
* @remarks UI 흐름: trade 진입 -> 잔고 조회 -> selectedStock과 symbol 매칭 -> 상단 보유수량/손익 표기
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!canTrade || !verifiedCredentials?.accountNo?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialTimerId = window.setTimeout(() => {
|
||||
void loadHoldingsSnapshot();
|
||||
}, 0);
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void loadHoldingsSnapshot();
|
||||
}, 60_000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(initialTimerId);
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [canTrade, verifiedCredentials?.accountNo, loadHoldingsSnapshot]);
|
||||
|
||||
const matchedHolding = useMemo(() => {
|
||||
if (!canTrade || !selectedSymbol) return null;
|
||||
return holdings.find((item) => item.symbol === selectedSymbol) ?? null;
|
||||
}, [canTrade, holdings, selectedSymbol]);
|
||||
|
||||
const {
|
||||
searchShellRef,
|
||||
isSearchPanelOpen,
|
||||
@@ -142,12 +195,12 @@ export function TradeContainer() {
|
||||
|
||||
// 1. Trade WebSocket (체결 + 호가 통합)
|
||||
const { latestTick, recentTradeTicks } = useKisTradeWebSocket(
|
||||
selectedStock?.symbol,
|
||||
selectedSymbol,
|
||||
verifiedCredentials,
|
||||
isKisVerified,
|
||||
updateRealtimeTradeTick,
|
||||
{
|
||||
orderBookSymbol: selectedStock?.symbol,
|
||||
orderBookSymbol: selectedSymbol,
|
||||
orderBookMarket: selectedStock?.market,
|
||||
onOrderBookMessage: handleOrderBookMessage,
|
||||
},
|
||||
@@ -155,12 +208,12 @@ export function TradeContainer() {
|
||||
|
||||
// 2. OrderBook (REST 초기 조회 + WS 실시간 병합)
|
||||
const { orderBook, isLoading: isOrderBookLoading } = useOrderBook(
|
||||
selectedStock?.symbol,
|
||||
selectedSymbol,
|
||||
selectedStock?.market,
|
||||
verifiedCredentials,
|
||||
isKisVerified,
|
||||
{
|
||||
enabled: !!selectedStock && !!verifiedCredentials && isKisVerified,
|
||||
enabled: !!selectedSymbol && !!verifiedCredentials && isKisVerified,
|
||||
externalRealtimeOrderBook: realtimeOrderBook,
|
||||
},
|
||||
);
|
||||
@@ -210,7 +263,7 @@ export function TradeContainer() {
|
||||
if (!ensureSearchReady() || !verifiedCredentials) return;
|
||||
|
||||
// 같은 종목 재선택 시 중복 overview 요청을 막고 검색 목록만 닫습니다.
|
||||
if (selectedStock?.symbol === item.symbol) {
|
||||
if (selectedSymbol === item.symbol) {
|
||||
clearSearch();
|
||||
closeSearchPanel();
|
||||
return;
|
||||
@@ -227,7 +280,7 @@ export function TradeContainer() {
|
||||
[
|
||||
ensureSearchReady,
|
||||
verifiedCredentials,
|
||||
selectedStock?.symbol,
|
||||
selectedSymbol,
|
||||
clearSearch,
|
||||
closeSearchPanel,
|
||||
setKeyword,
|
||||
@@ -250,14 +303,18 @@ export function TradeContainer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full flex flex-col">
|
||||
<div className="relative flex h-full min-h-0 flex-col overflow-hidden xl:h-[calc(100dvh-4rem)]">
|
||||
{/* ========== SEARCH SECTION ========== */}
|
||||
<TradeSearchSection
|
||||
canSearch={canSearch}
|
||||
isSearchPanelOpen={isSearchPanelOpen}
|
||||
isSearching={isSearching}
|
||||
keyword={keyword}
|
||||
selectedSymbol={selectedStock?.symbol}
|
||||
selectedStock={selectedStock}
|
||||
selectedSymbol={selectedSymbol}
|
||||
currentPrice={currentPrice}
|
||||
change={change}
|
||||
changeRate={changeRate}
|
||||
searchResults={searchResults}
|
||||
searchHistory={searchHistory}
|
||||
searchShellRef={searchShellRef}
|
||||
@@ -280,9 +337,7 @@ export function TradeContainer() {
|
||||
orderBook={orderBook}
|
||||
isOrderBookLoading={isOrderBookLoading}
|
||||
referencePrice={referencePrice}
|
||||
currentPrice={currentPrice}
|
||||
change={change}
|
||||
changeRate={changeRate}
|
||||
matchedHolding={matchedHolding}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
const UP_COLOR = "#ef4444";
|
||||
const MINUTE_SYNC_INTERVAL_MS = 30000;
|
||||
const REALTIME_STALE_THRESHOLD_MS = 12000;
|
||||
const CHART_MIN_HEIGHT = 220;
|
||||
|
||||
interface ChartPalette {
|
||||
backgroundColor: string;
|
||||
@@ -60,7 +61,10 @@ const DEFAULT_CHART_PALETTE: ChartPalette = {
|
||||
|
||||
function readCssVar(name: string, fallback: string) {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
const value = window
|
||||
.getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(name)
|
||||
.trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
@@ -69,16 +73,28 @@ function getChartPaletteFromCssVars(themeMode: "light" | "dark"): ChartPalette {
|
||||
const backgroundVar = isDark
|
||||
? "--brand-chart-background-dark"
|
||||
: "--brand-chart-background-light";
|
||||
const textVar = isDark ? "--brand-chart-text-dark" : "--brand-chart-text-light";
|
||||
const borderVar = isDark ? "--brand-chart-border-dark" : "--brand-chart-border-light";
|
||||
const gridVar = isDark ? "--brand-chart-grid-dark" : "--brand-chart-grid-light";
|
||||
const textVar = isDark
|
||||
? "--brand-chart-text-dark"
|
||||
: "--brand-chart-text-light";
|
||||
const borderVar = isDark
|
||||
? "--brand-chart-border-dark"
|
||||
: "--brand-chart-border-light";
|
||||
const gridVar = isDark
|
||||
? "--brand-chart-grid-dark"
|
||||
: "--brand-chart-grid-light";
|
||||
const crosshairVar = isDark
|
||||
? "--brand-chart-crosshair-dark"
|
||||
: "--brand-chart-crosshair-light";
|
||||
|
||||
return {
|
||||
backgroundColor: readCssVar(backgroundVar, DEFAULT_CHART_PALETTE.backgroundColor),
|
||||
downColor: readCssVar("--brand-chart-down", DEFAULT_CHART_PALETTE.downColor),
|
||||
backgroundColor: readCssVar(
|
||||
backgroundVar,
|
||||
DEFAULT_CHART_PALETTE.backgroundColor,
|
||||
),
|
||||
downColor: readCssVar(
|
||||
"--brand-chart-down",
|
||||
DEFAULT_CHART_PALETTE.downColor,
|
||||
),
|
||||
volumeDownColor: readCssVar(
|
||||
"--brand-chart-volume-down",
|
||||
DEFAULT_CHART_PALETTE.volumeDownColor,
|
||||
@@ -237,7 +253,8 @@ export function StockLineChart({
|
||||
* @see lib/kis/domestic.ts getDomesticChart cursor
|
||||
*/
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current) return;
|
||||
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current)
|
||||
return;
|
||||
|
||||
loadingMoreRef.current = true;
|
||||
setIsLoadingMore(true);
|
||||
@@ -284,7 +301,7 @@ export function StockLineChart({
|
||||
|
||||
const chart = createChart(container, {
|
||||
width: Math.max(container.clientWidth, 320),
|
||||
height: Math.max(container.clientHeight, 340),
|
||||
height: Math.max(container.clientHeight, CHART_MIN_HEIGHT),
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: palette.backgroundColor },
|
||||
textColor: palette.textColor,
|
||||
@@ -298,7 +315,7 @@ export function StockLineChart({
|
||||
borderColor: palette.borderColor,
|
||||
scaleMargins: {
|
||||
top: 0.08,
|
||||
bottom: 0.24,
|
||||
bottom: 0.2,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
@@ -372,7 +389,7 @@ export function StockLineChart({
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
chart.resize(
|
||||
Math.max(container.clientWidth, 320),
|
||||
Math.max(container.clientHeight, 340),
|
||||
Math.max(container.clientHeight, CHART_MIN_HEIGHT),
|
||||
);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
@@ -380,7 +397,7 @@ export function StockLineChart({
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
chart.resize(
|
||||
Math.max(container.clientWidth, 320),
|
||||
Math.max(container.clientHeight, 340),
|
||||
Math.max(container.clientHeight, CHART_MIN_HEIGHT),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -452,7 +469,9 @@ export function StockLineChart({
|
||||
if (disposed) return;
|
||||
|
||||
let mergedBars = normalizeCandles(firstPage.candles, timeframe);
|
||||
let resolvedNextCursor = firstPage.hasMore ? firstPage.nextCursor : null;
|
||||
let resolvedNextCursor = firstPage.hasMore
|
||||
? firstPage.nextCursor
|
||||
: null;
|
||||
|
||||
// 분봉은 기본 3페이지까지 순차 조회해 오전 구간 누락을 줄입니다.
|
||||
if (
|
||||
@@ -474,7 +493,9 @@ export function StockLineChart({
|
||||
|
||||
const olderBars = normalizeCandles(olderPage.candles, timeframe);
|
||||
mergedBars = mergeBars(olderBars, mergedBars);
|
||||
resolvedNextCursor = olderPage.hasMore ? olderPage.nextCursor : null;
|
||||
resolvedNextCursor = olderPage.hasMore
|
||||
? olderPage.nextCursor
|
||||
: null;
|
||||
minuteCursor = olderPage.hasMore ? olderPage.nextCursor : null;
|
||||
extraPageCount += 1;
|
||||
} catch {
|
||||
@@ -522,11 +543,11 @@ export function StockLineChart({
|
||||
}
|
||||
}, [isChartReady, renderableBars, setSeriesData]);
|
||||
|
||||
/**
|
||||
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
|
||||
* @see features/trade/components/chart/chart-utils.ts toRealtimeTickBar
|
||||
*/
|
||||
/**
|
||||
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
|
||||
* @see features/trade/components/chart/chart-utils.ts toRealtimeTickBar
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!latestTick) return;
|
||||
if (bars.length === 0) return;
|
||||
@@ -600,7 +621,7 @@ export function StockLineChart({
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[340px] flex-col bg-white dark:bg-brand-900/10">
|
||||
<div className="flex h-full min-h-[280px] flex-col bg-white dark:bg-brand-900/10 xl:min-h-0">
|
||||
{/* ========== CHART TOOLBAR ========== */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-brand-100 bg-muted/20 px-2 py-2 sm:px-3 dark:border-brand-800/45 dark:bg-brand-900/35">
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
|
||||
@@ -668,14 +689,15 @@ export function StockLineChart({
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-muted-foreground dark:text-brand-100/85 sm:text-xs">
|
||||
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)} L{" "}
|
||||
{formatPrice(latest?.low ?? 0)} C{" "}
|
||||
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)}{" "}
|
||||
L {formatPrice(latest?.low ?? 0)} C{" "}
|
||||
<span
|
||||
className={cn(
|
||||
change >= 0 ? "text-red-600" : "text-blue-600 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)})
|
||||
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DashboardStockItem } from "@/features/trade/types/trade.types";
|
||||
import type { DashboardStockItem } from "@/features/trade/types/trade.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StockHeaderProps {
|
||||
@@ -13,6 +13,10 @@ interface StockHeaderProps {
|
||||
volume?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 선택된 종목의 현재가/등락/시세 요약 헤더를 렌더링합니다.
|
||||
* @see features/trade/components/layout/TradeDashboardContent.tsx - StockHeader 사용 (header prop으로 전달)
|
||||
*/
|
||||
export function StockHeader({
|
||||
stock,
|
||||
price,
|
||||
@@ -22,68 +26,154 @@ export function StockHeader({
|
||||
low,
|
||||
volume,
|
||||
}: StockHeaderProps) {
|
||||
const isRise = changeRate.startsWith("+") || parseFloat(changeRate) > 0;
|
||||
const isFall = changeRate.startsWith("-") || parseFloat(changeRate) < 0;
|
||||
const changeRateNum = parseFloat(changeRate);
|
||||
const isRise = changeRateNum > 0;
|
||||
const isFall = changeRateNum < 0;
|
||||
const colorClass = isRise
|
||||
? "text-red-500"
|
||||
: isFall
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-foreground";
|
||||
|
||||
const bgGlowClass = isRise
|
||||
? "from-red-500/10 to-transparent dark:from-red-500/15"
|
||||
: isFall
|
||||
? "from-blue-500/10 to-transparent dark:from-blue-500/15"
|
||||
: "from-brand-500/10 to-transparent";
|
||||
|
||||
// 전일종가 계산 (현재가 - 변동액)
|
||||
const prevClose =
|
||||
stock.prevClose > 0 ? stock.prevClose.toLocaleString("ko-KR") : "--";
|
||||
const open = stock.open > 0 ? stock.open.toLocaleString("ko-KR") : "--";
|
||||
|
||||
return (
|
||||
<div className="bg-white px-3 py-1.5 dark:bg-brand-900/22 sm:px-4 sm:py-2">
|
||||
<div className="bg-white px-3 py-2 dark:bg-brand-900/22 sm:px-4">
|
||||
{/* ========== STOCK SUMMARY ========== */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-base font-bold leading-tight text-foreground dark:text-brand-50 sm:text-lg">
|
||||
{stock.name}
|
||||
</h1>
|
||||
{/* 종목명 + 코드 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="truncate text-base font-bold leading-tight text-foreground dark:text-brand-50 sm:text-lg">
|
||||
{stock.name}
|
||||
</h1>
|
||||
<span className="shrink-0 rounded border border-brand-200/60 bg-brand-50/50 px-1.5 py-0.5 text-[10px] font-medium text-brand-600 dark:border-brand-700/45 dark:bg-brand-900/30 dark:text-brand-200">
|
||||
{stock.market}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mt-0.5 block text-[11px] text-muted-foreground dark:text-brand-100/70 sm:text-xs">
|
||||
{stock.symbol}/{stock.market}
|
||||
{stock.symbol}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={cn("shrink-0 text-right", colorClass)}>
|
||||
<span className="block text-xl font-bold tracking-tight sm:text-2xl">{price}</span>
|
||||
<span className="text-[11px] font-medium sm:text-xs">
|
||||
{changeRate}% <span className="ml-1 text-[11px] sm:text-xs">{change}</span>
|
||||
{/* 현재가 + 등락 */}
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-lg bg-linear-to-l px-3 py-1.5 text-right",
|
||||
bgGlowClass,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block text-xl font-bold tracking-tight tabular-nums sm:text-2xl",
|
||||
colorClass,
|
||||
)}
|
||||
>
|
||||
{price}
|
||||
</span>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-medium tabular-nums sm:text-xs",
|
||||
colorClass,
|
||||
)}
|
||||
>
|
||||
{isRise ? "▲" : isFall ? "▼" : ""}
|
||||
{changeRate}%
|
||||
</span>
|
||||
<span
|
||||
className={cn("text-[11px] tabular-nums sm:text-xs", colorClass)}
|
||||
>
|
||||
{isRise && "+"}
|
||||
{change}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== STATS ========== */}
|
||||
<div className="mt-1.5 grid grid-cols-3 gap-2 text-xs md:hidden">
|
||||
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
|
||||
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70">고가</p>
|
||||
<p className="font-medium text-red-500">{high || "--"}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
|
||||
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70">저가</p>
|
||||
<p className="font-medium text-blue-600 dark:text-blue-400">{low || "--"}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
|
||||
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70">거래량(24H)</p>
|
||||
<p className="font-medium">{volume || "--"}</p>
|
||||
</div>
|
||||
{/* ========== MOBILE STATS ========== */}
|
||||
<div className="mt-2 grid grid-cols-3 gap-1.5 text-xs md:hidden">
|
||||
<StatCard label="고가" value={high || "--"} tone="ask" />
|
||||
<StatCard label="저가" value={low || "--"} tone="bid" />
|
||||
<StatCard label="거래량" value={volume || "--"} />
|
||||
</div>
|
||||
|
||||
<Separator className="mt-1.5 md:hidden" />
|
||||
|
||||
{/* ========== DESKTOP STATS ========== */}
|
||||
<div className="hidden items-center justify-end gap-5 pt-1 text-sm md:flex">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-muted-foreground text-xs dark:text-brand-100/70">고가</span>
|
||||
<span className="font-medium text-red-500">{high || "--"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-muted-foreground text-xs dark:text-brand-100/70">저가</span>
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">{low || "--"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-muted-foreground text-xs dark:text-brand-100/70">거래량(24H)</span>
|
||||
<span className="font-medium">{volume || "--"}</span>
|
||||
</div>
|
||||
<div className="hidden items-center justify-end gap-4 pt-1.5 md:flex">
|
||||
<DesktopStat label="전일종가" value={prevClose} />
|
||||
<DesktopStat label="시가" value={open} />
|
||||
<DesktopStat label="고가" value={high || "--"} tone="ask" />
|
||||
<DesktopStat label="저가" value={low || "--"} tone="bid" />
|
||||
<DesktopStat label="거래량" value={volume ? `${volume}주` : "--"} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 모바일 통계 카드 */
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "ask" | "bid";
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
|
||||
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold",
|
||||
tone === "ask" && "text-red-500",
|
||||
tone === "bid" && "text-blue-600 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 데스크톱 통계 항목 */
|
||||
function DesktopStat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "ask" | "bid";
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-[11px] text-muted-foreground dark:text-brand-100/70">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold tabular-nums",
|
||||
tone === "ask" && "text-red-500",
|
||||
tone === "bid" && "text-blue-600 dark:text-blue-400",
|
||||
!tone && "text-foreground dark:text-brand-50",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
311
features/trade/components/holdings/HoldingsPanel.tsx
Normal file
311
features/trade/components/holdings/HoldingsPanel.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { RefreshCw, TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { fetchDashboardBalance } from "@/features/dashboard/apis/dashboard.api";
|
||||
import type {
|
||||
DashboardBalanceSummary,
|
||||
DashboardHoldingItem,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface HoldingsPanelProps {
|
||||
credentials: KisRuntimeCredentials;
|
||||
}
|
||||
|
||||
/** 천단위 포맷 */
|
||||
function fmt(v: number) {
|
||||
return Number.isFinite(v) ? v.toLocaleString("ko-KR") : "0";
|
||||
}
|
||||
|
||||
/** 수익률 색상 */
|
||||
function profitClass(v: number) {
|
||||
if (v > 0) return "text-red-500";
|
||||
if (v < 0) return "text-blue-600 dark:text-blue-400";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 매매창 하단에 보유 종목 및 평가손익 현황을 표시합니다.
|
||||
* @see features/trade/components/layout/TradeDashboardContent.tsx - holdingsPanel prop으로 DashboardLayout에 전달
|
||||
* @see features/dashboard/apis/dashboard.api.ts - fetchDashboardBalance API 호출
|
||||
*/
|
||||
export function HoldingsPanel({ credentials }: HoldingsPanelProps) {
|
||||
// [State] 잔고/보유종목 데이터
|
||||
const [summary, setSummary] = useState<DashboardBalanceSummary | null>(null);
|
||||
const [holdings, setHoldings] = useState<DashboardHoldingItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
/**
|
||||
* UI 흐름: HoldingsPanel 마운트 or 새로고침 버튼 -> loadBalance -> fetchDashboardBalance API ->
|
||||
* 응답 -> summary/holdings 상태 업데이트 -> 테이블 렌더링
|
||||
*/
|
||||
const loadBalance = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchDashboardBalance(credentials);
|
||||
setSummary(data.summary);
|
||||
setHoldings(data.holdings);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "잔고 조회 중 오류가 발생했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [credentials]);
|
||||
|
||||
// [Effect] 컴포넌트 마운트 시 잔고 조회
|
||||
useEffect(() => {
|
||||
loadBalance();
|
||||
}, [loadBalance]);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-brand-900/20">
|
||||
{/* ========== HOLDINGS HEADER ========== */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2 dark:border-brand-800/45">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-foreground dark:text-brand-50 hover:text-brand-600 dark:hover:text-brand-300 transition-colors"
|
||||
>
|
||||
<span className="text-brand-500">▶</span>
|
||||
보유 종목 현황
|
||||
<span className="text-xs font-normal text-muted-foreground dark:text-brand-100/60">
|
||||
({holdings.length}종목)
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 요약 배지: 수익/손실 */}
|
||||
{summary && !isLoading && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold",
|
||||
summary.totalProfitLoss >= 0
|
||||
? "bg-red-50 text-red-600 dark:bg-red-900/25 dark:text-red-400"
|
||||
: "bg-blue-50 text-blue-600 dark:bg-blue-900/25 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
{summary.totalProfitLoss >= 0 ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
{summary.totalProfitLoss >= 0 ? "+" : ""}
|
||||
{fmt(summary.totalProfitLoss)}원 (
|
||||
{summary.totalProfitRate >= 0 ? "+" : ""}
|
||||
{summary.totalProfitRate.toFixed(2)}%)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 새로고침 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadBalance}
|
||||
disabled={isLoading}
|
||||
className="h-7 gap-1 px-2 text-[11px] text-muted-foreground hover:text-brand-600 dark:text-brand-100/60 dark:hover:text-brand-300"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("h-3.5 w-3.5", isLoading && "animate-spin")}
|
||||
/>
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ========== HOLDINGS CONTENT ========== */}
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{/* 요약 바 */}
|
||||
{summary && !isLoading && (
|
||||
<div className="grid grid-cols-2 gap-2 border-b border-border/50 bg-muted/10 px-4 py-2 dark:border-brand-800/35 dark:bg-brand-900/15 sm:grid-cols-4">
|
||||
<SummaryItem
|
||||
label="총 평가금액"
|
||||
value={`${fmt(summary.evaluationAmount)}원`}
|
||||
/>
|
||||
<SummaryItem
|
||||
label="총 매입금액"
|
||||
value={`${fmt(summary.purchaseAmount)}원`}
|
||||
/>
|
||||
<SummaryItem
|
||||
label="평가손익"
|
||||
value={`${summary.totalProfitLoss >= 0 ? "+" : ""}${fmt(summary.totalProfitLoss)}원`}
|
||||
tone={
|
||||
summary.totalProfitLoss > 0
|
||||
? "profit"
|
||||
: summary.totalProfitLoss < 0
|
||||
? "loss"
|
||||
: "neutral"
|
||||
}
|
||||
/>
|
||||
<SummaryItem
|
||||
label="수익률"
|
||||
value={`${summary.totalProfitRate >= 0 ? "+" : ""}${summary.totalProfitRate.toFixed(2)}%`}
|
||||
tone={
|
||||
summary.totalProfitRate > 0
|
||||
? "profit"
|
||||
: summary.totalProfitRate < 0
|
||||
? "loss"
|
||||
: "neutral"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{isLoading && <HoldingsSkeleton />}
|
||||
|
||||
{/* 에러 상태 */}
|
||||
{!isLoading && error && (
|
||||
<div className="flex items-center justify-center px-4 py-6 text-sm text-muted-foreground dark:text-brand-100/60">
|
||||
<span className="mr-2 text-destructive">⚠</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 보유 종목 없음 */}
|
||||
{!isLoading && !error && holdings.length === 0 && (
|
||||
<div className="flex items-center justify-center px-4 py-6 text-sm text-muted-foreground dark:text-brand-100/60">
|
||||
보유 중인 종목이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 보유 종목 테이블 */}
|
||||
{!isLoading && !error && holdings.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="grid min-w-[600px] grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr] border-b border-border/50 bg-muted/15 px-4 py-1.5 text-[11px] font-medium text-muted-foreground dark:border-brand-800/35 dark:bg-brand-900/20 dark:text-brand-100/65">
|
||||
<div>종목명</div>
|
||||
<div className="text-right">보유수량</div>
|
||||
<div className="text-right">평균단가</div>
|
||||
<div className="text-right">현재가</div>
|
||||
<div className="text-right">평가손익</div>
|
||||
<div className="text-right">수익률</div>
|
||||
</div>
|
||||
|
||||
{/* 종목 행 */}
|
||||
{holdings.map((holding) => (
|
||||
<HoldingRow key={holding.symbol} holding={holding} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 요약 항목 */
|
||||
function SummaryItem({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "profit" | "loss" | "neutral";
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] text-muted-foreground dark:text-brand-100/60">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-semibold tabular-nums",
|
||||
tone === "profit" && "text-red-500",
|
||||
tone === "loss" && "text-blue-600 dark:text-blue-400",
|
||||
(!tone || tone === "neutral") && "text-foreground dark:text-brand-50",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 보유 종목 행 */
|
||||
function HoldingRow({ holding }: { holding: DashboardHoldingItem }) {
|
||||
return (
|
||||
<div className="grid min-w-[600px] grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr] items-center border-b border-border/30 px-4 py-2 text-xs hover:bg-muted/20 dark:border-brand-800/25 dark:hover:bg-brand-900/20">
|
||||
{/* 종목명 */}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground dark:text-brand-50">
|
||||
{holding.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground dark:text-brand-100/55">
|
||||
{holding.symbol} · {holding.market}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 보유수량 */}
|
||||
<div className="text-right tabular-nums text-foreground dark:text-brand-50">
|
||||
{fmt(holding.quantity)}주
|
||||
</div>
|
||||
|
||||
{/* 평균단가 */}
|
||||
<div className="text-right tabular-nums text-foreground dark:text-brand-50">
|
||||
{fmt(holding.averagePrice)}
|
||||
</div>
|
||||
|
||||
{/* 현재가 */}
|
||||
<div
|
||||
className={cn(
|
||||
"text-right tabular-nums font-medium",
|
||||
profitClass(holding.currentPrice - holding.averagePrice),
|
||||
)}
|
||||
>
|
||||
{fmt(holding.currentPrice)}
|
||||
</div>
|
||||
|
||||
{/* 평가손익 */}
|
||||
<div
|
||||
className={cn(
|
||||
"text-right tabular-nums font-medium",
|
||||
profitClass(holding.profitLoss),
|
||||
)}
|
||||
>
|
||||
{holding.profitLoss >= 0 ? "+" : ""}
|
||||
{fmt(holding.profitLoss)}
|
||||
</div>
|
||||
|
||||
{/* 수익률 */}
|
||||
<div
|
||||
className={cn(
|
||||
"text-right tabular-nums font-semibold",
|
||||
profitClass(holding.profitRate),
|
||||
)}
|
||||
>
|
||||
{holding.profitRate >= 0 ? "+" : ""}
|
||||
{holding.profitRate.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 로딩 스켈레톤 */
|
||||
function HoldingsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2 px-4 py-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<Skeleton className="h-8 flex-1" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
header: ReactNode;
|
||||
header?: ReactNode;
|
||||
chart: ReactNode;
|
||||
orderBook: ReactNode;
|
||||
orderForm: ReactNode;
|
||||
@@ -14,8 +14,9 @@ interface DashboardLayoutProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 트레이드 본문 레이아웃을 구성합니다. 상단 차트 영역은 보임/숨김 토글을 지원합니다.
|
||||
* @see features/trade/components/layout/TradeDashboardContent.tsx 상위 컴포넌트에서 차트 토글 상태를 관리하고 본 레이아웃에 전달합니다.
|
||||
* @description 트레이드 본문을 업비트 스타일의 2단 레이아웃으로 렌더링합니다.
|
||||
* @summary UI 흐름: TradeDashboardContent -> DashboardLayout -> 상단(차트) + 하단(호가/주문) 배치
|
||||
* @see features/trade/components/layout/TradeDashboardContent.tsx - 차트 토글 상태와 슬롯 컴포넌트를 전달합니다.
|
||||
*/
|
||||
export function DashboardLayout({
|
||||
header,
|
||||
@@ -29,54 +30,46 @@ export function DashboardLayout({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col bg-background dark:bg-[linear-gradient(135deg,rgba(53,35,86,0.18),rgba(13,13,20,0.98))]",
|
||||
// Mobile: Scrollable page height
|
||||
"min-h-[calc(100vh-64px)]",
|
||||
// Desktop: Fixed height, no window scroll
|
||||
"xl:h-[calc(100vh-64px)] xl:overflow-hidden",
|
||||
"flex h-full min-h-0 flex-col bg-background dark:bg-[linear-gradient(135deg,rgba(53,35,86,0.18),rgba(13,13,20,0.98))]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* 1. Header Area */}
|
||||
<div className="flex-none border-b border-brand-100 bg-white dark:border-brand-800/45 dark:bg-brand-900/22">
|
||||
{header}
|
||||
</div>
|
||||
{/* ========== 1. OPTIONAL HEADER AREA ========== */}
|
||||
{header && (
|
||||
<div className="flex-none border-b border-brand-100 bg-white dark:border-brand-800/45 dark:bg-brand-900/22">
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. Main Content Area */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-h-0 overflow-y-auto",
|
||||
"xl:overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-full flex-col xl:h-full xl:min-h-0">
|
||||
{/* ========== CHART SECTION ========== */}
|
||||
<section className="flex-none border-b border-border dark:border-brand-800/45">
|
||||
<div className="flex items-center justify-between gap-2 bg-muted/20 px-3 py-1.5 dark:bg-brand-900/30 sm:px-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-foreground dark:text-brand-50 sm:text-sm">
|
||||
{/* ========== 2. MAIN CONTENT AREA ========== */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto xl:overflow-hidden">
|
||||
<div className="flex min-h-full flex-col xl:h-full xl:min-h-0 xl:overflow-hidden">
|
||||
{/* ========== TOP: CHART AREA ========== */}
|
||||
<section className="flex min-h-0 flex-col border-b border-border dark:border-brand-800/45 xl:h-[34%] xl:min-h-[200px]">
|
||||
{/* 모바일 전용 차트 토글 */}
|
||||
<div className="flex items-center justify-between gap-2 bg-muted/15 px-3 py-1.5 dark:bg-brand-900/25 sm:px-4 xl:hidden">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-green-400 shadow-[0_0_6px_rgba(74,222,128,0.6)]" />
|
||||
<p className="text-xs font-semibold text-foreground dark:text-brand-50">
|
||||
실시간 차트
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground dark:text-brand-100/70 sm:text-[11px]">
|
||||
거래 화면 집중을 위해 기본은 접힌 상태입니다.
|
||||
</p>
|
||||
</div>
|
||||
{/* UI 흐름: 차트 토글 버튼 -> onToggleChart 호출 -> TradeDashboardContent의 상태 변경 -> 차트 wrapper 높이 반영 */}
|
||||
{/* UI 흐름: 토글 클릭 -> onToggleChart -> 상위 상태 변경 -> 차트 표시/숨김 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onToggleChart}
|
||||
className="h-7 gap-1 border-brand-200 bg-white px-2 text-[11px] text-brand-700 hover:bg-brand-50 dark:border-brand-700/55 dark:bg-brand-900/35 dark:text-brand-100 dark:hover:bg-brand-800/35 sm:h-8 sm:px-3 sm:text-xs"
|
||||
className="h-6 gap-1 border-brand-200 bg-white/70 px-2 text-[11px] text-brand-700 hover:bg-brand-50 dark:border-brand-700/55 dark:bg-brand-900/35 dark:text-brand-100 dark:hover:bg-brand-800/35 sm:h-7 sm:px-3"
|
||||
aria-expanded={isChartVisible}
|
||||
>
|
||||
{isChartVisible ? (
|
||||
<>
|
||||
차트 숨기기 <ChevronUp className="h-3.5 w-3.5" />
|
||||
숨기기 <ChevronUp className="h-3 w-3" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
차트 보이기 <ChevronDown className="h-3.5 w-3.5" />
|
||||
펼치기 <ChevronDown className="h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -84,28 +77,28 @@ export function DashboardLayout({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden border-t border-border/70 transition-[max-height,opacity] duration-200 dark:border-brand-800/45",
|
||||
isChartVisible ? "max-h-[56vh] opacity-100" : "max-h-0 opacity-0",
|
||||
"overflow-hidden border-t border-border/70 transition-[max-height,opacity] duration-300 dark:border-brand-800/45 xl:flex-1 xl:min-h-0 xl:max-h-none xl:opacity-100",
|
||||
isChartVisible ? "max-h-[64vh] opacity-100" : "max-h-0 opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="h-[34vh] min-h-[280px] w-full sm:h-[40vh] xl:h-[34vh] 2xl:h-[38vh]">
|
||||
<div className="h-[29vh] min-h-[200px] w-full sm:h-[33vh] xl:h-full xl:min-h-0">
|
||||
{chart}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== ORDERBOOK + ORDER SECTION ========== */}
|
||||
<div className="flex flex-1 min-h-0 flex-col xl:flex-row xl:overflow-hidden">
|
||||
<section className="flex min-h-0 flex-col border-b border-border dark:border-brand-800/45 xl:flex-1 xl:border-b-0 xl:border-r">
|
||||
<div className="h-[390px] min-h-0 sm:h-[430px] xl:h-full">
|
||||
{/* ========== BOTTOM: ORDERBOOK + ORDER AREA ========== */}
|
||||
<section className="flex flex-1 min-h-0 flex-col xl:grid xl:grid-cols-[minmax(0,1fr)_480px] 2xl:grid-cols-[minmax(0,1fr)_540px] xl:overflow-hidden">
|
||||
<div className="flex min-h-0 flex-col border-b border-border dark:border-brand-800/45 xl:border-b-0 xl:border-r">
|
||||
<div className="min-h-0 xl:h-full xl:min-h-0">
|
||||
{orderBook}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="flex min-h-0 flex-col bg-background dark:bg-brand-900/12 xl:w-[430px] 2xl:w-[470px]">
|
||||
<div className="min-h-[320px] xl:h-full">{orderForm}</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col bg-background dark:bg-brand-900/12">
|
||||
<div className="min-h-[280px] xl:h-full xl:min-h-0">{orderForm}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import { StockLineChart } from "@/features/trade/components/chart/StockLineChart";
|
||||
import { StockHeader } from "@/features/trade/components/header/StockHeader";
|
||||
import { DashboardLayout } from "@/features/trade/components/layout/DashboardLayout";
|
||||
import { OrderForm } from "@/features/trade/components/order/OrderForm";
|
||||
import { OrderBook } from "@/features/trade/components/orderbook/OrderBook";
|
||||
@@ -14,36 +14,32 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface TradeDashboardContentProps {
|
||||
selectedStock: DashboardStockItem | null;
|
||||
matchedHolding?: DashboardHoldingItem | null;
|
||||
verifiedCredentials: KisRuntimeCredentials | null;
|
||||
latestTick: DashboardRealtimeTradeTick | null;
|
||||
recentTradeTicks: DashboardRealtimeTradeTick[];
|
||||
orderBook: DashboardStockOrderBookResponse | null;
|
||||
isOrderBookLoading: boolean;
|
||||
referencePrice?: number;
|
||||
currentPrice?: number;
|
||||
change?: number;
|
||||
changeRate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 트레이드 본문(헤더/차트/호가/주문)을 조합해서 렌더링합니다.
|
||||
* @see features/trade/components/TradeContainer.tsx TradeContainer가 화면 조합 코드를 단순화하기 위해 사용합니다.
|
||||
* @see features/trade/components/layout/DashboardLayout.tsx 실제 4분할 레이아웃은 DashboardLayout에서 처리합니다.
|
||||
* @description 트레이드 본문(차트/체결+호가/주문)을 조합하여 렌더링합니다.
|
||||
* @see features/trade/components/TradeContainer.tsx - TradeDashboardContent 렌더링 (selectedStock, verifiedCredentials 등 전달)
|
||||
* @see features/trade/components/layout/DashboardLayout.tsx - 3열 레이아웃(차트 | 체결+호가 | 매도)을 처리합니다.
|
||||
*/
|
||||
export function TradeDashboardContent({
|
||||
selectedStock,
|
||||
matchedHolding,
|
||||
verifiedCredentials,
|
||||
latestTick,
|
||||
recentTradeTicks,
|
||||
orderBook,
|
||||
isOrderBookLoading,
|
||||
referencePrice,
|
||||
currentPrice,
|
||||
change,
|
||||
changeRate,
|
||||
}: TradeDashboardContentProps) {
|
||||
// [State] 차트 영역 보임/숨김 상태
|
||||
const [isChartVisible, setIsChartVisible] = useState(false);
|
||||
// [State] 차트 영역 보임/숨김 - 요청사항 반영: 모바일에서도 기본 표시
|
||||
const [isChartVisible, setIsChartVisible] = useState(true);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -54,21 +50,6 @@ export function TradeDashboardContent({
|
||||
>
|
||||
{/* ========== DASHBOARD LAYOUT ========== */}
|
||||
<DashboardLayout
|
||||
header={
|
||||
selectedStock ? (
|
||||
<StockHeader
|
||||
stock={selectedStock}
|
||||
price={currentPrice?.toLocaleString() ?? "0"}
|
||||
change={change?.toLocaleString() ?? "0"}
|
||||
changeRate={changeRate?.toFixed(2) ?? "0.00"}
|
||||
high={latestTick ? latestTick.high.toLocaleString() : undefined}
|
||||
low={latestTick ? latestTick.low.toLocaleString() : undefined}
|
||||
volume={
|
||||
latestTick ? latestTick.accumulatedVolume.toLocaleString() : undefined
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
chart={
|
||||
selectedStock ? (
|
||||
<div className="p-0 h-full flex flex-col">
|
||||
@@ -95,7 +76,12 @@ export function TradeDashboardContent({
|
||||
isLoading={isOrderBookLoading}
|
||||
/>
|
||||
}
|
||||
orderForm={<OrderForm stock={selectedStock ?? undefined} />}
|
||||
orderForm={
|
||||
<OrderForm
|
||||
stock={selectedStock ?? undefined}
|
||||
matchedHolding={matchedHolding}
|
||||
/>
|
||||
}
|
||||
isChartVisible={isChartVisible}
|
||||
onToggleChart={() => setIsChartVisible((prev) => !prev)}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -9,28 +12,35 @@ import type {
|
||||
DashboardOrderSide,
|
||||
DashboardStockItem,
|
||||
} from "@/features/trade/types/trade.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OrderFormProps {
|
||||
stock?: DashboardStockItem;
|
||||
matchedHolding?: DashboardHoldingItem | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 대시보드 주문 패널에서 매수/매도 주문을 입력하고 전송합니다.
|
||||
* @see features/trade/hooks/useOrder.ts placeOrder - 주문 API 호출
|
||||
* @see features/trade/components/TradeContainer.tsx OrderForm - 우측 주문 패널 렌더링
|
||||
* @see features/trade/hooks/useOrder.ts - placeOrder 주문 API 호출
|
||||
* @see features/trade/components/layout/TradeDashboardContent.tsx - orderForm prop으로 DashboardLayout에 전달
|
||||
*/
|
||||
export function OrderForm({ stock }: OrderFormProps) {
|
||||
export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
const verifiedCredentials = useKisRuntimeStore(
|
||||
(state) => state.verifiedCredentials,
|
||||
);
|
||||
const { placeOrder, isLoading, error } = useOrder();
|
||||
|
||||
// ========== FORM STATE ==========
|
||||
const [price, setPrice] = useState<string>(stock?.currentPrice.toString() || "");
|
||||
const [price, setPrice] = useState<string>(
|
||||
stock?.currentPrice.toString() || "",
|
||||
);
|
||||
const [quantity, setQuantity] = useState<string>("");
|
||||
const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy");
|
||||
|
||||
// ========== ORDER HANDLER ==========
|
||||
/**
|
||||
* UI 흐름: 매수하기/매도하기 버튼 클릭 -> handleOrder -> placeOrder API 호출 -> 주문번호 반환 -> alert
|
||||
*/
|
||||
const handleOrder = async (side: DashboardOrderSide) => {
|
||||
if (!stock || !verifiedCredentials) return;
|
||||
|
||||
@@ -79,34 +89,67 @@ export function OrderForm({ stock }: OrderFormProps) {
|
||||
};
|
||||
|
||||
const isMarketDataAvailable = Boolean(stock);
|
||||
const isBuy = activeTab === "buy";
|
||||
|
||||
return (
|
||||
<div className="h-full border-l border-border bg-background p-3 dark:border-brand-800/45 dark:bg-brand-950/55 sm:p-4">
|
||||
<div className="h-full bg-background p-3 dark:bg-brand-950/55 sm:p-4">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as "buy" | "sell")}
|
||||
className="flex h-full w-full flex-col"
|
||||
>
|
||||
{/* ========== ORDER SIDE TABS ========== */}
|
||||
<TabsList className="mb-3 grid h-10 w-full grid-cols-2 gap-1 border border-brand-200/70 bg-muted/35 p-1 dark:border-brand-700/50 dark:bg-brand-900/28 sm:mb-4">
|
||||
<TabsList className="mb-3 grid h-10 w-full grid-cols-2 gap-0.5 rounded-lg border border-border/60 bg-muted/30 p-0.5 dark:border-brand-700/50 dark:bg-brand-900/25 sm:mb-4">
|
||||
<TabsTrigger
|
||||
value="buy"
|
||||
className="!h-full rounded-md border border-transparent text-foreground/75 transition-colors dark:text-brand-100/75 data-[state=active]:border-red-400/60 data-[state=active]:bg-red-600 data-[state=active]:text-white data-[state=active]:shadow-[0_0_0_1px_rgba(248,113,113,0.45)]"
|
||||
className={cn(
|
||||
"!h-full rounded-md border border-transparent text-sm font-semibold text-foreground/70 transition-all dark:text-brand-100/70",
|
||||
"data-[state=active]:border-red-400/50 data-[state=active]:bg-red-600 data-[state=active]:text-white data-[state=active]:shadow-[0_2px_8px_rgba(220,38,38,0.4)]",
|
||||
)}
|
||||
>
|
||||
매수
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="sell"
|
||||
className="!h-full rounded-md border border-transparent text-foreground/75 transition-colors dark:text-brand-100/75 data-[state=active]:border-blue-400/65 data-[state=active]:bg-blue-600 data-[state=active]:text-white data-[state=active]:shadow-[0_0_0_1px_rgba(96,165,250,0.45)]"
|
||||
className={cn(
|
||||
"!h-full rounded-md border border-transparent text-sm font-semibold text-foreground/70 transition-all dark:text-brand-100/70",
|
||||
"data-[state=active]:border-blue-400/50 data-[state=active]:bg-blue-600 data-[state=active]:text-white data-[state=active]:shadow-[0_2px_8px_rgba(37,99,235,0.4)]",
|
||||
)}
|
||||
>
|
||||
매도
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ========== CURRENT PRICE INFO ========== */}
|
||||
{stock && (
|
||||
<div
|
||||
className={cn(
|
||||
"mb-3 flex items-center justify-between rounded-md border px-3 py-2 text-xs",
|
||||
isBuy
|
||||
? "border-red-200/60 bg-red-50/50 dark:border-red-800/35 dark:bg-red-950/25"
|
||||
: "border-blue-200/60 bg-blue-50/50 dark:border-blue-800/35 dark:bg-blue-950/25",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground dark:text-brand-100/65">
|
||||
현재가
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-bold tabular-nums",
|
||||
isBuy
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-blue-600 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
{stock.currentPrice.toLocaleString()}원
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== BUY TAB ========== */}
|
||||
<TabsContent
|
||||
value="buy"
|
||||
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
|
||||
className="flex flex-1 flex-col space-y-2.5 data-[state=inactive]:hidden sm:space-y-3"
|
||||
>
|
||||
<OrderInputs
|
||||
type="buy"
|
||||
@@ -120,19 +163,26 @@ export function OrderForm({ stock }: OrderFormProps) {
|
||||
errorMessage={error}
|
||||
/>
|
||||
<PercentButtons onSelect={setPercent} />
|
||||
<Button
|
||||
className="mt-auto h-11 w-full bg-red-600 text-base text-white shadow-sm ring-1 ring-red-300/35 hover:bg-red-700 dark:bg-red-500 dark:ring-red-300/45 dark:hover:bg-red-400 sm:h-12 sm:text-lg"
|
||||
disabled={isLoading || !isMarketDataAvailable}
|
||||
onClick={() => handleOrder("buy")}
|
||||
>
|
||||
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매수하기"}
|
||||
</Button>
|
||||
<div className="mt-auto space-y-2.5 sm:space-y-3">
|
||||
<HoldingInfoPanel holding={matchedHolding} />
|
||||
<Button
|
||||
className="h-11 w-full rounded-lg bg-red-600 text-base font-bold text-white shadow-[0_4px_14px_rgba(220,38,38,0.4)] ring-1 ring-red-300/30 transition-all hover:bg-red-700 hover:shadow-[0_4px_20px_rgba(220,38,38,0.5)] dark:bg-red-500 dark:ring-red-300/40 dark:hover:bg-red-400 sm:h-12 sm:text-lg"
|
||||
disabled={isLoading || !isMarketDataAvailable}
|
||||
onClick={() => handleOrder("buy")}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 animate-spin" />
|
||||
) : (
|
||||
"매수하기"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ========== SELL TAB ========== */}
|
||||
<TabsContent
|
||||
value="sell"
|
||||
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
|
||||
className="flex flex-1 flex-col space-y-2.5 data-[state=inactive]:hidden sm:space-y-3"
|
||||
>
|
||||
<OrderInputs
|
||||
type="sell"
|
||||
@@ -146,13 +196,20 @@ export function OrderForm({ stock }: OrderFormProps) {
|
||||
errorMessage={error}
|
||||
/>
|
||||
<PercentButtons onSelect={setPercent} />
|
||||
<Button
|
||||
className="mt-auto h-11 w-full bg-blue-600 text-base text-white shadow-sm ring-1 ring-blue-300/35 hover:bg-blue-700 dark:bg-blue-500 dark:ring-blue-300/45 dark:hover:bg-blue-400 sm:h-12 sm:text-lg"
|
||||
disabled={isLoading || !isMarketDataAvailable}
|
||||
onClick={() => handleOrder("sell")}
|
||||
>
|
||||
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매도하기"}
|
||||
</Button>
|
||||
<div className="mt-auto space-y-2.5 sm:space-y-3">
|
||||
<HoldingInfoPanel holding={matchedHolding} />
|
||||
<Button
|
||||
className="h-11 w-full rounded-lg bg-blue-600 text-base font-bold text-white shadow-[0_4px_14px_rgba(37,99,235,0.4)] ring-1 ring-blue-300/30 transition-all hover:bg-blue-700 hover:shadow-[0_4px_20px_rgba(37,99,235,0.5)] dark:bg-blue-500 dark:ring-blue-300/40 dark:hover:bg-blue-400 sm:h-12 sm:text-lg"
|
||||
disabled={isLoading || !isMarketDataAvailable}
|
||||
onClick={() => handleOrder("sell")}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 animate-spin" />
|
||||
) : (
|
||||
"매도하기"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -161,7 +218,7 @@ export function OrderForm({ stock }: OrderFormProps) {
|
||||
|
||||
/**
|
||||
* @description 주문 입력 영역(가격/수량/총액)을 렌더링합니다.
|
||||
* @see features/trade/components/order/OrderForm.tsx OrderForm - 매수/매도 탭에서 공용 호출
|
||||
* @see features/trade/components/order/OrderForm.tsx - OrderForm 매수/매도 탭에서 공용 호출
|
||||
*/
|
||||
function OrderInputs({
|
||||
type,
|
||||
@@ -184,25 +241,36 @@ function OrderInputs({
|
||||
hasError: boolean;
|
||||
errorMessage: string | null;
|
||||
}) {
|
||||
const labelClass =
|
||||
"text-xs font-medium text-foreground/80 dark:text-brand-100/80 min-w-[56px]";
|
||||
const inputClass =
|
||||
"col-span-3 h-9 text-right font-mono text-sm dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100";
|
||||
|
||||
return (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>주문가능</span>
|
||||
<span>- {type === "buy" ? "KRW" : "주"}</span>
|
||||
<div className="space-y-2 sm:space-y-2.5">
|
||||
{/* 주문 가능 */}
|
||||
<div className="flex items-center justify-between rounded-md bg-muted/30 px-3 py-1.5 text-xs dark:bg-brand-900/25">
|
||||
<span className="text-muted-foreground dark:text-brand-100/60">
|
||||
주문가능
|
||||
</span>
|
||||
<span className="font-medium text-foreground dark:text-brand-50">
|
||||
- {type === "buy" ? "KRW" : "주"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasError && (
|
||||
<div className="rounded bg-destructive/10 p-2 text-xs text-destructive break-keep">
|
||||
<div className="rounded-md bg-destructive/10 p-2 text-xs text-destructive break-keep">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 가격 입력 */}
|
||||
<div className="grid grid-cols-4 items-center gap-2">
|
||||
<span className="text-xs font-medium sm:text-sm">
|
||||
<span className={labelClass}>
|
||||
{type === "buy" ? "매수가격" : "매도가격"}
|
||||
</span>
|
||||
<Input
|
||||
className="col-span-3 text-right font-mono dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100"
|
||||
className={inputClass}
|
||||
placeholder="0"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
@@ -210,10 +278,11 @@ function OrderInputs({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수량 입력 */}
|
||||
<div className="grid grid-cols-4 items-center gap-2">
|
||||
<span className="text-xs font-medium sm:text-sm">주문수량</span>
|
||||
<span className={labelClass}>주문수량</span>
|
||||
<Input
|
||||
className="col-span-3 text-right font-mono dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100"
|
||||
className={inputClass}
|
||||
placeholder="0"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
@@ -221,13 +290,15 @@ function OrderInputs({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 총액 */}
|
||||
<div className="grid grid-cols-4 items-center gap-2">
|
||||
<span className="text-xs font-medium sm:text-sm">주문총액</span>
|
||||
<span className={labelClass}>주문총액</span>
|
||||
<Input
|
||||
className="col-span-3 bg-muted/50 text-right font-mono dark:border-brand-700/55 dark:bg-black/20 dark:text-brand-100"
|
||||
value={totalPrice.toLocaleString()}
|
||||
className={cn(inputClass, "bg-muted/40 dark:bg-black/20")}
|
||||
value={totalPrice > 0 ? `${totalPrice.toLocaleString()}원` : ""}
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
placeholder="0원"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,17 +307,17 @@ function OrderInputs({
|
||||
|
||||
/**
|
||||
* @description 주문 비율(10/25/50/100%) 단축 버튼을 표시합니다.
|
||||
* @see features/trade/components/order/OrderForm.tsx setPercent - 버튼 클릭 이벤트 처리
|
||||
* @see features/trade/components/order/OrderForm.tsx - OrderForm setPercent 이벤트 처리
|
||||
*/
|
||||
function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
|
||||
return (
|
||||
<div className="mt-2 grid grid-cols-4 gap-2">
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{["10%", "25%", "50%", "100%"].map((pct) => (
|
||||
<Button
|
||||
key={pct}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
className="h-8 text-xs font-medium border-border/60 hover:border-brand-300 hover:bg-brand-50/50 hover:text-brand-700 dark:border-brand-700/50 dark:hover:border-brand-500 dark:hover:bg-brand-900/30 dark:hover:text-brand-200"
|
||||
onClick={() => onSelect(pct)}
|
||||
>
|
||||
{pct}
|
||||
@@ -255,3 +326,80 @@ function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 선택 종목이 보유 상태일 때 주문 패널 하단에 보유 요약을 표시합니다.
|
||||
* @summary UI 흐름: TradeContainer(matchedHolding 계산) -> TradeDashboardContent -> OrderForm -> HoldingInfoPanel 렌더링
|
||||
* @see features/trade/components/TradeContainer.tsx - selectedSymbol 기준으로 보유종목 매칭 값을 전달합니다.
|
||||
*/
|
||||
function HoldingInfoPanel({
|
||||
holding,
|
||||
}: {
|
||||
holding?: DashboardHoldingItem | null;
|
||||
}) {
|
||||
if (!holding) return null;
|
||||
|
||||
const profitToneClass = getHoldingProfitToneClass(holding.profitLoss);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/65 bg-muted/20 p-3 dark:border-brand-700/45 dark:bg-brand-900/28">
|
||||
<p className="mb-2 text-xs font-semibold text-foreground dark:text-brand-50">
|
||||
보유 정보
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
|
||||
<HoldingInfoRow label="보유수량" value={`${holding.quantity.toLocaleString("ko-KR")}주`} />
|
||||
<HoldingInfoRow
|
||||
label="평균단가"
|
||||
value={`${holding.averagePrice.toLocaleString("ko-KR")}원`}
|
||||
/>
|
||||
<HoldingInfoRow
|
||||
label="평가금액"
|
||||
value={`${holding.evaluationAmount.toLocaleString("ko-KR")}원`}
|
||||
/>
|
||||
<HoldingInfoRow
|
||||
label="손익"
|
||||
value={`${holding.profitLoss >= 0 ? "+" : ""}${holding.profitLoss.toLocaleString("ko-KR")}원`}
|
||||
toneClass={profitToneClass}
|
||||
/>
|
||||
<HoldingInfoRow
|
||||
label="수익률"
|
||||
value={`${holding.profitRate >= 0 ? "+" : ""}${holding.profitRate.toFixed(2)}%`}
|
||||
toneClass={profitToneClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 보유정보 카드의 단일 라벨/값 행을 렌더링합니다.
|
||||
* @see features/trade/components/order/OrderForm.tsx HoldingInfoPanel
|
||||
*/
|
||||
function HoldingInfoRow({
|
||||
label,
|
||||
value,
|
||||
toneClass,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
toneClass?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground dark:text-brand-100/70">{label}</span>
|
||||
<span className={cn("truncate font-semibold tabular-nums text-foreground dark:text-brand-50", toneClass)}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 보유 손익 부호에 따른 색상 클래스를 반환합니다.
|
||||
* @see features/trade/components/order/OrderForm.tsx HoldingInfoPanel
|
||||
*/
|
||||
function getHoldingProfitToneClass(value: number) {
|
||||
if (value > 0) return "text-red-500 dark:text-red-400";
|
||||
if (value < 0) return "text-blue-600 dark:text-blue-400";
|
||||
return "text-foreground dark:text-brand-50";
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ interface BookRow {
|
||||
* @description 실시간 호가 레벨 데이터가 실제 값을 포함하는지 검사합니다.
|
||||
* @see features/trade/components/orderbook/OrderBook.tsx levels 계산에서 WS 호가 유효성 판단에 사용합니다.
|
||||
*/
|
||||
function hasOrderBookLevelData(levels: DashboardStockOrderBookResponse["levels"]) {
|
||||
function hasOrderBookLevelData(
|
||||
levels: DashboardStockOrderBookResponse["levels"],
|
||||
) {
|
||||
return levels.some(
|
||||
(level) =>
|
||||
level.askPrice > 0 ||
|
||||
@@ -45,7 +47,9 @@ function hasOrderBookLevelData(levels: DashboardStockOrderBookResponse["levels"]
|
||||
* @description H0STOAA0 미수신 상황에서 체결(H0UNCNT0) 데이터로 최소 1호가를 구성합니다.
|
||||
* @see features/trade/utils/kisRealtimeUtils.ts parseKisRealtimeTickBatch 체결 데이터에서 1호가/잔량/총잔량을 파싱합니다.
|
||||
*/
|
||||
function buildFallbackLevelsFromTick(latestTick: DashboardRealtimeTradeTick | null) {
|
||||
function buildFallbackLevelsFromTick(
|
||||
latestTick: DashboardRealtimeTradeTick | null,
|
||||
) {
|
||||
if (!latestTick) return [] as DashboardStockOrderBookResponse["levels"];
|
||||
if (latestTick.askPrice1 <= 0 && latestTick.bidPrice1 <= 0) {
|
||||
return [] as DashboardStockOrderBookResponse["levels"];
|
||||
@@ -292,6 +296,8 @@ export function OrderBook({
|
||||
|
||||
const askMax = Math.max(1, ...askRows.map((r) => r.size));
|
||||
const bidMax = Math.max(1, ...bidRows.map((r) => r.size));
|
||||
const mobileAskRows = useMemo(() => askRows.slice(0, 6), [askRows]);
|
||||
const mobileBidRows = useMemo(() => bidRows.slice(0, 6), [bidRows]);
|
||||
|
||||
// 스프레드·수급 불균형
|
||||
const bestAsk = levels.find((l) => l.askPrice > 0)?.askPrice ?? 0;
|
||||
@@ -332,10 +338,10 @@ export function OrderBook({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-brand-900/10">
|
||||
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-[linear-gradient(180deg,rgba(13,13,24,0.95),rgba(8,8,18,0.98))]">
|
||||
<Tabs defaultValue="normal" className="h-full min-h-0">
|
||||
{/* 탭 헤더 */}
|
||||
<div className="border-b px-2 pt-2 dark:border-brand-800/45 dark:bg-brand-900/28">
|
||||
<div className="border-b border-border/60 bg-muted/15 px-2 pt-2 dark:border-brand-800/50 dark:bg-brand-950/60">
|
||||
<TabsList variant="line" className="w-full justify-start">
|
||||
<TabsTrigger value="normal" className="px-3">
|
||||
일반호가
|
||||
@@ -351,71 +357,61 @@ export function OrderBook({
|
||||
|
||||
{/* ── 일반호가 탭 ── */}
|
||||
<TabsContent value="normal" className="min-h-0 flex-1">
|
||||
<div className="flex h-full min-h-0 flex-col border-t dark:border-brand-800/45 xl:grid xl:grid-cols-[minmax(0,1fr)_320px_168px] xl:overflow-hidden">
|
||||
<div className="flex h-full min-h-0 flex-col border-t dark:border-brand-800/45 xl:grid xl:grid-cols-[minmax(0,1fr)_220px_220px] 2xl:grid-cols-[minmax(0,1fr)_250px_240px] xl:overflow-hidden">
|
||||
{/* 호가 테이블 */}
|
||||
<div className="flex min-h-0 flex-col xl:border-r dark:border-brand-800/45">
|
||||
{isTickFallbackActive && (
|
||||
<div className="border-b border-amber-200 bg-amber-50 px-2 py-1 text-[11px] text-amber-700 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200">
|
||||
시간외 전용 호가(`H0STOAA0`) 미수신 상태입니다. 체결(`H0UNCNT0`)
|
||||
1호가 기준으로 표시 중입니다.
|
||||
시간외 전용 호가(`H0STOAA0`) 미수신 상태입니다.
|
||||
체결(`H0UNCNT0`) 1호가 기준으로 표시 중입니다.
|
||||
</div>
|
||||
)}
|
||||
<BookHeader />
|
||||
<ScrollArea className="min-h-0 flex-1 [&>[data-slot=scroll-area-scrollbar]]:hidden xl:[&>[data-slot=scroll-area-scrollbar]]:flex">
|
||||
{/* 매도호가 */}
|
||||
<div className="xl:hidden">
|
||||
{/* 모바일: 양방향 호가가 항상 보이도록 6호가씩 고정 노출 */}
|
||||
<BookSideRows rows={mobileAskRows} side="ask" maxSize={askMax} />
|
||||
<CurrentPriceBar
|
||||
latestPrice={latestPrice}
|
||||
basePrice={basePrice}
|
||||
bestAsk={bestAsk}
|
||||
totalAsk={totalAsk}
|
||||
totalBid={totalBid}
|
||||
/>
|
||||
<BookSideRows rows={mobileBidRows} side="bid" maxSize={bidMax} />
|
||||
</div>
|
||||
<ScrollArea className="hidden min-h-0 flex-1 xl:block">
|
||||
{/* 데스크톱: 전체 호가 스크롤 */}
|
||||
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
|
||||
|
||||
{/* 중앙 바: 현재 체결가 */}
|
||||
<div className="grid h-8 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30 xl:h-9">
|
||||
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
|
||||
{totalAsk > 0 ? fmt(totalAsk) : ""}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="text-xs font-bold tabular-nums">
|
||||
{latestPrice > 0
|
||||
? fmt(latestPrice)
|
||||
: bestAsk > 0
|
||||
? fmt(bestAsk)
|
||||
: "-"}
|
||||
</span>
|
||||
{latestPrice > 0 && basePrice > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-medium",
|
||||
latestPrice >= basePrice
|
||||
? "text-red-500"
|
||||
: "text-blue-600 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
{fmtPct(pctChange(latestPrice, basePrice))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2 text-left text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
|
||||
{totalBid > 0 ? fmt(totalBid) : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 매수호가 */}
|
||||
<CurrentPriceBar
|
||||
latestPrice={latestPrice}
|
||||
basePrice={basePrice}
|
||||
bestAsk={bestAsk}
|
||||
totalAsk={totalAsk}
|
||||
totalBid={totalBid}
|
||||
/>
|
||||
<BookSideRows rows={bidRows} side="bid" maxSize={bidMax} />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* 체결 목록: 데스크톱에서는 호가 오른쪽, 모바일에서는 아래 */}
|
||||
<div className="min-h-[220px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0 xl:border-r">
|
||||
<TradeTape ticks={recentTicks} />
|
||||
{/* 체결량 영역 */}
|
||||
<div className="min-h-[180px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0 xl:border-r">
|
||||
<div className="h-full min-h-0">
|
||||
<TradeTape ticks={recentTicks} maxRows={10} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 요약 패널 */}
|
||||
<div className="hidden xl:block min-h-0">
|
||||
<SummaryPanel
|
||||
orderBook={orderBook}
|
||||
latestTick={latestTick}
|
||||
spread={spread}
|
||||
imbalance={imbalance}
|
||||
totalAsk={totalAsk}
|
||||
totalBid={totalBid}
|
||||
/>
|
||||
{/* 실시간 정보 영역 */}
|
||||
<div className="min-h-[180px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0">
|
||||
<div className="h-full min-h-0">
|
||||
<SummaryPanel
|
||||
orderBook={orderBook}
|
||||
latestTick={latestTick}
|
||||
spread={spread}
|
||||
imbalance={imbalance}
|
||||
totalAsk={totalAsk}
|
||||
totalBid={totalBid}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -447,13 +443,75 @@ export function OrderBook({
|
||||
|
||||
// ─── 하위 컴포넌트 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 호가창 중앙의 현재가/등락률/총잔량 바를 렌더링합니다.
|
||||
* @summary UI 흐름: OrderBook -> CurrentPriceBar -> 매도/매수 구간 사이 현재 체결 상태를 강조 표시
|
||||
* @see features/trade/components/orderbook/OrderBook.tsx 일반호가 탭의 모바일/데스크톱 공통 현재가 행
|
||||
*/
|
||||
function CurrentPriceBar({
|
||||
latestPrice,
|
||||
basePrice,
|
||||
bestAsk,
|
||||
totalAsk,
|
||||
totalBid,
|
||||
}: {
|
||||
latestPrice: number;
|
||||
basePrice: number;
|
||||
bestAsk: number;
|
||||
totalAsk: number;
|
||||
totalBid: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid h-10 grid-cols-3 items-center border-y-2 border-amber-400 bg-linear-to-r from-red-50/60 via-amber-50/90 to-blue-50/60 shadow-[0_0_10px_rgba(251,191,36,0.25)] dark:from-red-950/30 dark:via-amber-900/30 dark:to-blue-950/30 xl:h-10">
|
||||
<div className="px-2 text-right text-[10px] font-semibold text-red-600 dark:text-red-400">
|
||||
{totalAsk > 0 ? fmt(totalAsk) : ""}
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
"text-base leading-none font-bold tabular-nums",
|
||||
latestPrice > 0 && basePrice > 0
|
||||
? latestPrice >= basePrice
|
||||
? "text-red-600"
|
||||
: "text-blue-600 dark:text-blue-400"
|
||||
: "text-foreground dark:text-brand-50",
|
||||
)}
|
||||
>
|
||||
{latestPrice > 0 ? fmt(latestPrice) : bestAsk > 0 ? fmt(bestAsk) : "-"}
|
||||
</span>
|
||||
{latestPrice > 0 && basePrice > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-semibold leading-none",
|
||||
latestPrice >= basePrice
|
||||
? "text-red-500"
|
||||
: "text-blue-600 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
{fmtPct(pctChange(latestPrice, basePrice))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2 text-left text-[10px] font-semibold text-blue-600 dark:text-blue-400">
|
||||
{totalBid > 0 ? fmt(totalBid) : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 호가 표 헤더 */
|
||||
function BookHeader() {
|
||||
return (
|
||||
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 text-[11px] font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
|
||||
<div className="flex items-center justify-end px-2">매도잔량</div>
|
||||
<div className="flex items-center justify-center border-x">호가</div>
|
||||
<div className="flex items-center justify-start px-2">매수잔량</div>
|
||||
<div className="grid h-8 grid-cols-3 border-b bg-linear-to-r from-red-50/40 via-muted/20 to-blue-50/40 text-[11px] font-semibold text-muted-foreground dark:border-brand-800/50 dark:from-red-950/30 dark:via-brand-900/40 dark:to-blue-950/30 dark:text-brand-100/80">
|
||||
<div className="flex items-center justify-end px-2 text-red-600/80 dark:text-red-400/80">
|
||||
매도잔량
|
||||
</div>
|
||||
<div className="flex items-center justify-center border-x border-border/50 dark:border-brand-800/40">
|
||||
호가
|
||||
</div>
|
||||
<div className="flex items-center justify-start px-2 text-blue-600/80 dark:text-blue-400/80">
|
||||
매수잔량
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -474,8 +532,8 @@ function BookSideRows({
|
||||
<div
|
||||
className={cn(
|
||||
isAsk
|
||||
? "bg-red-50/20 dark:bg-red-950/18"
|
||||
: "bg-blue-50/55 dark:bg-blue-950/22",
|
||||
? "bg-linear-to-r from-red-50/40 via-red-50/10 to-transparent dark:from-red-950/35 dark:via-red-950/10 dark:to-transparent"
|
||||
: "bg-linear-to-r from-transparent via-blue-50/10 to-blue-50/45 dark:from-transparent dark:via-blue-950/10 dark:to-blue-950/35",
|
||||
)}
|
||||
>
|
||||
{rows.map((row, i) => {
|
||||
@@ -486,9 +544,9 @@ function BookSideRows({
|
||||
<div
|
||||
key={`${side}-${row.price}-${i}`}
|
||||
className={cn(
|
||||
"grid h-7 grid-cols-3 border-b border-border/40 text-[11px] xl:h-8 xl:text-xs dark:border-brand-800/35",
|
||||
"grid h-[29px] grid-cols-3 border-b border-border/30 text-[11px] transition-colors hover:bg-muted/15 xl:h-[29px] xl:text-xs dark:border-brand-800/25 dark:hover:bg-brand-900/20",
|
||||
row.isHighlighted &&
|
||||
"ring-1 ring-inset ring-amber-400 bg-amber-100/50 dark:bg-amber-800/30",
|
||||
"ring-1 ring-inset ring-amber-400 bg-amber-100/60 dark:bg-amber-800/35",
|
||||
)}
|
||||
>
|
||||
{/* 매도잔량 (좌측) */}
|
||||
@@ -520,19 +578,22 @@ function BookSideRows({
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
isAsk ? "text-red-600" : "text-blue-600 dark:text-blue-400"
|
||||
}
|
||||
className={cn(
|
||||
"text-[12px] xl:text-[13px]",
|
||||
isAsk ? "text-red-600" : "text-blue-600 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
{row.price > 0 ? fmt(row.price) : "-"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"w-[58px] shrink-0 text-right text-[10px] tabular-nums",
|
||||
"w-[50px] shrink-0 text-right text-[10px] tabular-nums xl:w-[58px]",
|
||||
getChangeToneClass(row.changeValue),
|
||||
)}
|
||||
>
|
||||
{row.changeValue === null ? "-" : fmtSignedChange(row.changeValue)}
|
||||
{row.changeValue === null
|
||||
? "-"
|
||||
: fmtSignedChange(row.changeValue)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -582,71 +643,80 @@ function SummaryPanel({
|
||||
latestTick?.isExpected && (orderBook?.anticipatedVolume ?? 0) > 0
|
||||
? (orderBook?.anticipatedVolume ?? 0)
|
||||
: (latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0);
|
||||
const summaryItems: SummaryMetric[] = [
|
||||
{
|
||||
label: "실시간",
|
||||
value: orderBook || latestTick ? "연결됨" : "끊김",
|
||||
tone: orderBook || latestTick ? "bid" : undefined,
|
||||
},
|
||||
{ label: "거래량", value: fmt(displayTradeVolume) },
|
||||
{
|
||||
label: "누적거래량",
|
||||
value: fmt(
|
||||
latestTick?.accumulatedVolume ?? orderBook?.accumulatedVolume ?? 0,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "체결강도",
|
||||
value: latestTick
|
||||
? `${latestTick.tradeStrength.toFixed(2)}%`
|
||||
: orderBook?.anticipatedChangeRate !== undefined
|
||||
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
|
||||
: "-",
|
||||
},
|
||||
{ label: "예상체결가", value: fmt(orderBook?.anticipatedPrice ?? 0) },
|
||||
{
|
||||
label: "매도1호가",
|
||||
value: latestTick ? fmt(latestTick.askPrice1) : "-",
|
||||
tone: "ask",
|
||||
},
|
||||
{
|
||||
label: "매수1호가",
|
||||
value: latestTick ? fmt(latestTick.bidPrice1) : "-",
|
||||
tone: "bid",
|
||||
},
|
||||
{
|
||||
label: "순매수체결",
|
||||
value: latestTick ? fmt(latestTick.netBuyExecutionCount) : "-",
|
||||
},
|
||||
{ label: "총 매도잔량", value: fmt(totalAsk), tone: "ask" },
|
||||
{ label: "총 매수잔량", value: fmt(totalBid), tone: "bid" },
|
||||
{ label: "스프레드", value: fmt(spread) },
|
||||
{
|
||||
label: "수급 불균형",
|
||||
value: `${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`,
|
||||
tone: imbalance >= 0 ? "bid" : "ask",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-w-0 border-l bg-muted/10 p-2 text-[11px] dark:border-brand-800/45 dark:bg-brand-900/30">
|
||||
<Row
|
||||
label="실시간"
|
||||
value={orderBook || latestTick ? "연결됨" : "끊김"}
|
||||
tone={orderBook || latestTick ? "bid" : undefined}
|
||||
/>
|
||||
<Row
|
||||
label="거래량"
|
||||
value={fmt(displayTradeVolume)}
|
||||
/>
|
||||
<Row
|
||||
label="누적거래량"
|
||||
value={fmt(
|
||||
latestTick?.accumulatedVolume ?? orderBook?.accumulatedVolume ?? 0,
|
||||
)}
|
||||
/>
|
||||
<Row
|
||||
label="체결강도"
|
||||
value={
|
||||
latestTick
|
||||
? `${latestTick.tradeStrength.toFixed(2)}%`
|
||||
: orderBook?.anticipatedChangeRate !== undefined
|
||||
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
|
||||
: "-"
|
||||
}
|
||||
/>
|
||||
<Row label="예상체결가" value={fmt(orderBook?.anticipatedPrice ?? 0)} />
|
||||
<Row
|
||||
label="매도1호가"
|
||||
value={latestTick ? fmt(latestTick.askPrice1) : "-"}
|
||||
tone="ask"
|
||||
/>
|
||||
<Row
|
||||
label="매수1호가"
|
||||
value={latestTick ? fmt(latestTick.bidPrice1) : "-"}
|
||||
tone="bid"
|
||||
/>
|
||||
<Row
|
||||
label="매수체결"
|
||||
value={latestTick ? fmt(latestTick.buyExecutionCount) : "-"}
|
||||
/>
|
||||
<Row
|
||||
label="매도체결"
|
||||
value={latestTick ? fmt(latestTick.sellExecutionCount) : "-"}
|
||||
/>
|
||||
<Row
|
||||
label="순매수체결"
|
||||
value={latestTick ? fmt(latestTick.netBuyExecutionCount) : "-"}
|
||||
/>
|
||||
<Row label="총 매도잔량" value={fmt(totalAsk)} tone="ask" />
|
||||
<Row label="총 매수잔량" value={fmt(totalBid)} tone="bid" />
|
||||
<Row label="스프레드" value={fmt(spread)} />
|
||||
<Row
|
||||
label="수급 불균형"
|
||||
value={`${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`}
|
||||
tone={imbalance >= 0 ? "bid" : "ask"}
|
||||
/>
|
||||
<div className="h-full min-w-0 bg-muted/10 p-2 text-xs dark:bg-brand-900/30">
|
||||
<div className="grid grid-cols-2 gap-1 xl:h-full xl:grid-cols-1 xl:grid-rows-12">
|
||||
{summaryItems.map((item) => (
|
||||
<SummaryMetricCell
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
value={item.value}
|
||||
tone={item.tone}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 요약 패널 단일 행 */
|
||||
function Row({
|
||||
interface SummaryMetric {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "ask" | "bid";
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 실시간 요약 패널의 2열 메트릭 셀을 렌더링합니다.
|
||||
* @summary UI 흐름: SummaryPanel -> SummaryMetricCell -> 체결량 하단 정보 패널을 압축해 스크롤 없이 표시
|
||||
* @see features/trade/components/orderbook/OrderBook.tsx SummaryPanel summaryItems
|
||||
*/
|
||||
function SummaryMetricCell({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
@@ -656,13 +726,13 @@ function Row({
|
||||
tone?: "ask" | "bid";
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2 rounded border bg-background px-2 py-1 dark:border-brand-800/45 dark:bg-black/20">
|
||||
<span className="min-w-0 truncate text-muted-foreground dark:text-brand-100/70">
|
||||
<div className="flex h-full min-w-0 items-center justify-between gap-2 rounded border bg-background px-2 py-1 dark:border-brand-800/45 dark:bg-black/20">
|
||||
<span className="truncate text-[11px] text-muted-foreground dark:text-brand-100/70">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 font-medium tabular-nums",
|
||||
"shrink-0 text-xs font-semibold tabular-nums",
|
||||
tone === "ask" && "text-red-600",
|
||||
tone === "bid" && "text-blue-600 dark:text-blue-400",
|
||||
)}
|
||||
@@ -679,10 +749,10 @@ function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-1 z-0 rounded-sm",
|
||||
"absolute inset-y-0.5 z-0 rounded-sm transition-[width] duration-150",
|
||||
side === "ask"
|
||||
? "right-1 bg-red-200/50 dark:bg-red-800/40"
|
||||
: "left-1 bg-blue-200/55 dark:bg-blue-500/35",
|
||||
? "right-0.5 bg-red-300/55 dark:bg-red-700/50"
|
||||
: "left-0.5 bg-blue-300/60 dark:bg-blue-600/45",
|
||||
)}
|
||||
style={{ width: `${ratio}%` }}
|
||||
/>
|
||||
@@ -690,65 +760,79 @@ function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
||||
}
|
||||
|
||||
/** 체결 목록 (Trade Tape) */
|
||||
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
|
||||
function TradeTape({
|
||||
ticks,
|
||||
maxRows,
|
||||
}: {
|
||||
ticks: DashboardRealtimeTradeTick[];
|
||||
maxRows?: number;
|
||||
}) {
|
||||
const visibleTicks = typeof maxRows === "number" ? ticks.slice(0, maxRows) : ticks;
|
||||
const shouldUseScrollableList = typeof maxRows !== "number";
|
||||
|
||||
const tapeRows = (
|
||||
<div>
|
||||
{visibleTicks.length === 0 && (
|
||||
<div className="flex min-h-[96px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70 xl:min-h-[140px]">
|
||||
체결 데이터가 아직 없습니다.
|
||||
</div>
|
||||
)}
|
||||
{visibleTicks.map((t, i) => {
|
||||
const olderTick = visibleTicks[i + 1];
|
||||
const executionSide = resolveTickExecutionSide(t, olderTick);
|
||||
// UI 흐름: 체결목록 UI -> resolveTickExecutionSide(현재/이전 틱) -> 색상 class 결정 -> 체결량 렌더 반영
|
||||
const volumeToneClass =
|
||||
executionSide === "buy"
|
||||
? "text-red-600"
|
||||
: executionSide === "sell"
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-muted-foreground dark:text-brand-100/70";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${t.tickTime}-${t.price}-${i}`}
|
||||
className="grid h-7 grid-cols-3 border-b border-border/40 px-2 text-[13px] dark:border-brand-800/35"
|
||||
>
|
||||
<div className="flex items-center tabular-nums">
|
||||
{fmtTime(t.tickTime)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-end tabular-nums",
|
||||
getChangeToneClass(
|
||||
t.change,
|
||||
"text-foreground dark:text-brand-50",
|
||||
),
|
||||
)}
|
||||
>
|
||||
{fmt(t.price)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-end tabular-nums",
|
||||
volumeToneClass,
|
||||
)}
|
||||
>
|
||||
{fmt(t.tradeVolume)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background dark:bg-brand-900/20">
|
||||
<div className="grid h-7 grid-cols-4 border-b bg-muted/20 px-2 text-[11px] font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
|
||||
<div className="flex min-h-0 flex-col bg-background dark:bg-brand-900/20">
|
||||
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 px-2 text-xs font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
|
||||
<div className="flex items-center">체결시각</div>
|
||||
<div className="flex items-center justify-end">체결가</div>
|
||||
<div className="flex items-center justify-end">체결량</div>
|
||||
<div className="flex items-center justify-end">체결강도</div>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div>
|
||||
{ticks.length === 0 && (
|
||||
<div className="flex min-h-[160px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70">
|
||||
체결 데이터가 아직 없습니다.
|
||||
</div>
|
||||
)}
|
||||
{ticks.map((t, i) => {
|
||||
const olderTick = ticks[i + 1];
|
||||
const executionSide = resolveTickExecutionSide(t, olderTick);
|
||||
// UI 흐름: 체결목록 UI -> resolveTickExecutionSide(현재/이전 틱) -> 색상 class 결정 -> 체결량 렌더 반영
|
||||
const volumeToneClass =
|
||||
executionSide === "buy"
|
||||
? "text-red-600"
|
||||
: executionSide === "sell"
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-muted-foreground dark:text-brand-100/70";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${t.tickTime}-${t.price}-${i}`}
|
||||
className="grid h-8 grid-cols-4 border-b border-border/40 px-2 text-xs dark:border-brand-800/35"
|
||||
>
|
||||
<div className="flex items-center tabular-nums">
|
||||
{fmtTime(t.tickTime)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-end tabular-nums",
|
||||
getChangeToneClass(t.change, "text-foreground dark:text-brand-50"),
|
||||
)}
|
||||
>
|
||||
{fmt(t.price)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-end tabular-nums",
|
||||
volumeToneClass,
|
||||
)}
|
||||
>
|
||||
{fmt(t.tradeVolume)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end tabular-nums">
|
||||
{t.tradeStrength.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{shouldUseScrollableList ? (
|
||||
<ScrollArea className="min-h-0 flex-1">{tapeRows}</ScrollArea>
|
||||
) : (
|
||||
tapeRows
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function StockSearchForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="flex gap-2">
|
||||
<form onSubmit={onSubmit} className="flex items-center gap-2">
|
||||
{/* ========== SEARCH INPUT ========== */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground dark:text-brand-100/65" />
|
||||
@@ -39,9 +39,9 @@ export function StockSearchForm({
|
||||
value={keyword}
|
||||
onChange={(e) => onKeywordChange(e.target.value)}
|
||||
onFocus={onInputFocus}
|
||||
placeholder="종목명 또는 종목코드(6자리)를 입력하세요."
|
||||
placeholder="종목명 또는 코드 검색"
|
||||
autoComplete="off"
|
||||
className="pl-9 pr-9 dark:border-brand-700/55 dark:bg-brand-900/28 dark:text-brand-100 dark:placeholder:text-brand-100/55"
|
||||
className="h-9 pl-9 pr-9 dark:border-brand-700/55 dark:bg-brand-900/28 dark:text-brand-100 dark:placeholder:text-brand-100/55"
|
||||
/>
|
||||
{keyword && (
|
||||
<button
|
||||
@@ -57,7 +57,11 @@ export function StockSearchForm({
|
||||
</div>
|
||||
|
||||
{/* ========== SUBMIT BUTTON ========== */}
|
||||
<Button type="submit" disabled={disabled || isLoading}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disabled || isLoading}
|
||||
className="h-9 px-2.5 text-xs sm:px-3 sm:text-sm"
|
||||
>
|
||||
{isLoading ? "검색 중..." : "검색"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -3,16 +3,22 @@ import { StockSearchForm } from "@/features/trade/components/search/StockSearchF
|
||||
import { StockSearchHistory } from "@/features/trade/components/search/StockSearchHistory";
|
||||
import { StockSearchResults } from "@/features/trade/components/search/StockSearchResults";
|
||||
import type {
|
||||
DashboardStockItem,
|
||||
DashboardStockSearchHistoryItem,
|
||||
DashboardStockSearchItem,
|
||||
} from "@/features/trade/types/trade.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TradeSearchSectionProps {
|
||||
canSearch: boolean;
|
||||
isSearchPanelOpen: boolean;
|
||||
isSearching: boolean;
|
||||
keyword: string;
|
||||
selectedStock: DashboardStockItem | null;
|
||||
selectedSymbol?: string;
|
||||
currentPrice?: number;
|
||||
change?: number;
|
||||
changeRate?: number;
|
||||
searchResults: DashboardStockSearchItem[];
|
||||
searchHistory: DashboardStockSearchHistoryItem[];
|
||||
searchShellRef: MutableRefObject<HTMLDivElement | null>;
|
||||
@@ -27,16 +33,20 @@ interface TradeSearchSectionProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 트레이드 화면 상단의 검색 입력/결과/히스토리 드롭다운 영역을 렌더링합니다.
|
||||
* @see features/trade/components/TradeContainer.tsx TradeContainer에서 검색 섹션을 분리해 렌더 복잡도를 줄입니다.
|
||||
* @see features/trade/hooks/useTradeSearchPanel.ts 패널 열림/닫힘 및 포커스 핸들러를 전달받습니다.
|
||||
* @description 트레이드 화면 상단의 검색 입력/결과/종목 요약 통합 영역을 렌더링합니다.
|
||||
* @summary UI 흐름: TradeContainer -> TradeSearchSection -> (검색 입력/선택) + (선택 종목 실시간 요약) 반영
|
||||
* @see features/trade/components/TradeContainer.tsx - 검색 상태/선택 종목 실시간 데이터를 전달합니다.
|
||||
*/
|
||||
export function TradeSearchSection({
|
||||
canSearch,
|
||||
isSearchPanelOpen,
|
||||
isSearching,
|
||||
keyword,
|
||||
selectedStock,
|
||||
selectedSymbol,
|
||||
currentPrice,
|
||||
change,
|
||||
changeRate,
|
||||
searchResults,
|
||||
searchHistory,
|
||||
searchShellRef,
|
||||
@@ -50,52 +60,176 @@ export function TradeSearchSection({
|
||||
onClearHistory,
|
||||
}: TradeSearchSectionProps) {
|
||||
return (
|
||||
<div className="z-30 flex-none border-b bg-background/95 px-3 py-2 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22 sm:px-4">
|
||||
{/* ========== SEARCH SHELL ========== */}
|
||||
<div
|
||||
ref={searchShellRef}
|
||||
onBlurCapture={onSearchShellBlur}
|
||||
onKeyDownCapture={onSearchShellKeyDown}
|
||||
className="relative mx-auto max-w-2xl"
|
||||
>
|
||||
<StockSearchForm
|
||||
keyword={keyword}
|
||||
onKeywordChange={onKeywordChange}
|
||||
onSubmit={onSearchSubmit}
|
||||
onInputFocus={onSearchFocus}
|
||||
disabled={!canSearch}
|
||||
isLoading={isSearching}
|
||||
/>
|
||||
<div className="z-30 flex-none border-b bg-background/95 px-3 py-1 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22 sm:px-4">
|
||||
{/* ========== TOP BAR (검색 + 종목 요약 통합) ========== */}
|
||||
<div className="mx-auto flex max-w-[1800px] items-center gap-2">
|
||||
{/* ========== SEARCH SHELL ========== */}
|
||||
<div
|
||||
ref={searchShellRef}
|
||||
onBlurCapture={onSearchShellBlur}
|
||||
onKeyDownCapture={onSearchShellKeyDown}
|
||||
className="relative min-w-0 flex-1 md:max-w-[480px]"
|
||||
>
|
||||
<StockSearchForm
|
||||
keyword={keyword}
|
||||
onKeywordChange={onKeywordChange}
|
||||
onSubmit={onSearchSubmit}
|
||||
onInputFocus={onSearchFocus}
|
||||
disabled={!canSearch}
|
||||
isLoading={isSearching}
|
||||
/>
|
||||
|
||||
{/* ========== SEARCH DROPDOWN ========== */}
|
||||
{isSearchPanelOpen && canSearch && (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-x-hidden overflow-y-auto rounded-md border bg-background shadow-lg dark:border-brand-800/45 dark:bg-brand-950/95">
|
||||
{searchResults.length > 0 ? (
|
||||
<StockSearchResults
|
||||
items={searchResults}
|
||||
onSelect={onSelectStock}
|
||||
selectedSymbol={selectedSymbol}
|
||||
/>
|
||||
) : keyword.trim() ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
{isSearching ? "검색 중..." : "검색 결과가 없습니다."}
|
||||
</div>
|
||||
) : searchHistory.length > 0 ? (
|
||||
<StockSearchHistory
|
||||
items={searchHistory}
|
||||
onSelect={onSelectStock}
|
||||
onRemove={onRemoveHistory}
|
||||
onClear={onClearHistory}
|
||||
selectedSymbol={selectedSymbol}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
최근 검색 종목이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* ========== SEARCH DROPDOWN ========== */}
|
||||
{isSearchPanelOpen && canSearch && (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-x-hidden overflow-y-auto rounded-md border bg-background shadow-lg dark:border-brand-800/45 dark:bg-brand-950/95">
|
||||
{searchResults.length > 0 ? (
|
||||
<StockSearchResults
|
||||
items={searchResults}
|
||||
onSelect={onSelectStock}
|
||||
selectedSymbol={selectedSymbol}
|
||||
/>
|
||||
) : keyword.trim() ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
{isSearching ? "검색 중..." : "검색 결과가 없습니다."}
|
||||
</div>
|
||||
) : searchHistory.length > 0 ? (
|
||||
<StockSearchHistory
|
||||
items={searchHistory}
|
||||
onSelect={onSelectStock}
|
||||
onRemove={onRemoveHistory}
|
||||
onClear={onClearHistory}
|
||||
selectedSymbol={selectedSymbol}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
최근 검색 종목이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<InlineStockSummary
|
||||
stock={selectedStock}
|
||||
currentPrice={currentPrice}
|
||||
change={change}
|
||||
changeRate={changeRate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 검색창 우측의 선택 종목/보유 종목 요약 배지를 렌더링합니다.
|
||||
* @see features/trade/components/search/TradeSearchSection.tsx - 상단 1줄 통합 바에서 사용합니다.
|
||||
*/
|
||||
function InlineStockSummary({
|
||||
stock,
|
||||
currentPrice,
|
||||
change,
|
||||
changeRate,
|
||||
}: {
|
||||
stock: DashboardStockItem | null;
|
||||
currentPrice?: number;
|
||||
change?: number;
|
||||
changeRate?: number;
|
||||
}) {
|
||||
if (!stock) {
|
||||
return (
|
||||
<div className="hidden min-w-0 flex-1 items-center justify-end md:flex">
|
||||
<div className="rounded-md border border-dashed border-border/80 px-3 py-1 text-xs text-muted-foreground dark:border-brand-800/45 dark:text-brand-100/65">
|
||||
종목을 선택하면 현재가/보유손익이 여기에 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayPrice = currentPrice ?? stock.currentPrice;
|
||||
const displayChange = change ?? stock.change;
|
||||
const displayChangeRate = changeRate ?? stock.changeRate;
|
||||
const isRise = displayChangeRate > 0;
|
||||
const isFall = displayChangeRate < 0;
|
||||
const priceToneClass = isRise
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: isFall
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-foreground dark:text-brand-50";
|
||||
|
||||
return (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-end gap-2 overflow-hidden rounded-lg border border-brand-200/50 bg-white/70 px-2 py-1 dark:border-brand-700/45 dark:bg-brand-900/30">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-semibold text-foreground dark:text-brand-50">
|
||||
{stock.name}
|
||||
</p>
|
||||
<p className="truncate text-[10px] text-muted-foreground dark:text-brand-100/65">
|
||||
{stock.symbol} · {stock.market}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l border-border/65 pl-2 text-right dark:border-brand-700/45">
|
||||
<p className={cn("text-sm font-bold tabular-nums", priceToneClass)}>
|
||||
{displayPrice.toLocaleString("ko-KR")}
|
||||
</p>
|
||||
<p className={cn("text-[10px] tabular-nums", priceToneClass)}>
|
||||
{isRise ? "+" : ""}
|
||||
{displayChange.toLocaleString("ko-KR")} (
|
||||
{isRise ? "+" : ""}
|
||||
{displayChangeRate.toFixed(2)}%)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-2 border-l border-border/65 pl-2 dark:border-brand-700/45 xl:flex">
|
||||
<CompactMetric
|
||||
label="고"
|
||||
value={stock.high.toLocaleString("ko-KR")}
|
||||
tone="ask"
|
||||
/>
|
||||
<CompactMetric
|
||||
label="저"
|
||||
value={stock.low.toLocaleString("ko-KR")}
|
||||
tone="bid"
|
||||
/>
|
||||
<CompactMetric
|
||||
label="거래량"
|
||||
value={stock.volume.toLocaleString("ko-KR")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 검색 헤더 1줄 안에서 시세 핵심 값(고가/저가/거래량)을 표시하는 칩입니다.
|
||||
* @summary UI 흐름: InlineStockSummary -> CompactMetric -> 종목 핵심 지표를 축약 표기
|
||||
* @see features/trade/components/search/TradeSearchSection.tsx - 상단 통합 헤더의 우측 지표 영역
|
||||
*/
|
||||
function CompactMetric({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "ask" | "bid";
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/35 px-2 py-1 dark:bg-brand-900/25">
|
||||
<p className="text-[10px] text-muted-foreground dark:text-brand-100/70">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"max-w-[120px] truncate text-[11px] font-semibold tabular-nums",
|
||||
tone === "ask" && "text-red-600 dark:text-red-400",
|
||||
tone === "bid" && "text-blue-600 dark:text-blue-400",
|
||||
!tone && "text-foreground dark:text-brand-50",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ export function useOrderbookSubscription({
|
||||
marketSession,
|
||||
onOrderBookMessage,
|
||||
}: UseOrderbookSubscriptionParams) {
|
||||
const { subscribe, connect } = useKisWebSocketStore();
|
||||
const subscribeRef = useRef(useKisWebSocketStore.getState().subscribe);
|
||||
const connectRef = useRef(useKisWebSocketStore.getState().connect);
|
||||
const onOrderBookMessageRef = useRef(onOrderBookMessage);
|
||||
const activeOrderBookTrIdRef = useRef<string | null>(null);
|
||||
const activeOrderBookTrUpdatedAtRef = useRef(0);
|
||||
@@ -47,7 +48,7 @@ export function useOrderbookSubscription({
|
||||
useEffect(() => {
|
||||
if (!symbol || !isVerified || !credentials) return;
|
||||
|
||||
connect();
|
||||
connectRef.current();
|
||||
|
||||
const trIds = resolveOrderBookTrIds(
|
||||
credentials.tradingEnv,
|
||||
@@ -83,7 +84,9 @@ export function useOrderbookSubscription({
|
||||
};
|
||||
|
||||
for (const trId of trIds) {
|
||||
unsubscribers.push(subscribe(trId, symbol, handleOrderBookMessage));
|
||||
unsubscribers.push(
|
||||
subscribeRef.current(trId, symbol, handleOrderBookMessage),
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -91,5 +94,5 @@ export function useOrderbookSubscription({
|
||||
activeOrderBookTrIdRef.current = null;
|
||||
activeOrderBookTrUpdatedAtRef.current = 0;
|
||||
};
|
||||
}, [symbol, market, isVerified, credentials, marketSession, connect, subscribe]);
|
||||
}, [symbol, market, isVerified, credentials, marketSession]);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ export function useTradeTickSubscription({
|
||||
const activeTradeTrIdRef = useRef<string | null>(null);
|
||||
const activeTradeTrUpdatedAtRef = useRef(0);
|
||||
|
||||
const { subscribe, connect } = useKisWebSocketStore();
|
||||
const subscribeRef = useRef(useKisWebSocketStore.getState().subscribe);
|
||||
const connectRef = useRef(useKisWebSocketStore.getState().connect);
|
||||
const onTickRef = useRef(onTick);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -73,7 +74,7 @@ export function useTradeTickSubscription({
|
||||
useEffect(() => {
|
||||
if (!symbol || !isVerified || !credentials) return;
|
||||
|
||||
connect();
|
||||
connectRef.current();
|
||||
|
||||
const trIds = resolveTradeTrIds(credentials.tradingEnv, marketSession);
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
@@ -148,13 +149,15 @@ export function useTradeTickSubscription({
|
||||
};
|
||||
|
||||
for (const trId of trIds) {
|
||||
unsubscribers.push(subscribe(trId, symbol, handleTradeMessage));
|
||||
unsubscribers.push(
|
||||
subscribeRef.current(trId, symbol, handleTradeMessage),
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribers.forEach((unsub) => unsub());
|
||||
};
|
||||
}, [symbol, isVerified, credentials, marketSession, connect, subscribe]);
|
||||
}, [symbol, isVerified, credentials, marketSession]);
|
||||
|
||||
return { latestTick, recentTradeTicks, lastTickAt };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user