337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
|
|
"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<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,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
// 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 (
|
||
|
|
<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 gap-2 px-3 py-2 text-xs sm:px-4">
|
||
|
|
<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>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
size="sm"
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => setIsAuthPanelExpanded((prev) => !prev)}
|
||
|
|
className={cn(
|
||
|
|
"h-8 shrink-0 gap-1.5 px-2.5 text-[11px] font-semibold",
|
||
|
|
"border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100",
|
||
|
|
!isAuthPanelExpanded && isMobileViewport && "ring-2 ring-brand-200",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{isAuthPanelExpanded ? (
|
||
|
|
<>
|
||
|
|
<ChevronUp className="h-3.5 w-3.5" />
|
||
|
|
API 설정 접기
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<ChevronDown className="h-3.5 w-3.5" />
|
||
|
|
API 설정 펼치기
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"overflow-hidden transition-[max-height,opacity] duration-300 ease-in-out",
|
||
|
|
isAuthPanelExpanded
|
||
|
|
? "max-h-[560px] opacity-100"
|
||
|
|
: "max-h-0 opacity-0",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<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={referencePrice}
|
||
|
|
currentPrice={currentPrice}
|
||
|
|
latestTick={latestTick}
|
||
|
|
recentTicks={recentTradeTicks}
|
||
|
|
orderBook={orderBook}
|
||
|
|
isLoading={isOrderBookLoading}
|
||
|
|
/>
|
||
|
|
}
|
||
|
|
orderForm={<OrderForm stock={selectedStock ?? undefined} />}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|