"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { ChevronDown, ChevronUp } from "lucide-react"; import { useShallow } from "zustand/react/shallow"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; 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 { StockSearchHistory } from "@/features/dashboard/components/search/StockSearchHistory"; 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 { useCurrentPrice } from "@/features/dashboard/hooks/useCurrentPrice"; 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 { 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 hasInitializedAuthPanelRef = useRef(false); const searchShellRef = useRef(null); // 모바일에서는 초기 진입 시 API 패널을 접어 본문(차트/호가)을 먼저 보이게 합니다. const [isMobileViewport, setIsMobileViewport] = useState(false); const [isAuthPanelExpanded, setIsAuthPanelExpanded] = useState(true); const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false); const { verifiedCredentials, isKisVerified } = useKisRuntimeStore( useShallow((state) => ({ verifiedCredentials: state.verifiedCredentials, isKisVerified: state.isKisVerified, })), ); const { keyword, setKeyword, searchResults, setSearchError, isSearching, search, clearSearch, searchHistory, appendSearchHistory, removeSearchHistory, clearSearchHistory, } = useStockSearch(); const { selectedStock, loadOverview, updateRealtimeTradeTick } = useStockOverview(); // 호가 실시간 데이터 (체결 WS에서 동일 소켓으로 수신) const [realtimeOrderBook, setRealtimeOrderBook] = useState(null); const handleOrderBookMessage = useCallback( (data: DashboardStockOrderBookResponse) => { setRealtimeOrderBook(data); }, [], ); // 1. Trade WebSocket (체결 + 호가 통합) const { latestTick, 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, }, ); // 3. Price Calculation Logic (Hook) const { currentPrice, change, changeRate, prevClose: referencePrice, } = useCurrentPrice({ stock: selectedStock, latestTick, orderBook, }); const canSearch = isKisVerified && !!verifiedCredentials; /** * @description 검색 전 API 인증 여부를 확인합니다. * @see features/dashboard/components/search/StockSearchForm.tsx 검색 제출 전 공통 가드로 사용합니다. */ const ensureSearchReady = useCallback(() => { if (canSearch) return true; setSearchError("API 키 검증을 먼저 완료해 주세요."); return false; }, [canSearch, setSearchError]); const closeSearchPanel = useCallback(() => { setIsSearchPanelOpen(false); }, []); const openSearchPanel = useCallback(() => { if (!canSearch) return; setIsSearchPanelOpen(true); }, [canSearch]); /** * @description 검색 영역 포커스가 완전히 빠지면 드롭다운(검색결과/히스토리)을 닫습니다. * @see features/dashboard/components/search/StockSearchForm.tsx 입력 포커스 이벤트에서 열림 제어를 함께 사용합니다. */ const handleSearchShellBlur = useCallback( (event: React.FocusEvent) => { const nextTarget = event.relatedTarget as Node | null; if (nextTarget && searchShellRef.current?.contains(nextTarget)) return; closeSearchPanel(); }, [closeSearchPanel], ); const handleSearchShellKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key !== "Escape") return; closeSearchPanel(); (event.target as HTMLElement | null)?.blur?.(); }, [closeSearchPanel], ); useEffect(() => { const mediaQuery = window.matchMedia("(max-width: 767px)"); const applyViewportMode = (matches: boolean) => { setIsMobileViewport(matches); // 최초 1회: 모바일이면 접힘, 데스크탑이면 펼침. if (!hasInitializedAuthPanelRef.current) { setIsAuthPanelExpanded(!matches); hasInitializedAuthPanelRef.current = true; return; } // 데스크탑으로 돌아오면 항상 펼쳐 사용성을 유지합니다. if (!matches) { setIsAuthPanelExpanded(true); } }; applyViewportMode(mediaQuery.matches); const onViewportChange = (event: MediaQueryListEvent) => { applyViewportMode(event.matches); }; mediaQuery.addEventListener("change", onViewportChange); return () => mediaQuery.removeEventListener("change", onViewportChange); }, []); useEffect(() => { if (skipNextAutoSearchRef.current) { skipNextAutoSearchRef.current = false; return; } if (!canSearch) { clearSearch(); return; } const trimmed = keyword.trim(); if (!trimmed) { clearSearch(); return; } const timer = window.setTimeout(() => { search(trimmed, verifiedCredentials); }, 220); return () => window.clearTimeout(timer); }, [canSearch, keyword, verifiedCredentials, search, clearSearch]); /** * @description 수동 검색 버튼(엔터 포함) 제출 이벤트를 처리합니다. * @see features/dashboard/components/search/StockSearchForm.tsx onSubmit 이벤트로 호출됩니다. */ const handleSearchSubmit = useCallback( (event: React.FormEvent) => { event.preventDefault(); if (!ensureSearchReady() || !verifiedCredentials) return; search(keyword, verifiedCredentials); }, [ensureSearchReady, keyword, search, verifiedCredentials], ); /** * @description 검색 결과/히스토리에서 종목을 선택하면 종목 상세를 로드하고 히스토리를 갱신합니다. * @see features/dashboard/components/search/StockSearchResults.tsx onSelect 이벤트 * @see features/dashboard/components/search/StockSearchHistory.tsx onSelect 이벤트 */ const handleSelectStock = useCallback( (item: DashboardStockSearchItem) => { if (!ensureSearchReady() || !verifiedCredentials) return; // 같은 종목 재선택 시 중복 overview 요청을 막고 검색 목록만 닫습니다. if (selectedStock?.symbol === item.symbol) { clearSearch(); closeSearchPanel(); return; } // 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 실행되지 않게 한 번 건너뜁니다. skipNextAutoSearchRef.current = true; setKeyword(item.name); clearSearch(); closeSearchPanel(); appendSearchHistory(item); loadOverview(item.symbol, verifiedCredentials, item.market); }, [ ensureSearchReady, verifiedCredentials, selectedStock?.symbol, clearSearch, closeSearchPanel, setKeyword, appendSearchHistory, loadOverview, ], ); return (
{/* ========== AUTH STATUS ========== */}
KIS API 연결 상태: {isKisVerified ? ( 연결됨 ({verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"}) ) : ( 미연결 )}
{/* ========== SEARCH ========== */}
{isSearchPanelOpen && canSearch && (
{searchResults.length > 0 ? ( ) : keyword.trim() ? (
{isSearching ? "검색 중..." : "검색 결과가 없습니다."}
) : searchHistory.length > 0 ? ( ) : (
최근 검색 종목이 없습니다.
)}
)}
{/* ========== MAIN CONTENT ========== */}
) : null } chart={ selectedStock ? (
) : (
차트 영역
) } orderBook={ } orderForm={} />
); }