"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 { 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 { 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 hasInitializedAuthPanelRef = useRef(false); // 모바일에서는 초기 진입 시 API 키 패널을 접어서 본문(차트/호가)을 먼저 보이게 합니다. const [isMobileViewport, setIsMobileViewport] = useState(false); const [isAuthPanelExpanded, setIsAuthPanelExpanded] = useState(true); 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(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, }); 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 (!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]); 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 (
{/* ========== AUTH STATUS ========== */}
KIS API 연결 상태: {isKisVerified ? ( 연결됨 ( {verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"}) ) : ( 미연결 )}
{/* ========== SEARCH ========== */}
{searchResults.length > 0 && (
)}
{/* ========== MAIN CONTENT ========== */}
) : null } chart={ selectedStock ? (
) : (
차트 영역
) } orderBook={ } orderForm={} />
); }