Files
auto-trade/features/dashboard/components/DashboardContainer.tsx

280 lines
9.8 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { cn } from "@/lib/utils";
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
import { KisAuthForm } from "@/features/dashboard/components/auth/KisAuthForm";
import { StockSearchForm } from "@/features/dashboard/components/search/StockSearchForm";
import { StockSearchResults } from "@/features/dashboard/components/search/StockSearchResults";
import { useStockSearch } from "@/features/dashboard/hooks/useStockSearch";
import { useOrderBook } from "@/features/dashboard/hooks/useOrderBook";
import { useKisTradeWebSocket } from "@/features/dashboard/hooks/useKisTradeWebSocket";
import { useStockOverview } from "@/features/dashboard/hooks/useStockOverview";
import { DashboardLayout } from "@/features/dashboard/components/layout/DashboardLayout";
import { StockHeader } from "@/features/dashboard/components/header/StockHeader";
import { OrderBook } from "@/features/dashboard/components/orderbook/OrderBook";
import { OrderForm } from "@/features/dashboard/components/order/OrderForm";
import { StockLineChart } from "@/features/dashboard/components/chart/StockLineChart";
import type {
DashboardStockItem,
DashboardStockOrderBookResponse,
DashboardStockSearchItem,
} from "@/features/dashboard/types/dashboard.types";
/**
* @description 대시보드 메인 컨테이너
* @see app/(main)/dashboard/page.tsx 로그인 완료 후 이 컴포넌트를 렌더링합니다.
* @see features/dashboard/hooks/useStockSearch.ts 검색 입력/요청 상태를 관리합니다.
* @see features/dashboard/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
*/
export function DashboardContainer() {
const skipNextAutoSearchRef = useRef(false);
const { verifiedCredentials, isKisVerified } = useKisRuntimeStore(
useShallow((state) => ({
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
})),
);
const {
keyword,
setKeyword,
searchResults,
setSearchResults,
setError: setSearchError,
isSearching,
search,
clearSearch,
} = useStockSearch();
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
useStockOverview();
// 호가 실시간 데이터 (체결 WS에서 동일 소켓으로 수신)
const [realtimeOrderBook, setRealtimeOrderBook] =
useState<DashboardStockOrderBookResponse | null>(null);
const handleOrderBookMessage = useCallback(
(data: DashboardStockOrderBookResponse) => {
setRealtimeOrderBook(data);
},
[],
);
// 1. Trade WebSocket (체결 + 호가 통합)
const { latestTick, realtimeCandles, recentTradeTicks } =
useKisTradeWebSocket(
selectedStock?.symbol,
verifiedCredentials,
isKisVerified,
updateRealtimeTradeTick,
{
orderBookSymbol: selectedStock?.symbol,
onOrderBookMessage: handleOrderBookMessage,
},
);
// 2. OrderBook (REST 초기 조회 + WS 실시간 병합)
const { orderBook, isLoading: isOrderBookLoading } = useOrderBook(
selectedStock?.symbol,
selectedStock?.market,
verifiedCredentials,
isKisVerified,
{
enabled: !!selectedStock && !!verifiedCredentials && isKisVerified,
externalRealtimeOrderBook: realtimeOrderBook,
},
);
useEffect(() => {
if (skipNextAutoSearchRef.current) {
skipNextAutoSearchRef.current = false;
return;
}
if (!isKisVerified || !verifiedCredentials) {
clearSearch();
return;
}
const trimmed = keyword.trim();
if (!trimmed) {
clearSearch();
return;
}
const timer = window.setTimeout(() => {
search(trimmed, verifiedCredentials);
}, 220);
return () => window.clearTimeout(timer);
}, [keyword, isKisVerified, verifiedCredentials, search, clearSearch]);
// Price Calculation Logic
// Prioritize latestTick (Real Exec) > OrderBook Ask1 (Proxy) > REST Data
let currentPrice = selectedStock?.currentPrice;
let change = selectedStock?.change;
let changeRate = selectedStock?.changeRate;
if (latestTick) {
currentPrice = latestTick.price;
change = latestTick.change;
changeRate = latestTick.changeRate;
} else if (orderBook?.levels[0]?.askPrice) {
// Fallback: Use Best Ask Price as proxy for current price
const askPrice = orderBook.levels[0].askPrice;
if (askPrice > 0) {
currentPrice = askPrice;
// Recalculate change/rate based on prevClose
if (selectedStock && selectedStock.prevClose > 0) {
change = currentPrice - selectedStock.prevClose;
changeRate = (change / selectedStock.prevClose) * 100;
}
}
}
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault();
if (!isKisVerified || !verifiedCredentials) {
setSearchError("API 키 검증을 먼저 완료해 주세요.");
return;
}
search(keyword, verifiedCredentials);
}
function handleSelectStock(item: DashboardStockSearchItem) {
if (!isKisVerified || !verifiedCredentials) {
setSearchError("API 키 검증을 먼저 완료해 주세요.");
return;
}
// 이미 선택된 종목을 다시 누른 경우 불필요한 개요 API 재호출을 막습니다.
if (selectedStock?.symbol === item.symbol) {
setSearchResults([]);
return;
}
// 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 돌지 않도록 1회 건너뜁니다.
skipNextAutoSearchRef.current = true;
setKeyword(item.name);
setSearchResults([]);
loadOverview(item.symbol, verifiedCredentials, item.market);
}
return (
<div className="relative h-full flex flex-col">
{/* ========== AUTH STATUS ========== */}
<div className="flex-none border-b bg-muted/40 transition-all duration-300 ease-in-out">
<div className="flex items-center justify-between px-4 py-2 text-xs">
<div className="flex items-center gap-2">
<span className="font-semibold">KIS API :</span>
{isKisVerified ? (
<span className="text-green-600 font-medium flex items-center">
<span className="mr-1.5 h-2 w-2 rounded-full bg-green-500 ring-2 ring-green-100" />
(
{verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})
</span>
) : (
<span className="text-muted-foreground flex items-center">
<span className="mr-1.5 h-2 w-2 rounded-full bg-gray-300" />
</span>
)}
</div>
</div>
<div className="overflow-hidden transition-[max-height,opacity] duration-300 ease-in-out max-h-[500px] opacity-100">
<div className="p-4 border-t bg-background">
<KisAuthForm />
</div>
</div>
</div>
{/* ========== SEARCH ========== */}
<div className="flex-none p-4 border-b bg-background/95 backdrop-blur-sm z-30">
<div className="max-w-2xl mx-auto space-y-2 relative">
<StockSearchForm
keyword={keyword}
onKeywordChange={setKeyword}
onSubmit={handleSearchSubmit}
disabled={!isKisVerified}
isLoading={isSearching}
/>
{searchResults.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 z-50 bg-background border rounded-md shadow-lg max-h-80 overflow-y-auto overflow-x-hidden">
<StockSearchResults
items={searchResults}
onSelect={handleSelectStock}
selectedSymbol={
(selectedStock as DashboardStockItem | null)?.symbol
}
/>
</div>
)}
</div>
</div>
{/* ========== MAIN CONTENT ========== */}
<div
className={cn(
"transition-opacity duration-200 h-full flex-1 min-h-0 overflow-x-hidden",
!selectedStock && "opacity-20 pointer-events-none",
)}
>
<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} // High/Low/Vol only from Tick or Static
low={latestTick ? latestTick.low.toLocaleString() : undefined}
volume={
latestTick
? latestTick.accumulatedVolume.toLocaleString()
: undefined
}
/>
) : null
}
chart={
selectedStock ? (
<div className="p-0 h-full flex flex-col">
<StockLineChart
symbol={selectedStock.symbol}
candles={
realtimeCandles.length > 0
? realtimeCandles
: selectedStock.candles
}
credentials={verifiedCredentials}
/>
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
</div>
)
}
orderBook={
<OrderBook
symbol={selectedStock?.symbol}
referencePrice={selectedStock?.prevClose}
currentPrice={currentPrice}
latestTick={latestTick}
recentTicks={recentTradeTicks}
orderBook={orderBook}
isLoading={isOrderBookLoading}
/>
}
orderForm={<OrderForm stock={selectedStock ?? undefined} />}
/>
</div>
</div>
);
}