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

This commit is contained in:
2026-02-24 15:43:56 +09:00
parent 19ebb1c6ea
commit c53f79a86f
16 changed files with 1553 additions and 450 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>
);