트레이딩창 UI 배치 및 UX 수정 및 기획서 추가

This commit is contained in:
2026-02-24 15:43:56 +09:00
parent 19ebb1c6ea
commit a16af8ad7d
16 changed files with 1615 additions and 479 deletions

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>
);
}

View 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)}&nbsp;(
{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>
);
}

View File

@@ -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>

View File

@@ -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)}
/>

View File

@@ -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";
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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]);
}

View File

@@ -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 };
}