테마 적용

This commit is contained in:
2026-02-11 14:06:06 +09:00
parent def87bd47a
commit 95291e6922
30 changed files with 1209 additions and 496 deletions

View File

@@ -8,6 +8,7 @@ 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";
@@ -20,7 +21,6 @@ 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";
@@ -28,16 +28,18 @@ import type {
/**
* @description 대시보드 메인 컨테이너
* @see app/(main)/dashboard/page.tsx 로그인 완료 후 이 컴포넌트를 렌더링합니다.
* @see features/dashboard/hooks/useStockSearch.ts 검색 입력/요청 상태를 관리합니다.
* @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<HTMLDivElement | null>(null);
// 모바일에서는 초기 진입 시 API 패널을 접어 본문(차트/호가)을 먼저 보이게 합니다.
// 모바일에서는 초기 진입 시 API 패널을 접어 본문(차트/호가)을 먼저 보이게 합니다.
const [isMobileViewport, setIsMobileViewport] = useState(false);
const [isAuthPanelExpanded, setIsAuthPanelExpanded] = useState(true);
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
const { verifiedCredentials, isKisVerified } = useKisRuntimeStore(
useShallow((state) => ({
@@ -50,11 +52,14 @@ export function DashboardContainer() {
keyword,
setKeyword,
searchResults,
setSearchResults,
setError: setSearchError,
setSearchError,
isSearching,
search,
clearSearch,
searchHistory,
appendSearchHistory,
removeSearchHistory,
clearSearchHistory,
} = useStockSearch();
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
@@ -107,6 +112,49 @@ export function DashboardContainer() {
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<HTMLDivElement>) => {
const nextTarget = event.relatedTarget as Node | null;
if (nextTarget && searchShellRef.current?.contains(nextTarget)) return;
closeSearchPanel();
},
[closeSearchPanel],
);
const handleSearchShellKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Escape") return;
closeSearchPanel();
(event.target as HTMLElement | null)?.blur?.();
},
[closeSearchPanel],
);
useEffect(() => {
const mediaQuery = window.matchMedia("(max-width: 767px)");
@@ -142,7 +190,7 @@ export function DashboardContainer() {
return;
}
if (!isKisVerified || !verifiedCredentials) {
if (!canSearch) {
clearSearch();
return;
}
@@ -158,52 +206,72 @@ export function DashboardContainer() {
}, 220);
return () => window.clearTimeout(timer);
}, [keyword, isKisVerified, verifiedCredentials, search, clearSearch]);
}, [canSearch, keyword, verifiedCredentials, search, clearSearch]);
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault();
if (!isKisVerified || !verifiedCredentials) {
setSearchError("API 키 검증을 먼저 완료해 주세요.");
return;
}
search(keyword, verifiedCredentials);
}
/**
* @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],
);
function handleSelectStock(item: DashboardStockSearchItem) {
if (!isKisVerified || !verifiedCredentials) {
setSearchError("API 키 검증을 먼저 완료해 주세요.");
return;
}
/**
* @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;
// 이미 선택된 종목을 다시 누른 경우 불필요한 개요 API 재호출을 막습니다.
if (selectedStock?.symbol === item.symbol) {
setSearchResults([]);
return;
}
// 같은 종목 재선택 시 중복 overview 요청을 막고 검색 목록만 닫습니다.
if (selectedStock?.symbol === item.symbol) {
clearSearch();
closeSearchPanel();
return;
}
// 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 돌지 않도록 1회 건너뜁니다.
skipNextAutoSearchRef.current = true;
setKeyword(item.name);
setSearchResults([]);
loadOverview(item.symbol, verifiedCredentials, item.market);
}
// 카드 선택으로 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 (
<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-none border-b bg-muted/40 transition-all duration-300 ease-in-out dark:border-brand-800/45 dark:bg-brand-900/28">
<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 className="flex items-center font-medium text-brand-700 dark:text-brand-200">
<span className="mr-1.5 h-2 w-2 rounded-full bg-brand-500 ring-2 ring-brand-100 dark:ring-brand-900" />
({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 className="mr-1.5 h-2 w-2 rounded-full bg-brand-200 dark:bg-brand-500/60" />
</span>
)}
@@ -216,8 +284,10 @@ export function DashboardContainer() {
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",
"border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 dark:border-brand-700/60 dark:bg-brand-900/30 dark:text-brand-200 dark:hover:bg-brand-900/45",
!isAuthPanelExpanded &&
isMobileViewport &&
"ring-2 ring-brand-200 dark:ring-brand-600/60",
)}
>
{isAuthPanelExpanded ? (
@@ -237,36 +307,57 @@ export function DashboardContainer() {
<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",
isAuthPanelExpanded ? "max-h-[560px] opacity-100" : "max-h-0 opacity-0",
)}
>
<div className="p-4 border-t bg-background">
<div className="border-t bg-background p-4 dark:border-brand-800/45 dark:bg-brand-900/14">
<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">
<div className="z-30 flex-none border-b bg-background/95 p-4 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22">
<div
ref={searchShellRef}
onBlurCapture={handleSearchShellBlur}
onKeyDownCapture={handleSearchShellKeyDown}
className="relative mx-auto max-w-2xl"
>
<StockSearchForm
keyword={keyword}
onKeywordChange={setKeyword}
onSubmit={handleSearchSubmit}
disabled={!isKisVerified}
onInputFocus={openSearchPanel}
disabled={!canSearch}
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
}
/>
{isSearchPanelOpen && canSearch && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-x-hidden overflow-y-auto rounded-md border bg-background shadow-lg dark:border-brand-800/45 dark:bg-brand-950/95">
{searchResults.length > 0 ? (
<StockSearchResults
items={searchResults}
onSelect={handleSelectStock}
selectedSymbol={selectedStock?.symbol}
/>
) : keyword.trim() ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
{isSearching ? "검색 중..." : "검색 결과가 없습니다."}
</div>
) : searchHistory.length > 0 ? (
<StockSearchHistory
items={searchHistory}
onSelect={handleSelectStock}
onRemove={removeSearchHistory}
onClear={clearSearchHistory}
selectedSymbol={selectedStock?.symbol}
/>
) : (
<div className="px-3 py-2 text-sm text-muted-foreground">
.
</div>
)}
</div>
)}
</div>
@@ -287,7 +378,7 @@ export function DashboardContainer() {
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
high={latestTick ? latestTick.high.toLocaleString() : undefined}
low={latestTick ? latestTick.low.toLocaleString() : undefined}
volume={
latestTick