트레이딩창 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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user