대시보드 중간 커밋
This commit is contained in:
279
features/dashboard/components/DashboardContainer.tsx
Normal file
279
features/dashboard/components/DashboardContainer.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
230
features/dashboard/components/auth/KisAuthForm.tsx
Normal file
230
features/dashboard/components/auth/KisAuthForm.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useState, useTransition } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import {
|
||||
revokeKisCredentials,
|
||||
validateKisCredentials,
|
||||
} from "@/features/dashboard/apis/kis-auth.api";
|
||||
|
||||
/**
|
||||
* @description KIS 인증 입력 폼
|
||||
* @see features/dashboard/store/use-kis-runtime-store.ts 인증 입력값/검증 상태를 저장합니다.
|
||||
*/
|
||||
export function KisAuthForm() {
|
||||
const {
|
||||
kisTradingEnvInput,
|
||||
kisAppKeyInput,
|
||||
kisAppSecretInput,
|
||||
verifiedCredentials,
|
||||
isKisVerified,
|
||||
setKisTradingEnvInput,
|
||||
setKisAppKeyInput,
|
||||
setKisAppSecretInput,
|
||||
setVerifiedKisSession,
|
||||
invalidateKisVerification,
|
||||
clearKisRuntimeSession,
|
||||
} = useKisRuntimeStore(
|
||||
useShallow((state) => ({
|
||||
kisTradingEnvInput: state.kisTradingEnvInput,
|
||||
kisAppKeyInput: state.kisAppKeyInput,
|
||||
kisAppSecretInput: state.kisAppSecretInput,
|
||||
verifiedCredentials: state.verifiedCredentials,
|
||||
isKisVerified: state.isKisVerified,
|
||||
setKisTradingEnvInput: state.setKisTradingEnvInput,
|
||||
setKisAppKeyInput: state.setKisAppKeyInput,
|
||||
setKisAppSecretInput: state.setKisAppSecretInput,
|
||||
setVerifiedKisSession: state.setVerifiedKisSession,
|
||||
invalidateKisVerification: state.invalidateKisVerification,
|
||||
clearKisRuntimeSession: state.clearKisRuntimeSession,
|
||||
})),
|
||||
);
|
||||
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isValidating, startValidateTransition] = useTransition();
|
||||
const [isRevoking, startRevokeTransition] = useTransition();
|
||||
|
||||
function handleValidate() {
|
||||
startValidateTransition(async () => {
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
setStatusMessage(null);
|
||||
|
||||
const appKey = kisAppKeyInput.trim();
|
||||
const appSecret = kisAppSecretInput.trim();
|
||||
|
||||
if (!appKey || !appSecret) {
|
||||
throw new Error("App Key와 App Secret을 모두 입력해 주세요.");
|
||||
}
|
||||
|
||||
// 주문 기능에서 계좌번호가 필요할 수 있어 구조는 유지하되, 인증 단계에서는 입력받지 않습니다.
|
||||
const credentials = {
|
||||
appKey,
|
||||
appSecret,
|
||||
tradingEnv: kisTradingEnvInput,
|
||||
accountNo: verifiedCredentials?.accountNo ?? "",
|
||||
};
|
||||
|
||||
const result = await validateKisCredentials(credentials);
|
||||
setVerifiedKisSession(credentials, result.tradingEnv);
|
||||
setStatusMessage(
|
||||
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"})`,
|
||||
);
|
||||
} catch (err) {
|
||||
invalidateKisVerification();
|
||||
setErrorMessage(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "API 키 검증 중 오류가 발생했습니다.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleRevoke() {
|
||||
if (!verifiedCredentials) return;
|
||||
|
||||
startRevokeTransition(async () => {
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
setStatusMessage(null);
|
||||
const result = await revokeKisCredentials(verifiedCredentials);
|
||||
clearKisRuntimeSession(result.tradingEnv);
|
||||
setStatusMessage(
|
||||
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"})`,
|
||||
);
|
||||
} catch (err) {
|
||||
setErrorMessage(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "연결 해제 중 오류가 발생했습니다.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-brand-200 bg-linear-to-r from-brand-50/60 to-background">
|
||||
<CardHeader>
|
||||
<CardTitle>KIS API 키 연결</CardTitle>
|
||||
<CardDescription>
|
||||
대시보드 사용 전, 개인 API 키를 입력하고 검증해 주세요.
|
||||
검증에 성공해야 시세 조회가 동작합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* ========== CREDENTIAL INPUTS ========== */}
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">
|
||||
거래 모드
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={kisTradingEnvInput === "real" ? "default" : "outline"}
|
||||
className={cn(
|
||||
"flex-1",
|
||||
kisTradingEnvInput === "real"
|
||||
? "bg-brand-600 hover:bg-brand-700"
|
||||
: "",
|
||||
)}
|
||||
onClick={() => setKisTradingEnvInput("real")}
|
||||
>
|
||||
실전
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={kisTradingEnvInput === "mock" ? "default" : "outline"}
|
||||
className={cn(
|
||||
"flex-1",
|
||||
kisTradingEnvInput === "mock"
|
||||
? "bg-brand-600 hover:bg-brand-700"
|
||||
: "",
|
||||
)}
|
||||
onClick={() => setKisTradingEnvInput("mock")}
|
||||
>
|
||||
모의
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">
|
||||
KIS App Key
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={kisAppKeyInput}
|
||||
onChange={(e) => setKisAppKeyInput(e.target.value)}
|
||||
placeholder="App Key 입력"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">
|
||||
KIS App Secret
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={kisAppSecretInput}
|
||||
onChange={(e) => setKisAppSecretInput(e.target.value)}
|
||||
placeholder="App Secret 입력"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== ACTIONS ========== */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleValidate}
|
||||
disabled={
|
||||
isValidating || !kisAppKeyInput.trim() || !kisAppSecretInput.trim()
|
||||
}
|
||||
className="bg-brand-600 hover:bg-brand-700"
|
||||
>
|
||||
{isValidating ? "검증 중..." : "API 키 검증"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRevoke}
|
||||
disabled={isRevoking || !isKisVerified || !verifiedCredentials}
|
||||
className="border-brand-200 text-brand-700 hover:bg-brand-50 hover:text-brand-800"
|
||||
>
|
||||
{isRevoking ? "해제 중..." : "연결 끊기"}
|
||||
</Button>
|
||||
|
||||
{isKisVerified ? (
|
||||
<span className="flex items-center text-sm font-medium text-green-600">
|
||||
<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-sm text-muted-foreground">미연결</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="text-sm font-medium text-destructive">{errorMessage}</div>
|
||||
)}
|
||||
{statusMessage && <div className="text-sm text-blue-600">{statusMessage}</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
636
features/dashboard/components/chart/StockLineChart.tsx
Normal file
636
features/dashboard/components/chart/StockLineChart.tsx
Normal file
@@ -0,0 +1,636 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CandlestickSeries,
|
||||
ColorType,
|
||||
HistogramSeries,
|
||||
createChart,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
type Time,
|
||||
type UTCTimestamp,
|
||||
} from "lightweight-charts";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchStockChart } from "@/features/dashboard/apis/kis-stock.api";
|
||||
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardChartTimeframe,
|
||||
StockCandlePoint,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
|
||||
const UP_COLOR = "#ef4444";
|
||||
const DOWN_COLOR = "#2563eb";
|
||||
|
||||
// 분봉 드롭다운 옵션
|
||||
const MINUTE_TIMEFRAMES: Array<{
|
||||
value: DashboardChartTimeframe;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "1m", label: "1분" },
|
||||
{ value: "30m", label: "30분" },
|
||||
{ value: "1h", label: "1시간" },
|
||||
];
|
||||
|
||||
// 일봉 이상 개별 버튼
|
||||
const PERIOD_TIMEFRAMES: Array<{
|
||||
value: DashboardChartTimeframe;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "1d", label: "일" },
|
||||
{ value: "1w", label: "주" },
|
||||
];
|
||||
|
||||
type ChartBar = {
|
||||
time: UTCTimestamp;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
};
|
||||
|
||||
interface StockLineChartProps {
|
||||
symbol?: string;
|
||||
candles: StockCandlePoint[];
|
||||
credentials?: KisRuntimeCredentials | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description TradingView 스타일 캔들 차트 + 거래량 + 무한 과거 로딩
|
||||
* @see https://tradingview.github.io/lightweight-charts/tutorials/demos/realtime-updates
|
||||
* @see https://tradingview.github.io/lightweight-charts/tutorials/demos/infinite-history
|
||||
*/
|
||||
export function StockLineChart({
|
||||
symbol,
|
||||
candles,
|
||||
credentials,
|
||||
}: StockLineChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const candleSeriesRef = useRef<ISeriesApi<"Candlestick", Time> | null>(null);
|
||||
const volumeSeriesRef = useRef<ISeriesApi<"Histogram", Time> | null>(null);
|
||||
|
||||
const [timeframe, setTimeframe] = useState<DashboardChartTimeframe>("1d");
|
||||
const [isMinuteDropdownOpen, setIsMinuteDropdownOpen] = useState(false);
|
||||
const [bars, setBars] = useState<ChartBar[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [isChartReady, setIsChartReady] = useState(false);
|
||||
const lastRealtimeKeyRef = useRef<string>("");
|
||||
const loadingMoreRef = useRef(false);
|
||||
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
|
||||
const initialLoadCompleteRef = useRef(false);
|
||||
|
||||
// candles prop을 ref로 관리하여 useEffect 디펜던시에서 제거 (무한 페칭 방지)
|
||||
const latestCandlesRef = useRef(candles);
|
||||
useEffect(() => {
|
||||
latestCandlesRef.current = candles;
|
||||
}, [candles]);
|
||||
|
||||
const latest = bars.at(-1);
|
||||
const prevClose = bars.length > 1 ? (bars.at(-2)?.close ?? 0) : 0;
|
||||
const change = latest ? latest.close - prevClose : 0;
|
||||
const changeRate = prevClose > 0 ? (change / prevClose) * 100 : 0;
|
||||
const renderableBars = useMemo(() => {
|
||||
const dedup = new Map<number, ChartBar>();
|
||||
for (const bar of bars) {
|
||||
if (
|
||||
!Number.isFinite(bar.time) ||
|
||||
!Number.isFinite(bar.open) ||
|
||||
!Number.isFinite(bar.high) ||
|
||||
!Number.isFinite(bar.low) ||
|
||||
!Number.isFinite(bar.close) ||
|
||||
bar.close <= 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
dedup.set(bar.time, bar);
|
||||
}
|
||||
return [...dedup.values()].sort((a, b) => a.time - b.time);
|
||||
}, [bars]);
|
||||
|
||||
const setSeriesData = useCallback((nextBars: ChartBar[]) => {
|
||||
const candleSeries = candleSeriesRef.current;
|
||||
const volumeSeries = volumeSeriesRef.current;
|
||||
if (!candleSeries || !volumeSeries) return;
|
||||
|
||||
// lightweight-charts는 시간 오름차순/유효 숫자 조건이 깨지면 렌더를 멈출 수 있어
|
||||
// 전달 직전 데이터를 한 번 더 정리합니다.
|
||||
const safeBars = nextBars;
|
||||
|
||||
try {
|
||||
candleSeries.setData(
|
||||
safeBars.map((bar) => ({
|
||||
time: bar.time,
|
||||
open: bar.open,
|
||||
high: bar.high,
|
||||
low: bar.low,
|
||||
close: bar.close,
|
||||
})),
|
||||
);
|
||||
|
||||
volumeSeries.setData(
|
||||
safeBars.map((bar) => ({
|
||||
time: bar.time,
|
||||
value: Number.isFinite(bar.volume) ? bar.volume : 0,
|
||||
color:
|
||||
bar.close >= bar.open
|
||||
? "rgba(239,68,68,0.45)"
|
||||
: "rgba(37,99,235,0.45)",
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to render chart series data:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current)
|
||||
return;
|
||||
|
||||
loadingMoreRef.current = true;
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const response = await fetchStockChart(
|
||||
symbol,
|
||||
timeframe,
|
||||
credentials,
|
||||
nextCursor,
|
||||
);
|
||||
const older = normalizeCandles(response.candles, timeframe);
|
||||
setBars((prev) => mergeBars(older, prev));
|
||||
setNextCursor(response.hasMore ? response.nextCursor : null);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "과거 차트 조회에 실패했습니다.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
loadingMoreRef.current = false;
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [credentials, nextCursor, symbol, timeframe]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMoreHandlerRef.current = handleLoadMore;
|
||||
}, [handleLoadMore]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || chartRef.current) return;
|
||||
|
||||
const initialWidth = Math.max(container.clientWidth, 320);
|
||||
const initialHeight = Math.max(container.clientHeight, 340);
|
||||
|
||||
const chart = createChart(container, {
|
||||
width: initialWidth,
|
||||
height: initialHeight,
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: "#ffffff" },
|
||||
textColor: "#475569",
|
||||
attributionLogo: true,
|
||||
},
|
||||
localization: {
|
||||
locale: "ko-KR",
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: "#e2e8f0",
|
||||
scaleMargins: {
|
||||
top: 0.08,
|
||||
bottom: 0.24,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: "#edf1f5" },
|
||||
horzLines: { color: "#edf1f5" },
|
||||
},
|
||||
crosshair: {
|
||||
vertLine: { color: "#94a3b8", width: 1, style: 2 },
|
||||
horzLine: { color: "#94a3b8", width: 1, style: 2 },
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: "#e2e8f0",
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
rightOffset: 2,
|
||||
},
|
||||
handleScroll: {
|
||||
mouseWheel: true,
|
||||
pressedMouseMove: true,
|
||||
},
|
||||
handleScale: {
|
||||
mouseWheel: true,
|
||||
pinch: true,
|
||||
axisPressedMouseMove: true,
|
||||
},
|
||||
});
|
||||
|
||||
const candleSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: UP_COLOR,
|
||||
downColor: DOWN_COLOR,
|
||||
wickUpColor: UP_COLOR,
|
||||
wickDownColor: DOWN_COLOR,
|
||||
borderUpColor: UP_COLOR,
|
||||
borderDownColor: DOWN_COLOR,
|
||||
priceLineVisible: true,
|
||||
lastValueVisible: true,
|
||||
});
|
||||
|
||||
const volumeSeries = chart.addSeries(HistogramSeries, {
|
||||
priceScaleId: "volume",
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
base: 0,
|
||||
});
|
||||
|
||||
chart.priceScale("volume").applyOptions({
|
||||
scaleMargins: {
|
||||
top: 0.78,
|
||||
bottom: 0,
|
||||
},
|
||||
borderVisible: false,
|
||||
});
|
||||
|
||||
// 스크롤 디바운스 타이머
|
||||
let scrollTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
|
||||
if (!range) return;
|
||||
|
||||
// 초기 로딩 완료 후에만 무한 스크롤 트리거
|
||||
// range.from이 0에 가까워지면(과거 데이터 필요) 로딩
|
||||
if (range.from < 10 && initialLoadCompleteRef.current) {
|
||||
if (scrollTimeout) clearTimeout(scrollTimeout);
|
||||
|
||||
scrollTimeout = setTimeout(() => {
|
||||
void loadMoreHandlerRef.current();
|
||||
}, 300); // 300ms 디바운스
|
||||
}
|
||||
});
|
||||
|
||||
chartRef.current = chart;
|
||||
candleSeriesRef.current = candleSeries;
|
||||
volumeSeriesRef.current = volumeSeries;
|
||||
setIsChartReady(true);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const nextWidth = Math.max(container.clientWidth, 320);
|
||||
const nextHeight = Math.max(container.clientHeight, 340);
|
||||
chart.resize(nextWidth, nextHeight);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// 첫 렌더 직후 부모 레이아웃 계산이 끝난 시점에 한 번 더 사이즈를 맞춥니다.
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
const nextWidth = Math.max(container.clientWidth, 320);
|
||||
const nextHeight = Math.max(container.clientHeight, 340);
|
||||
chart.resize(nextWidth, nextHeight);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
resizeObserver.disconnect();
|
||||
chart.remove();
|
||||
chartRef.current = null;
|
||||
candleSeriesRef.current = null;
|
||||
volumeSeriesRef.current = null;
|
||||
setIsChartReady(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (symbol && credentials) return;
|
||||
setBars(normalizeCandles(candles, "1d"));
|
||||
setNextCursor(null);
|
||||
}, [candles, credentials, symbol]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!symbol || !credentials) return;
|
||||
|
||||
// 초기 로딩 보호 플래그 초기화 (타임프레임/종목 변경 시)
|
||||
initialLoadCompleteRef.current = false;
|
||||
|
||||
let disposed = false;
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetchStockChart(symbol, timeframe, credentials);
|
||||
if (disposed) return;
|
||||
|
||||
const normalized = normalizeCandles(response.candles, timeframe);
|
||||
setBars(normalized);
|
||||
setNextCursor(response.hasMore ? response.nextCursor : null);
|
||||
|
||||
// 초기 로딩 완료 후 500ms 지연 후 무한 스크롤 활성화
|
||||
// (fitContent 후 range가 0이 되어 즉시 트리거되는 것 방지)
|
||||
setTimeout(() => {
|
||||
if (!disposed) {
|
||||
initialLoadCompleteRef.current = true;
|
||||
}
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
const message =
|
||||
error instanceof Error ? error.message : "차트 조회에 실패했습니다.";
|
||||
toast.error(message);
|
||||
// 에러 발생 시 fallback으로 props로 전달된 candles 사용
|
||||
setBars(normalizeCandles(latestCandlesRef.current, timeframe));
|
||||
setNextCursor(null);
|
||||
} finally {
|
||||
if (!disposed) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [credentials, symbol, timeframe]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isChartReady) return;
|
||||
setSeriesData(renderableBars);
|
||||
|
||||
// 초기 로딩 시에만 fitContent 수행
|
||||
if (!initialLoadCompleteRef.current && renderableBars.length > 0) {
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
}
|
||||
}, [isChartReady, renderableBars, setSeriesData]);
|
||||
|
||||
const latestRealtime = useMemo(() => candles.at(-1), [candles]);
|
||||
useEffect(() => {
|
||||
if (!latestRealtime || bars.length === 0) return;
|
||||
if (timeframe === "1w" && !latestRealtime.timestamp && !latestRealtime.time)
|
||||
return;
|
||||
|
||||
const key = `${latestRealtime.time}-${latestRealtime.price}-${latestRealtime.volume ?? 0}`;
|
||||
if (lastRealtimeKeyRef.current === key) return;
|
||||
lastRealtimeKeyRef.current = key;
|
||||
|
||||
const nextBar = convertRealtimePointToBar(latestRealtime, timeframe);
|
||||
if (!nextBar) return;
|
||||
|
||||
setBars((prev) => upsertRealtimeBar(prev, nextBar));
|
||||
}, [bars.length, candles, latestRealtime, timeframe]);
|
||||
|
||||
// 상태 메시지 결정 (오버레이로 표시)
|
||||
const statusMessage = (() => {
|
||||
if (isLoading && bars.length === 0)
|
||||
return "차트 데이터를 불러오는 중입니다.";
|
||||
if (bars.length === 0) return "차트 데이터가 없습니다.";
|
||||
if (renderableBars.length === 0)
|
||||
return "차트 데이터 형식이 올바르지 않아 렌더링할 수 없습니다.";
|
||||
return null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[340px] flex-col bg-white">
|
||||
{/* ========== CHART TOOLBAR ========== */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-2 py-2 sm:px-3">
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
|
||||
{/* 분봉 드롭다운 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMinuteDropdownOpen((v) => !v)}
|
||||
onBlur={() =>
|
||||
setTimeout(() => setIsMinuteDropdownOpen(false), 200)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded px-2 py-1 text-slate-600 transition-colors hover:bg-slate-100",
|
||||
MINUTE_TIMEFRAMES.some((t) => t.value === timeframe) &&
|
||||
"bg-brand-100 font-semibold text-brand-700",
|
||||
)}
|
||||
>
|
||||
{MINUTE_TIMEFRAMES.find((t) => t.value === timeframe)?.label ??
|
||||
"분봉"}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
{isMinuteDropdownOpen && (
|
||||
<div className="absolute left-0 top-full z-10 mt-1 rounded border border-slate-200 bg-white shadow-lg">
|
||||
{MINUTE_TIMEFRAMES.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTimeframe(item.value);
|
||||
setIsMinuteDropdownOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"block w-full whitespace-nowrap px-3 py-1.5 text-left hover:bg-slate-100",
|
||||
timeframe === item.value &&
|
||||
"bg-brand-50 font-semibold text-brand-700",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 일/주 버튼 */}
|
||||
{PERIOD_TIMEFRAMES.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => setTimeframe(item.value)}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-slate-600 transition-colors hover:bg-slate-100",
|
||||
timeframe === item.value &&
|
||||
"bg-brand-100 font-semibold text-brand-700",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
{isLoadingMore && (
|
||||
<span className="ml-2 text-[11px] text-muted-foreground">
|
||||
과거 데이터 로딩중...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-600 sm:text-xs">
|
||||
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)}{" "}
|
||||
L {formatPrice(latest?.low ?? 0)} C{" "}
|
||||
<span className={cn(change >= 0 ? "text-red-600" : "text-blue-600")}>
|
||||
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== CHART BODY ========== */}
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
{/* 차트 캔버스 컨테이너 - 항상 렌더링 */}
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
|
||||
{/* 상태 메시지 오버레이 */}
|
||||
{statusMessage && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 text-sm text-muted-foreground">
|
||||
{statusMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCandles(
|
||||
candles: StockCandlePoint[],
|
||||
timeframe: DashboardChartTimeframe,
|
||||
) {
|
||||
const rows = candles
|
||||
.map((item) => convertCandleToBar(item, timeframe))
|
||||
.filter((item): item is ChartBar => Boolean(item));
|
||||
return mergeBars([], rows);
|
||||
}
|
||||
|
||||
function convertCandleToBar(
|
||||
candle: StockCandlePoint,
|
||||
timeframe: DashboardChartTimeframe,
|
||||
): ChartBar | null {
|
||||
const close = candle.close ?? candle.price;
|
||||
if (!Number.isFinite(close) || close <= 0) return null;
|
||||
|
||||
const open = candle.open ?? close;
|
||||
const high = candle.high ?? Math.max(open, close);
|
||||
const low = candle.low ?? Math.min(open, close);
|
||||
const volume = candle.volume ?? 0;
|
||||
const time = resolveBarTimestamp(candle, timeframe);
|
||||
if (!time) return null;
|
||||
|
||||
return {
|
||||
time,
|
||||
open,
|
||||
high: Math.max(high, open, close),
|
||||
low: Math.min(low, open, close),
|
||||
close,
|
||||
volume,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBarTimestamp(
|
||||
candle: StockCandlePoint,
|
||||
timeframe: DashboardChartTimeframe,
|
||||
): UTCTimestamp | null {
|
||||
if (
|
||||
typeof candle.timestamp === "number" &&
|
||||
Number.isFinite(candle.timestamp)
|
||||
) {
|
||||
return adjustTimestampForTimeframe(candle.timestamp, timeframe);
|
||||
}
|
||||
|
||||
const timeText = typeof candle.time === "string" ? candle.time.trim() : "";
|
||||
if (!timeText) return null;
|
||||
|
||||
if (/^\d{2}\/\d{2}$/.test(timeText)) {
|
||||
const [mm, dd] = timeText.split("/");
|
||||
const year = new Date().getFullYear();
|
||||
const d = new Date(`${year}-${mm}-${dd}T09:00:00+09:00`);
|
||||
return adjustTimestampForTimeframe(
|
||||
Math.floor(d.getTime() / 1000),
|
||||
timeframe,
|
||||
);
|
||||
}
|
||||
|
||||
if (/^\d{2}:\d{2}(:\d{2})?$/.test(timeText)) {
|
||||
const [hh, mi, ss] = timeText.split(":");
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = `${now.getMonth() + 1}`.padStart(2, "0");
|
||||
const d = `${now.getDate()}`.padStart(2, "0");
|
||||
const ts = new Date(
|
||||
`${y}-${m}-${d}T${hh}:${mi}:${ss ?? "00"}+09:00`,
|
||||
).getTime();
|
||||
return adjustTimestampForTimeframe(Math.floor(ts / 1000), timeframe);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function adjustTimestampForTimeframe(
|
||||
timestamp: number,
|
||||
timeframe: DashboardChartTimeframe,
|
||||
): UTCTimestamp {
|
||||
const date = new Date(timestamp * 1000);
|
||||
if (timeframe === "30m" || timeframe === "1h") {
|
||||
const bucketMinutes = timeframe === "30m" ? 30 : 60;
|
||||
const mins = date.getUTCMinutes();
|
||||
const aligned = Math.floor(mins / bucketMinutes) * bucketMinutes;
|
||||
date.setUTCMinutes(aligned, 0, 0);
|
||||
} else if (timeframe === "1d") {
|
||||
date.setUTCHours(0, 0, 0, 0);
|
||||
} else if (timeframe === "1w") {
|
||||
const day = date.getUTCDay();
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
date.setUTCDate(date.getUTCDate() + diff);
|
||||
date.setUTCHours(0, 0, 0, 0);
|
||||
}
|
||||
return Math.floor(date.getTime() / 1000) as UTCTimestamp;
|
||||
}
|
||||
|
||||
function mergeBars(left: ChartBar[], right: ChartBar[]) {
|
||||
const map = new Map<number, ChartBar>();
|
||||
for (const bar of [...left, ...right]) {
|
||||
const prev = map.get(bar.time);
|
||||
if (!prev) {
|
||||
map.set(bar.time, bar);
|
||||
continue;
|
||||
}
|
||||
|
||||
map.set(bar.time, {
|
||||
time: bar.time,
|
||||
open: prev.open,
|
||||
high: Math.max(prev.high, bar.high),
|
||||
low: Math.min(prev.low, bar.low),
|
||||
close: bar.close,
|
||||
volume: Math.max(prev.volume, bar.volume),
|
||||
});
|
||||
}
|
||||
|
||||
return [...map.values()].sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
function convertRealtimePointToBar(
|
||||
point: StockCandlePoint,
|
||||
timeframe: DashboardChartTimeframe,
|
||||
) {
|
||||
return convertCandleToBar(point, timeframe);
|
||||
}
|
||||
|
||||
function upsertRealtimeBar(prev: ChartBar[], incoming: ChartBar) {
|
||||
if (prev.length === 0) return [incoming];
|
||||
const last = prev[prev.length - 1];
|
||||
if (incoming.time > last.time) {
|
||||
return [...prev, incoming];
|
||||
}
|
||||
if (incoming.time < last.time) return prev;
|
||||
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
time: last.time,
|
||||
open: last.open,
|
||||
high: Math.max(last.high, incoming.high),
|
||||
low: Math.min(last.low, incoming.low),
|
||||
close: incoming.close,
|
||||
volume: Math.max(last.volume, incoming.volume),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function formatPrice(value: number) {
|
||||
return KRW_FORMATTER.format(Math.round(value));
|
||||
}
|
||||
|
||||
function formatSignedPercent(value: number) {
|
||||
const sign = value > 0 ? "+" : "";
|
||||
return `${sign}${value.toFixed(2)}%`;
|
||||
}
|
||||
@@ -1,999 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useCallback, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { Activity, Search, ShieldCheck, TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useKisRuntimeStore,
|
||||
type KisRuntimeCredentials,
|
||||
} from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardMarketPhase,
|
||||
DashboardKisRevokeResponse,
|
||||
DashboardPriceSource,
|
||||
DashboardKisValidateResponse,
|
||||
DashboardKisWsApprovalResponse,
|
||||
DashboardStockItem,
|
||||
DashboardStockOverviewResponse,
|
||||
DashboardStockSearchItem,
|
||||
DashboardStockSearchResponse,
|
||||
StockCandlePoint,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
/**
|
||||
* @file features/dashboard/components/dashboard-main.tsx
|
||||
* @description 대시보드 메인 UI(검색/시세/차트)
|
||||
*/
|
||||
|
||||
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
|
||||
|
||||
function formatPrice(value: number) {
|
||||
return `${PRICE_FORMATTER.format(value)}원`;
|
||||
}
|
||||
|
||||
function formatVolume(value: number) {
|
||||
return `${PRICE_FORMATTER.format(value)}주`;
|
||||
}
|
||||
|
||||
function getPriceSourceLabel(source: DashboardPriceSource, marketPhase: DashboardMarketPhase) {
|
||||
switch (source) {
|
||||
case "inquire-overtime-price":
|
||||
return "시간외 현재가(inquire-overtime-price)";
|
||||
case "inquire-ccnl":
|
||||
return marketPhase === "afterHours"
|
||||
? "체결가 폴백(inquire-ccnl)"
|
||||
: "체결가(inquire-ccnl)";
|
||||
default:
|
||||
return "현재가(inquire-price)";
|
||||
}
|
||||
}
|
||||
|
||||
function getMarketPhaseLabel(marketPhase: DashboardMarketPhase) {
|
||||
return marketPhase === "regular" ? "장중(한국시간 09:00~15:30)" : "장외/휴장";
|
||||
}
|
||||
|
||||
/**
|
||||
* 주가 라인 차트(SVG)
|
||||
*/
|
||||
function StockLineChart({ candles }: { candles: StockCandlePoint[] }) {
|
||||
const chart = (() => {
|
||||
const width = 760;
|
||||
const height = 280;
|
||||
const paddingX = 24;
|
||||
const paddingY = 20;
|
||||
const plotWidth = width - paddingX * 2;
|
||||
const plotHeight = height - paddingY * 2;
|
||||
|
||||
const prices = candles.map((item) => item.price);
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
const range = Math.max(maxPrice - minPrice, 1);
|
||||
|
||||
const points = candles.map((item, index) => {
|
||||
const x = paddingX + (index / Math.max(candles.length - 1, 1)) * plotWidth;
|
||||
const y = paddingY + ((maxPrice - item.price) / range) * plotHeight;
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
const linePoints = points.map((point) => `${point.x},${point.y}`).join(" ");
|
||||
const firstPoint = points[0];
|
||||
const lastPoint = points[points.length - 1];
|
||||
const areaPoints = `${linePoints} ${lastPoint.x},${height - paddingY} ${firstPoint.x},${height - paddingY}`;
|
||||
|
||||
return { width, height, paddingX, paddingY, minPrice, maxPrice, linePoints, areaPoints };
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="h-[300px] w-full">
|
||||
<svg viewBox={`0 0 ${chart.width} ${chart.height}`} className="h-full w-full">
|
||||
<defs>
|
||||
<linearGradient id="priceAreaGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-brand-500)" stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor="var(--color-brand-500)" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<line
|
||||
x1={chart.paddingX}
|
||||
y1={chart.paddingY}
|
||||
x2={chart.width - chart.paddingX}
|
||||
y2={chart.paddingY}
|
||||
stroke="currentColor"
|
||||
className="text-border"
|
||||
/>
|
||||
<line
|
||||
x1={chart.paddingX}
|
||||
y1={chart.height / 2}
|
||||
x2={chart.width - chart.paddingX}
|
||||
y2={chart.height / 2}
|
||||
stroke="currentColor"
|
||||
className="text-border/70"
|
||||
/>
|
||||
<line
|
||||
x1={chart.paddingX}
|
||||
y1={chart.height - chart.paddingY}
|
||||
x2={chart.width - chart.paddingX}
|
||||
y2={chart.height - chart.paddingY}
|
||||
stroke="currentColor"
|
||||
className="text-border"
|
||||
/>
|
||||
|
||||
<polygon points={chart.areaPoints} fill="url(#priceAreaGradient)" />
|
||||
<polyline
|
||||
points={chart.linePoints}
|
||||
fill="none"
|
||||
stroke="var(--color-brand-600)"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{candles[0]?.time}</span>
|
||||
<span>저가 {formatPrice(chart.minPrice)}</span>
|
||||
<span>고가 {formatPrice(chart.maxPrice)}</span>
|
||||
<span>{candles[candles.length - 1]?.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceStat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/70 bg-background/70 p-3">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchStockSearch(keyword: string) {
|
||||
const response = await fetch(`/api/kis/domestic/search?q=${encodeURIComponent(keyword)}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as DashboardStockSearchResponse | { error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("error" in payload ? payload.error : "종목 검색 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
return payload as DashboardStockSearchResponse;
|
||||
}
|
||||
|
||||
async function fetchStockOverview(symbol: string, credentials: KisRuntimeCredentials) {
|
||||
const response = await fetch(`/api/kis/domestic/overview?symbol=${encodeURIComponent(symbol)}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-kis-app-key": credentials.appKey,
|
||||
"x-kis-app-secret": credentials.appSecret,
|
||||
"x-kis-trading-env": credentials.tradingEnv,
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as DashboardStockOverviewResponse | { error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("error" in payload ? payload.error : "종목 조회 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
return payload as DashboardStockOverviewResponse;
|
||||
}
|
||||
|
||||
async function validateKisCredentials(credentials: KisRuntimeCredentials) {
|
||||
const response = await fetch("/api/kis/validate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as DashboardKisValidateResponse;
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.message || "API 키 검증에 실패했습니다.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 접근토큰 폐기 요청
|
||||
* @param credentials 검증 완료된 KIS 키
|
||||
* @returns 폐기 응답
|
||||
* @see app/api/kis/revoke/route.ts POST - revokeP 폐기 프록시
|
||||
*/
|
||||
async function revokeKisCredentials(credentials: KisRuntimeCredentials) {
|
||||
const response = await fetch("/api/kis/revoke", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as DashboardKisRevokeResponse;
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.message || "API 키 접근 폐기에 실패했습니다.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
const KIS_REALTIME_TR_ID_REAL = "H0UNCNT0";
|
||||
const KIS_REALTIME_TR_ID_MOCK = "H0STCNT0";
|
||||
|
||||
function resolveRealtimeTrId(tradingEnv: KisRuntimeCredentials["tradingEnv"]) {
|
||||
return tradingEnv === "mock" ? KIS_REALTIME_TR_ID_MOCK : KIS_REALTIME_TR_ID_REAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 실시간 웹소켓 승인키를 발급받습니다.
|
||||
* @param credentials 검증 완료된 KIS 키
|
||||
* @returns approval key + ws url
|
||||
* @see app/api/kis/ws/approval/route.ts POST - Approval 발급 프록시
|
||||
*/
|
||||
async function fetchKisWebSocketApproval(credentials: KisRuntimeCredentials) {
|
||||
const response = await fetch("/api/kis/ws/approval", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as DashboardKisWsApprovalResponse;
|
||||
if (!response.ok || !payload.ok || !payload.approvalKey || !payload.wsUrl) {
|
||||
throw new Error(payload.message || "KIS 실시간 웹소켓 승인키 발급에 실패했습니다.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 실시간 체결가 구독/해제 메시지를 생성합니다.
|
||||
* @param approvalKey websocket 승인키
|
||||
* @param symbol 종목코드
|
||||
* @param trType "1"(구독) | "2"(해제)
|
||||
* @returns websocket 요청 메시지
|
||||
* @see https://github.com/koreainvestment/open-trading-api
|
||||
*/
|
||||
function buildKisRealtimeMessage(
|
||||
approvalKey: string,
|
||||
symbol: string,
|
||||
trId: string,
|
||||
trType: "1" | "2",
|
||||
) {
|
||||
return {
|
||||
header: {
|
||||
approval_key: approvalKey,
|
||||
custtype: "P",
|
||||
tr_type: trType,
|
||||
"content-type": "utf-8",
|
||||
},
|
||||
body: {
|
||||
input: {
|
||||
tr_id: trId,
|
||||
tr_key: symbol,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface KisRealtimeTick {
|
||||
point: StockCandlePoint;
|
||||
price: number;
|
||||
accumulatedVolume: number;
|
||||
tickTime: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 실시간 체결가 원문을 차트 포인트로 변환합니다.
|
||||
* @param raw websocket 수신 원문
|
||||
* @param expectedSymbol 현재 선택 종목코드
|
||||
* @returns 실시간 포인트 또는 null
|
||||
*/
|
||||
function parseKisRealtimeTick(raw: string, expectedSymbol: string, expectedTrId: string): KisRealtimeTick | null {
|
||||
if (!/^([01])\|/.test(raw)) return null;
|
||||
|
||||
const parts = raw.split("|");
|
||||
if (parts.length < 4) return null;
|
||||
if (parts[1] !== expectedTrId) return null;
|
||||
|
||||
const tickCount = Number(parts[2] ?? "1");
|
||||
const values = parts[3].split("^");
|
||||
const isBatch = Number.isInteger(tickCount) && tickCount > 1 && values.length % tickCount === 0;
|
||||
const fieldsPerTick = isBatch ? values.length / tickCount : values.length;
|
||||
const baseIndex = isBatch ? (tickCount - 1) * fieldsPerTick : 0;
|
||||
const symbol = values[baseIndex];
|
||||
const hhmmss = values[baseIndex + 1];
|
||||
const price = Number((values[baseIndex + 2] ?? "").replaceAll(",", "").trim());
|
||||
const accumulatedVolume = Number((values[baseIndex + 13] ?? "").replaceAll(",", "").trim());
|
||||
|
||||
if (symbol !== expectedSymbol) return null;
|
||||
if (!Number.isFinite(price) || price <= 0) return null;
|
||||
|
||||
return {
|
||||
point: {
|
||||
time: formatRealtimeTickTime(hhmmss),
|
||||
price,
|
||||
},
|
||||
price,
|
||||
accumulatedVolume: Number.isFinite(accumulatedVolume) && accumulatedVolume > 0 ? accumulatedVolume : 0,
|
||||
tickTime: hhmmss ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function formatRealtimeTickTime(hhmmss?: string) {
|
||||
if (!hhmmss || hhmmss.length !== 6) return "실시간";
|
||||
return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`;
|
||||
}
|
||||
|
||||
function appendRealtimeTick(prev: StockCandlePoint[], next: StockCandlePoint) {
|
||||
if (prev.length === 0) return [next];
|
||||
|
||||
const last = prev[prev.length - 1];
|
||||
if (last.time === next.time) {
|
||||
return [...prev.slice(0, -1), next];
|
||||
}
|
||||
|
||||
return [...prev, next].slice(-80);
|
||||
}
|
||||
|
||||
function toTickOrderValue(hhmmss?: string) {
|
||||
if (!hhmmss || !/^\d{6}$/.test(hhmmss)) return -1;
|
||||
return Number(hhmmss);
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 메인 화면
|
||||
*/
|
||||
export function DashboardMain() {
|
||||
// [State] KIS 키 입력/검증 상태(zustand + persist)
|
||||
const {
|
||||
kisTradingEnvInput,
|
||||
kisAppKeyInput,
|
||||
kisAppSecretInput,
|
||||
verifiedCredentials,
|
||||
isKisVerified,
|
||||
tradingEnv,
|
||||
setKisTradingEnvInput,
|
||||
setKisAppKeyInput,
|
||||
setKisAppSecretInput,
|
||||
setVerifiedKisSession,
|
||||
invalidateKisVerification,
|
||||
clearKisRuntimeSession,
|
||||
} = useKisRuntimeStore(
|
||||
useShallow((state) => ({
|
||||
kisTradingEnvInput: state.kisTradingEnvInput,
|
||||
kisAppKeyInput: state.kisAppKeyInput,
|
||||
kisAppSecretInput: state.kisAppSecretInput,
|
||||
verifiedCredentials: state.verifiedCredentials,
|
||||
isKisVerified: state.isKisVerified,
|
||||
tradingEnv: state.tradingEnv,
|
||||
setKisTradingEnvInput: state.setKisTradingEnvInput,
|
||||
setKisAppKeyInput: state.setKisAppKeyInput,
|
||||
setKisAppSecretInput: state.setKisAppSecretInput,
|
||||
setVerifiedKisSession: state.setVerifiedKisSession,
|
||||
invalidateKisVerification: state.invalidateKisVerification,
|
||||
clearKisRuntimeSession: state.clearKisRuntimeSession,
|
||||
})),
|
||||
);
|
||||
|
||||
// [State] 검증 상태 메시지
|
||||
const [kisStatusMessage, setKisStatusMessage] = useState<string | null>(null);
|
||||
const [kisStatusError, setKisStatusError] = useState<string | null>(null);
|
||||
|
||||
// [State] 검색/선택 데이터
|
||||
const [keyword, setKeyword] = useState("삼성전자");
|
||||
const [searchResults, setSearchResults] = useState<DashboardStockSearchItem[]>([]);
|
||||
const [selectedStock, setSelectedStock] = useState<DashboardStockItem | null>(null);
|
||||
const [selectedOverviewMeta, setSelectedOverviewMeta] = useState<{
|
||||
priceSource: DashboardPriceSource;
|
||||
marketPhase: DashboardMarketPhase;
|
||||
fetchedAt: string;
|
||||
} | null>(null);
|
||||
const [realtimeCandles, setRealtimeCandles] = useState<StockCandlePoint[]>([]);
|
||||
const [isRealtimeConnected, setIsRealtimeConnected] = useState(false);
|
||||
const [realtimeError, setRealtimeError] = useState<string | null>(null);
|
||||
const [lastRealtimeTickAt, setLastRealtimeTickAt] = useState<number | null>(null);
|
||||
const [realtimeTickCount, setRealtimeTickCount] = useState(0);
|
||||
|
||||
// [State] 영역별 에러
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [overviewError, setOverviewError] = useState<string | null>(null);
|
||||
|
||||
// [State] 비동기 전환 상태
|
||||
const [isValidatingKis, startValidateTransition] = useTransition();
|
||||
const [isRevokingKis, startRevokeTransition] = useTransition();
|
||||
const [isSearching, startSearchTransition] = useTransition();
|
||||
const [isLoadingOverview, startOverviewTransition] = useTransition();
|
||||
|
||||
const realtimeSocketRef = useRef<WebSocket | null>(null);
|
||||
const realtimeApprovalKeyRef = useRef<string | null>(null);
|
||||
const lastRealtimeTickOrderRef = useRef<number>(-1);
|
||||
const isPositive = (selectedStock?.change ?? 0) >= 0;
|
||||
const chartCandles =
|
||||
isRealtimeConnected && realtimeCandles.length > 0 ? realtimeCandles : (selectedStock?.candles ?? []);
|
||||
const apiPriceSourceLabel = selectedOverviewMeta
|
||||
? getPriceSourceLabel(selectedOverviewMeta.priceSource, selectedOverviewMeta.marketPhase)
|
||||
: null;
|
||||
const realtimeTrId = verifiedCredentials ? resolveRealtimeTrId(verifiedCredentials.tradingEnv) : null;
|
||||
const effectivePriceSourceLabel =
|
||||
isRealtimeConnected && lastRealtimeTickAt
|
||||
? `실시간 체결(WebSocket ${realtimeTrId ?? KIS_REALTIME_TR_ID_REAL})`
|
||||
: apiPriceSourceLabel;
|
||||
|
||||
useEffect(() => {
|
||||
setRealtimeCandles([]);
|
||||
setIsRealtimeConnected(false);
|
||||
setRealtimeError(null);
|
||||
setLastRealtimeTickAt(null);
|
||||
setRealtimeTickCount(0);
|
||||
lastRealtimeTickOrderRef.current = -1;
|
||||
}, [selectedStock?.symbol]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRealtimeConnected || lastRealtimeTickAt) return;
|
||||
|
||||
const noTickTimer = window.setTimeout(() => {
|
||||
setRealtimeError("실시간 연결은 되었지만 체결 데이터가 없습니다. 장중(09:00~15:30)인지 확인해 주세요.");
|
||||
}, 8000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(noTickTimer);
|
||||
};
|
||||
}, [isRealtimeConnected, lastRealtimeTickAt]);
|
||||
|
||||
useEffect(() => {
|
||||
const symbol = selectedStock?.symbol;
|
||||
|
||||
if (!symbol || !isKisVerified || !verifiedCredentials) {
|
||||
setIsRealtimeConnected(false);
|
||||
setRealtimeError(null);
|
||||
setRealtimeTickCount(0);
|
||||
lastRealtimeTickOrderRef.current = -1;
|
||||
realtimeSocketRef.current?.close();
|
||||
realtimeSocketRef.current = null;
|
||||
realtimeApprovalKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
|
||||
const realtimeTrId = resolveRealtimeTrId(verifiedCredentials.tradingEnv);
|
||||
|
||||
const connectKisRealtimePrice = async () => {
|
||||
try {
|
||||
setRealtimeError(null);
|
||||
setIsRealtimeConnected(false);
|
||||
|
||||
const approval = await fetchKisWebSocketApproval(verifiedCredentials);
|
||||
if (disposed) return;
|
||||
|
||||
realtimeApprovalKeyRef.current = approval.approvalKey ?? null;
|
||||
socket = new WebSocket(`${approval.wsUrl}/tryitout`);
|
||||
realtimeSocketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
if (disposed || !realtimeApprovalKeyRef.current) return;
|
||||
|
||||
const subscribeMessage = buildKisRealtimeMessage(realtimeApprovalKeyRef.current, symbol, realtimeTrId, "1");
|
||||
socket?.send(JSON.stringify(subscribeMessage));
|
||||
setIsRealtimeConnected(true);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (disposed || typeof event.data !== "string") return;
|
||||
|
||||
const tick = parseKisRealtimeTick(event.data, symbol, realtimeTrId);
|
||||
if (!tick) return;
|
||||
|
||||
// 지연 도착으로 시간이 역행하는 틱은 무시해 차트 흔들림을 줄입니다.
|
||||
const nextTickOrder = toTickOrderValue(tick.tickTime);
|
||||
if (nextTickOrder > 0 && lastRealtimeTickOrderRef.current > nextTickOrder) {
|
||||
return;
|
||||
}
|
||||
if (nextTickOrder > 0) {
|
||||
lastRealtimeTickOrderRef.current = nextTickOrder;
|
||||
}
|
||||
|
||||
setRealtimeError(null);
|
||||
setLastRealtimeTickAt(Date.now());
|
||||
setRealtimeTickCount((prev) => prev + 1);
|
||||
setRealtimeCandles((prev) => appendRealtimeTick(prev, tick.point));
|
||||
|
||||
// 실시간 체결가를 카드 현재가/등락/거래량에도 반영합니다.
|
||||
setSelectedStock((prev) => {
|
||||
if (!prev || prev.symbol !== symbol) return prev;
|
||||
|
||||
const nextPrice = tick.price;
|
||||
const nextChange = nextPrice - prev.prevClose;
|
||||
const nextChangeRate = prev.prevClose > 0 ? (nextChange / prev.prevClose) * 100 : prev.changeRate;
|
||||
const nextHigh = prev.high > 0 ? Math.max(prev.high, nextPrice) : nextPrice;
|
||||
const nextLow = prev.low > 0 ? Math.min(prev.low, nextPrice) : nextPrice;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
currentPrice: nextPrice,
|
||||
change: nextChange,
|
||||
changeRate: nextChangeRate,
|
||||
high: nextHigh,
|
||||
low: nextLow,
|
||||
volume: tick.accumulatedVolume > 0 ? tick.accumulatedVolume : prev.volume,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
if (disposed) return;
|
||||
setIsRealtimeConnected(false);
|
||||
setRealtimeError("실시간 연결 중 오류가 발생했습니다.");
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (disposed) return;
|
||||
setIsRealtimeConnected(false);
|
||||
};
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
const message =
|
||||
error instanceof Error ? error.message : "실시간 웹소켓 초기화 중 오류가 발생했습니다.";
|
||||
setRealtimeError(message);
|
||||
setIsRealtimeConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
void connectKisRealtimePrice();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
setIsRealtimeConnected(false);
|
||||
|
||||
const approvalKey = realtimeApprovalKeyRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN && approvalKey) {
|
||||
const unsubscribeMessage = buildKisRealtimeMessage(approvalKey, symbol, realtimeTrId, "2");
|
||||
socket.send(JSON.stringify(unsubscribeMessage));
|
||||
}
|
||||
|
||||
socket?.close();
|
||||
if (realtimeSocketRef.current === socket) {
|
||||
realtimeSocketRef.current = null;
|
||||
}
|
||||
realtimeApprovalKeyRef.current = null;
|
||||
};
|
||||
}, [
|
||||
isKisVerified,
|
||||
selectedStock?.symbol,
|
||||
verifiedCredentials,
|
||||
]);
|
||||
|
||||
const loadOverview = useCallback(
|
||||
async (symbol: string, credentials: KisRuntimeCredentials) => {
|
||||
try {
|
||||
setOverviewError(null);
|
||||
|
||||
const data = await fetchStockOverview(symbol, credentials);
|
||||
setSelectedStock(data.stock);
|
||||
setSelectedOverviewMeta({
|
||||
priceSource: data.priceSource,
|
||||
marketPhase: data.marketPhase,
|
||||
fetchedAt: data.fetchedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "종목 조회 중 오류가 발생했습니다.";
|
||||
setOverviewError(message);
|
||||
setSelectedOverviewMeta(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadSearch = useCallback(
|
||||
async (nextKeyword: string, credentials: KisRuntimeCredentials, pickFirst = false) => {
|
||||
try {
|
||||
setSearchError(null);
|
||||
|
||||
const data = await fetchStockSearch(nextKeyword);
|
||||
setSearchResults(data.items);
|
||||
|
||||
if (pickFirst && data.items[0]) {
|
||||
await loadOverview(data.items[0].symbol, credentials);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "종목 검색 중 오류가 발생했습니다.";
|
||||
setSearchError(message);
|
||||
}
|
||||
},
|
||||
[loadOverview],
|
||||
);
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!isKisVerified || !verifiedCredentials) {
|
||||
setSearchError("상단에서 API 키 검증을 먼저 완료해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
startSearchTransition(() => {
|
||||
void loadSearch(keyword, verifiedCredentials, true);
|
||||
});
|
||||
}
|
||||
|
||||
function handlePickStock(item: DashboardStockSearchItem) {
|
||||
if (!isKisVerified || !verifiedCredentials) {
|
||||
setSearchError("상단에서 API 키 검증을 먼저 완료해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setKeyword(item.name);
|
||||
|
||||
startOverviewTransition(() => {
|
||||
void loadOverview(item.symbol, verifiedCredentials);
|
||||
});
|
||||
}
|
||||
|
||||
function handleValidateKis() {
|
||||
startValidateTransition(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setKisStatusError(null);
|
||||
setKisStatusMessage(null);
|
||||
|
||||
const trimmedAppKey = kisAppKeyInput.trim();
|
||||
const trimmedAppSecret = kisAppSecretInput.trim();
|
||||
|
||||
if (!trimmedAppKey || !trimmedAppSecret) {
|
||||
throw new Error("앱 키와 앱 시크릿을 모두 입력해 주세요.");
|
||||
}
|
||||
|
||||
const credentials: KisRuntimeCredentials = {
|
||||
appKey: trimmedAppKey,
|
||||
appSecret: trimmedAppSecret,
|
||||
tradingEnv: kisTradingEnvInput,
|
||||
};
|
||||
|
||||
const result = await validateKisCredentials(credentials);
|
||||
|
||||
setVerifiedKisSession(credentials, result.tradingEnv);
|
||||
setKisStatusMessage(
|
||||
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"} 모드)`,
|
||||
);
|
||||
|
||||
startSearchTransition(() => {
|
||||
void loadSearch(keyword || "삼성전자", credentials, true);
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "API 키 검증 중 오류가 발생했습니다.";
|
||||
|
||||
invalidateKisVerification();
|
||||
setSearchResults([]);
|
||||
setSelectedStock(null);
|
||||
setSelectedOverviewMeta(null);
|
||||
setKisStatusError(message);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
function handleRevokeKis() {
|
||||
if (!verifiedCredentials) {
|
||||
setKisStatusError("먼저 API 키 검증을 완료해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
startRevokeTransition(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
// 접근 폐기 전, 화면 상태 메시지를 초기화합니다.
|
||||
setKisStatusError(null);
|
||||
setKisStatusMessage(null);
|
||||
|
||||
const result = await revokeKisCredentials(verifiedCredentials);
|
||||
|
||||
// 로그아웃처럼 검증/조회 상태를 초기화합니다.
|
||||
clearKisRuntimeSession(result.tradingEnv);
|
||||
setSearchResults([]);
|
||||
setSelectedStock(null);
|
||||
setSelectedOverviewMeta(null);
|
||||
setSearchError(null);
|
||||
setOverviewError(null);
|
||||
setKisStatusMessage(`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"} 모드)`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "API 키 접근 폐기 중 오류가 발생했습니다.";
|
||||
setKisStatusError(message);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* ========== KIS KEY VERIFY SECTION ========== */}
|
||||
<section>
|
||||
<Card className="border-brand-200 bg-gradient-to-r from-brand-50/60 to-background">
|
||||
<CardHeader>
|
||||
<CardTitle>KIS API 키 연결</CardTitle>
|
||||
<CardDescription>
|
||||
대시보드 사용 전, 개인 API 키를 입력하고 검증해 주세요. 검증에 성공해야 시세 조회가 동작합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="md:col-span-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">거래 모드</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={kisTradingEnvInput === "real" ? "default" : "outline"}
|
||||
className={cn("flex-1", kisTradingEnvInput === "real" ? "bg-brand-600 hover:bg-brand-700" : "")}
|
||||
onClick={() => setKisTradingEnvInput("real")}
|
||||
>
|
||||
실전
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={kisTradingEnvInput === "mock" ? "default" : "outline"}
|
||||
className={cn("flex-1", kisTradingEnvInput === "mock" ? "bg-brand-600 hover:bg-brand-700" : "")}
|
||||
onClick={() => setKisTradingEnvInput("mock")}
|
||||
>
|
||||
모의
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">KIS App Key</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={kisAppKeyInput}
|
||||
onChange={(event) => setKisAppKeyInput(event.target.value)}
|
||||
placeholder="앱 키 입력"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">KIS App Secret</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={kisAppSecretInput}
|
||||
onChange={(event) => setKisAppSecretInput(event.target.value)}
|
||||
placeholder="앱 시크릿 입력"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleValidateKis}
|
||||
disabled={isValidatingKis || !kisAppKeyInput.trim() || !kisAppSecretInput.trim()}
|
||||
className="bg-brand-600 hover:bg-brand-700"
|
||||
>
|
||||
{isValidatingKis ? "검증 중..." : "API 키 검증"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRevokeKis}
|
||||
disabled={isRevokingKis || !isKisVerified || !verifiedCredentials}
|
||||
className="border-brand-200 text-brand-700 hover:bg-brand-50 hover:text-brand-800"
|
||||
>
|
||||
{isRevokingKis ? "폐기 중..." : "접근 폐기"}
|
||||
</Button>
|
||||
|
||||
{isKisVerified ? (
|
||||
<span className="rounded-full bg-brand-100 px-3 py-1 text-xs font-semibold text-brand-700">
|
||||
검증 완료 ({tradingEnv === "real" ? "실전" : "모의"})
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground">미검증</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{kisStatusError ? <p className="text-sm text-red-600">{kisStatusError}</p> : null}
|
||||
{kisStatusMessage ? <p className="text-sm text-brand-700">{kisStatusMessage}</p> : null}
|
||||
|
||||
<div className="rounded-lg border border-brand-200 bg-brand-50/70 px-3 py-2 text-xs text-brand-800">
|
||||
입력한 API 키는 새로고침 유지를 위해 현재 브라우저 저장소(zustand persist)에만 보관되며, 접근 폐기를 누르면 즉시 초기화됩니다.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ========== DASHBOARD TITLE SECTION ========== */}
|
||||
<section>
|
||||
<h2 className="text-3xl font-bold tracking-tight">국내주식 대시보드</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
종목명 검색, 현재가, 일자별 차트를 한 화면에서 확인합니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ========== STOCK SEARCH SECTION ========== */}
|
||||
<section>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>종목 검색</CardTitle>
|
||||
<CardDescription>종목명 또는 종목코드(예: 삼성전자, 005930)로 검색할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 md:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="종목명 / 종목코드 검색"
|
||||
className="pl-9"
|
||||
disabled={!isKisVerified}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="md:min-w-28" disabled={!isKisVerified || isSearching || !keyword.trim()}>
|
||||
{isSearching ? "검색 중..." : "검색"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{!isKisVerified ? (
|
||||
<p className="text-xs text-muted-foreground">상단에서 API 키 검증을 완료해야 검색/시세 기능이 동작합니다.</p>
|
||||
) : searchError ? (
|
||||
<p className="text-sm text-red-600">{searchError}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{searchResults.length > 0
|
||||
? `검색 결과 ${searchResults.length}개`
|
||||
: "검색어를 입력하고 엔터를 누르면 종목이 표시됩니다."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-5">
|
||||
{searchResults.map((item) => {
|
||||
const active = item.symbol === selectedStock?.symbol;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${item.symbol}-${item.market}`}
|
||||
type="button"
|
||||
onClick={() => handlePickStock(item)}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-left transition-colors",
|
||||
active
|
||||
? "border-brand-500 bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300"
|
||||
: "border-border bg-background hover:bg-muted/60",
|
||||
)}
|
||||
disabled={!isKisVerified}
|
||||
>
|
||||
<p className="text-sm font-semibold">{item.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.symbol} · {item.market}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ========== STOCK OVERVIEW SECTION ========== */}
|
||||
<section className="grid gap-4 xl:grid-cols-3">
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">{selectedStock?.name ?? "종목을 선택해 주세요"}</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedStock ? `${selectedStock.symbol} · ${selectedStock.market}` : "선택된 종목이 없습니다."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{selectedStock && (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm font-semibold",
|
||||
isPositive
|
||||
? "bg-brand-50 text-brand-700 dark:bg-brand-900/35 dark:text-brand-300"
|
||||
: "bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300",
|
||||
)}
|
||||
>
|
||||
{isPositive ? <TrendingUp className="h-4 w-4" /> : <TrendingDown className="h-4 w-4" />}
|
||||
{isPositive ? "+" : ""}
|
||||
{selectedStock.change.toLocaleString()} ({isPositive ? "+" : ""}
|
||||
{selectedStock.changeRate.toFixed(2)}%)
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{overviewError ? (
|
||||
<p className="text-sm text-red-600">{overviewError}</p>
|
||||
) : !isKisVerified ? (
|
||||
<p className="text-sm text-muted-foreground">상단에서 API 키 검증을 완료해 주세요.</p>
|
||||
) : isLoadingOverview && !selectedStock ? (
|
||||
<p className="text-sm text-muted-foreground">종목 데이터를 불러오는 중입니다...</p>
|
||||
) : selectedStock ? (
|
||||
<>
|
||||
<p className="mb-4 text-3xl font-extrabold tracking-tight">{formatPrice(selectedStock.currentPrice)}</p>
|
||||
{effectivePriceSourceLabel && selectedOverviewMeta ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="rounded-full border border-brand-200 bg-brand-50 px-2 py-1 text-brand-700">
|
||||
현재가 소스: {effectivePriceSourceLabel}
|
||||
</span>
|
||||
<span className="rounded-full border border-border px-2 py-1">
|
||||
구간: {getMarketPhaseLabel(selectedOverviewMeta.marketPhase)}
|
||||
</span>
|
||||
<span className="rounded-full border border-border px-2 py-1">
|
||||
조회시각: {new Date(selectedOverviewMeta.fetchedAt).toLocaleTimeString("ko-KR")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<StockLineChart candles={chartCandles} />
|
||||
{realtimeError ? <p className="mt-3 text-xs text-red-600">{realtimeError}</p> : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">종목을 선택하면 시세와 차트가 표시됩니다.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">핵심 지표</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2">
|
||||
<PriceStat label="시가" value={formatPrice(selectedStock?.open ?? 0)} />
|
||||
<PriceStat label="고가" value={formatPrice(selectedStock?.high ?? 0)} />
|
||||
<PriceStat label="저가" value={formatPrice(selectedStock?.low ?? 0)} />
|
||||
<PriceStat label="전일 종가" value={formatPrice(selectedStock?.prevClose ?? 0)} />
|
||||
<PriceStat label="누적 거래량" value={formatVolume(selectedStock?.volume ?? 0)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">연동 상태</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-brand-500" />
|
||||
<p>국내주식 {tradingEnv === "real" ? "실전" : "모의"}투자 API 연결 완료</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className={cn("h-4 w-4", isRealtimeConnected ? "text-brand-500" : "text-muted-foreground")} />
|
||||
<p>
|
||||
실시간 체결가 연결 상태:{" "}
|
||||
{isRealtimeConnected ? "연결됨 (WebSocket)" : "대기 중 (일봉 차트 표시)"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<p>마지막 실시간 수신: {lastRealtimeTickAt ? new Date(lastRealtimeTickAt).toLocaleTimeString("ko-KR") : "수신 전"}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<p>실시간 틱 수신 수: {realtimeTickCount.toLocaleString("ko-KR")}건</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-brand-500" />
|
||||
<p>다음 단계: 주문/리스크 제어 API 연결</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
features/dashboard/components/details/StockOverviewCard.tsx
Normal file
144
features/dashboard/components/details/StockOverviewCard.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Activity, ShieldCheck } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { StockLineChart } from "@/features/dashboard/components/chart/StockLineChart";
|
||||
import { StockPriceBadge } from "@/features/dashboard/components/details/StockPriceBadge";
|
||||
import type {
|
||||
DashboardStockItem,
|
||||
DashboardPriceSource,
|
||||
DashboardMarketPhase,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
|
||||
function formatVolume(value: number) {
|
||||
return `${PRICE_FORMATTER.format(value)}주`;
|
||||
}
|
||||
|
||||
function getPriceSourceLabel(
|
||||
source: DashboardPriceSource,
|
||||
marketPhase: DashboardMarketPhase,
|
||||
) {
|
||||
switch (source) {
|
||||
case "inquire-overtime-price":
|
||||
return "시간외 현재가(inquire-overtime-price)";
|
||||
case "inquire-ccnl":
|
||||
return marketPhase === "afterHours"
|
||||
? "체결가 폴백(inquire-ccnl)"
|
||||
: "체결가(inquire-ccnl)";
|
||||
default:
|
||||
return "현재가(inquire-price)";
|
||||
}
|
||||
}
|
||||
|
||||
function PriceStat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/70 bg-background/70 p-3">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StockOverviewCardProps {
|
||||
stock: DashboardStockItem;
|
||||
priceSource: DashboardPriceSource;
|
||||
marketPhase: DashboardMarketPhase;
|
||||
isRealtimeConnected: boolean;
|
||||
realtimeTrId: string | null;
|
||||
lastRealtimeTickAt: number | null;
|
||||
}
|
||||
|
||||
export function StockOverviewCard({
|
||||
stock,
|
||||
priceSource,
|
||||
marketPhase,
|
||||
isRealtimeConnected,
|
||||
realtimeTrId,
|
||||
lastRealtimeTickAt,
|
||||
}: StockOverviewCardProps) {
|
||||
const apiPriceSourceLabel = getPriceSourceLabel(priceSource, marketPhase);
|
||||
const effectivePriceSourceLabel =
|
||||
isRealtimeConnected && lastRealtimeTickAt
|
||||
? `실시간 체결(WebSocket ${realtimeTrId || ""})`
|
||||
: apiPriceSourceLabel;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-brand-200">
|
||||
<CardHeader className="border-b border-border/50 bg-muted/30 pb-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-xl font-bold">{stock.name}</CardTitle>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{stock.symbol}
|
||||
</span>
|
||||
<span className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700">
|
||||
{stock.market}
|
||||
</span>
|
||||
</div>
|
||||
<CardDescription className="mt-1 flex items-center gap-1.5">
|
||||
<span>{effectivePriceSourceLabel}</span>
|
||||
{isRealtimeConnected && (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-xs font-medium text-green-700">
|
||||
<Activity className="h-3 w-3" />
|
||||
실시간
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<StockPriceBadge
|
||||
currentPrice={stock.currentPrice}
|
||||
change={stock.change}
|
||||
changeRate={stock.changeRate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="grid border-b border-border/50 lg:grid-cols-3">
|
||||
<div className="col-span-2 border-r border-border/50">
|
||||
{/* Chart Area */}
|
||||
<div className="p-6">
|
||||
<StockLineChart candles={stock.candles} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 bg-muted/10 p-6">
|
||||
<div className="mb-4 flex items-center gap-2 text-sm font-semibold text-foreground/80">
|
||||
<ShieldCheck className="h-4 w-4 text-brand-600" />
|
||||
주요 시세 정보
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<PriceStat
|
||||
label="시가"
|
||||
value={`${PRICE_FORMATTER.format(stock.open)}원`}
|
||||
/>
|
||||
<PriceStat
|
||||
label="고가"
|
||||
value={`${PRICE_FORMATTER.format(stock.high)}원`}
|
||||
/>
|
||||
<PriceStat
|
||||
label="저가"
|
||||
value={`${PRICE_FORMATTER.format(stock.low)}원`}
|
||||
/>
|
||||
<PriceStat
|
||||
label="전일종가"
|
||||
value={`${PRICE_FORMATTER.format(stock.prevClose)}원`}
|
||||
/>
|
||||
<div className="col-span-2">
|
||||
<PriceStat label="거래량" value={formatVolume(stock.volume)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
48
features/dashboard/components/details/StockPriceBadge.tsx
Normal file
48
features/dashboard/components/details/StockPriceBadge.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
|
||||
function formatPrice(value: number) {
|
||||
return `${PRICE_FORMATTER.format(value)}원`;
|
||||
}
|
||||
|
||||
interface StockPriceBadgeProps {
|
||||
currentPrice: number;
|
||||
change: number;
|
||||
changeRate: number;
|
||||
}
|
||||
|
||||
export function StockPriceBadge({
|
||||
currentPrice,
|
||||
change,
|
||||
changeRate,
|
||||
}: StockPriceBadgeProps) {
|
||||
const isPositive = change >= 0;
|
||||
const ChangeIcon = isPositive ? TrendingUp : TrendingDown;
|
||||
const changeColor = isPositive ? "text-red-500" : "text-blue-500";
|
||||
const changeSign = isPositive ? "+" : "";
|
||||
|
||||
return (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={cn("text-3xl font-bold", changeColor)}>
|
||||
{formatPrice(currentPrice)}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-sm font-medium",
|
||||
changeColor,
|
||||
)}
|
||||
>
|
||||
<ChangeIcon className="h-4 w-4" />
|
||||
<span>
|
||||
{changeSign}
|
||||
{PRICE_FORMATTER.format(change)}원
|
||||
</span>
|
||||
<span>
|
||||
({changeSign}
|
||||
{changeRate.toFixed(2)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
features/dashboard/components/header/StockHeader.tsx
Normal file
71
features/dashboard/components/header/StockHeader.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DashboardStockItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StockHeaderProps {
|
||||
stock: DashboardStockItem;
|
||||
price: string;
|
||||
change: string;
|
||||
changeRate: string;
|
||||
high?: string;
|
||||
low?: string;
|
||||
volume?: string;
|
||||
}
|
||||
|
||||
export function StockHeader({
|
||||
stock,
|
||||
price,
|
||||
change,
|
||||
changeRate,
|
||||
high,
|
||||
low,
|
||||
volume,
|
||||
}: StockHeaderProps) {
|
||||
const isRise = changeRate.startsWith("+") || parseFloat(changeRate) > 0;
|
||||
const isFall = changeRate.startsWith("-") || parseFloat(changeRate) < 0;
|
||||
const colorClass = isRise
|
||||
? "text-red-500"
|
||||
: isFall
|
||||
? "text-blue-500"
|
||||
: "text-foreground";
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
{/* Left: Stock Info */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold">{stock.name}</h1>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{stock.symbol}/{stock.market}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
|
||||
<div className={cn("flex items-end gap-2", colorClass)}>
|
||||
<span className="text-2xl font-bold tracking-tight">{price}</span>
|
||||
<span className="text-sm font-medium mb-1">
|
||||
{changeRate}% <span className="text-xs ml-1">{change}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: 24h Stats */}
|
||||
<div className="hidden md:flex items-center gap-6 text-sm">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-muted-foreground text-xs">고가</span>
|
||||
<span className="font-medium text-red-500">{high || "--"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-muted-foreground text-xs">저가</span>
|
||||
<span className="font-medium text-blue-500">{low || "--"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-muted-foreground text-xs">거래량(24H)</span>
|
||||
<span className="font-medium">{volume || "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
features/dashboard/components/layout/DashboardLayout.tsx
Normal file
54
features/dashboard/components/layout/DashboardLayout.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
header: ReactNode;
|
||||
chart: ReactNode;
|
||||
orderBook: ReactNode;
|
||||
orderForm: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DashboardLayout({
|
||||
header,
|
||||
chart,
|
||||
orderBook,
|
||||
orderForm,
|
||||
className,
|
||||
}: DashboardLayoutProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[calc(100vh-64px)] flex-col overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* 1. Header Area */}
|
||||
<div className="flex-none border-b border-border bg-background">
|
||||
{header}
|
||||
</div>
|
||||
|
||||
{/* 2. Main Content Area */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden xl:flex-row">
|
||||
{/* Left Column: Chart & Info */}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col border-border xl:border-r">
|
||||
<div className="flex-1 min-h-0">{chart}</div>
|
||||
{/* Future: Transaction History / Market Depth can go here */}
|
||||
</div>
|
||||
|
||||
{/* Right Column: Order Book & Order Form */}
|
||||
<div className="flex min-h-0 w-full flex-none flex-col bg-background xl:w-[460px] 2xl:w-[500px]">
|
||||
{/* Top: Order Book (Hoga) */}
|
||||
<div className="min-h-[360px] flex-1 overflow-hidden border-t border-border xl:min-h-0 xl:border-t-0 xl:border-b">
|
||||
{orderBook}
|
||||
</div>
|
||||
|
||||
{/* Bottom: Order Form */}
|
||||
<div className="flex-none h-[320px] sm:h-[360px] xl:h-[380px]">
|
||||
{orderForm}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
features/dashboard/components/order/OrderForm.tsx
Normal file
249
features/dashboard/components/order/OrderForm.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type {
|
||||
DashboardStockItem,
|
||||
DashboardOrderSide,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import { useOrder } from "@/features/dashboard/hooks/useOrder";
|
||||
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface OrderFormProps {
|
||||
stock?: DashboardStockItem;
|
||||
}
|
||||
|
||||
export function OrderForm({ stock }: OrderFormProps) {
|
||||
const verifiedCredentials = useKisRuntimeStore(
|
||||
(state) => state.verifiedCredentials,
|
||||
);
|
||||
|
||||
const { placeOrder, isLoading, error } = useOrder();
|
||||
|
||||
// Form State
|
||||
// Initial price set from stock current price if available, relying on component remount (key) for updates
|
||||
const [price, setPrice] = useState<string>(
|
||||
stock?.currentPrice.toString() || "",
|
||||
);
|
||||
const [quantity, setQuantity] = useState<string>("");
|
||||
const [activeTab, setActiveTab] = useState("buy");
|
||||
|
||||
const handleOrder = async (side: DashboardOrderSide) => {
|
||||
if (!stock || !verifiedCredentials) return;
|
||||
|
||||
const priceNum = parseInt(price.replace(/,/g, ""), 10);
|
||||
const qtyNum = parseInt(quantity.replace(/,/g, ""), 10);
|
||||
|
||||
if (isNaN(priceNum) || priceNum <= 0) {
|
||||
alert("가격을 올바르게 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (isNaN(qtyNum) || qtyNum <= 0) {
|
||||
alert("수량을 올바르게 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!verifiedCredentials.accountNo) {
|
||||
alert(
|
||||
"계좌번호가 설정되지 않았습니다. 설정에서 계좌번호를 입력해주세요.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await placeOrder(
|
||||
{
|
||||
symbol: stock.symbol,
|
||||
side: side,
|
||||
orderType: "limit", // 지정가 고정
|
||||
price: priceNum,
|
||||
quantity: qtyNum,
|
||||
accountNo: verifiedCredentials.accountNo,
|
||||
accountProductCode: "01", // Default to '01' (위탁)
|
||||
},
|
||||
verifiedCredentials,
|
||||
);
|
||||
|
||||
if (response && response.orderNo) {
|
||||
alert(`주문 전송 완료! 주문번호: ${response.orderNo}`);
|
||||
setQuantity("");
|
||||
}
|
||||
};
|
||||
|
||||
const totalPrice =
|
||||
parseInt(price.replace(/,/g, "") || "0", 10) *
|
||||
parseInt(quantity.replace(/,/g, "") || "0", 10);
|
||||
|
||||
const setPercent = (pct: string) => {
|
||||
// Placeholder logic for percent click
|
||||
console.log("Percent clicked:", pct);
|
||||
};
|
||||
|
||||
const isMarketDataAvailable = !!stock;
|
||||
|
||||
return (
|
||||
<div className="h-full bg-background p-4 border-l border-border">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full h-full flex flex-col"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-4">
|
||||
<TabsTrigger
|
||||
value="buy"
|
||||
className="data-[state=active]:bg-red-600 data-[state=active]:text-white transition-colors"
|
||||
>
|
||||
매수
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="sell"
|
||||
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white transition-colors"
|
||||
>
|
||||
매도
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="buy"
|
||||
className="flex-1 flex flex-col space-y-4 data-[state=inactive]:hidden"
|
||||
>
|
||||
<OrderInputs
|
||||
type="buy"
|
||||
price={price}
|
||||
setPrice={setPrice}
|
||||
quantity={quantity}
|
||||
setQuantity={setQuantity}
|
||||
totalPrice={totalPrice}
|
||||
disabled={!isMarketDataAvailable}
|
||||
hasError={!!error}
|
||||
errorMessage={error}
|
||||
/>
|
||||
|
||||
<PercentButtons onSelect={setPercent} />
|
||||
|
||||
<Button
|
||||
className="w-full bg-red-600 hover:bg-red-700 mt-auto text-lg h-12"
|
||||
disabled={isLoading || !isMarketDataAvailable}
|
||||
onClick={() => handleOrder("buy")}
|
||||
>
|
||||
{isLoading ? <Loader2 className="animate-spin mr-2" /> : "매수하기"}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="sell"
|
||||
className="flex-1 flex flex-col space-y-4 data-[state=inactive]:hidden"
|
||||
>
|
||||
<OrderInputs
|
||||
type="sell"
|
||||
price={price}
|
||||
setPrice={setPrice}
|
||||
quantity={quantity}
|
||||
setQuantity={setQuantity}
|
||||
totalPrice={totalPrice}
|
||||
disabled={!isMarketDataAvailable}
|
||||
hasError={!!error}
|
||||
errorMessage={error}
|
||||
/>
|
||||
|
||||
<PercentButtons onSelect={setPercent} />
|
||||
|
||||
<Button
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 mt-auto text-lg h-12"
|
||||
disabled={isLoading || !isMarketDataAvailable}
|
||||
onClick={() => handleOrder("sell")}
|
||||
>
|
||||
{isLoading ? <Loader2 className="animate-spin mr-2" /> : "매도하기"}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderInputs({
|
||||
type,
|
||||
price,
|
||||
setPrice,
|
||||
quantity,
|
||||
setQuantity,
|
||||
totalPrice,
|
||||
disabled,
|
||||
hasError,
|
||||
errorMessage,
|
||||
}: {
|
||||
type: "buy" | "sell";
|
||||
price: string;
|
||||
setPrice: (v: string) => void;
|
||||
quantity: string;
|
||||
setQuantity: (v: string) => void;
|
||||
totalPrice: number;
|
||||
disabled: boolean;
|
||||
hasError: boolean;
|
||||
errorMessage: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>주문가능</span>
|
||||
<span>- {type === "buy" ? "KRW" : "주"}</span>
|
||||
</div>
|
||||
|
||||
{hasError && (
|
||||
<div className="p-2 bg-destructive/10 text-destructive text-xs rounded break-keep">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{type === "buy" ? "매수가격" : "매도가격"}
|
||||
</span>
|
||||
<Input
|
||||
className="col-span-3 text-right font-mono"
|
||||
placeholder="0"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 items-center">
|
||||
<span className="text-sm font-medium">주문수량</span>
|
||||
<Input
|
||||
className="col-span-3 text-right font-mono"
|
||||
placeholder="0"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 items-center">
|
||||
<span className="text-sm font-medium">주문총액</span>
|
||||
<Input
|
||||
className="col-span-3 text-right font-mono bg-muted/50"
|
||||
value={totalPrice.toLocaleString()}
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2 mt-2">
|
||||
{["10%", "25%", "50%", "100%"].map((pct) => (
|
||||
<Button
|
||||
key={pct}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => onSelect(pct)}
|
||||
>
|
||||
{pct}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
features/dashboard/components/orderbook/AnimatedQuantity.tsx
Normal file
90
features/dashboard/components/orderbook/AnimatedQuantity.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
interface AnimatedQuantityProps {
|
||||
value: number;
|
||||
format?: (val: number) => string;
|
||||
className?: string;
|
||||
/** 값 변동 시 배경 깜빡임 */
|
||||
useColor?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 수량 표시 — 값이 변할 때 ±diff를 인라인으로 보여줍니다.
|
||||
*/
|
||||
export function AnimatedQuantity({
|
||||
value,
|
||||
format = (v) => v.toLocaleString(),
|
||||
className,
|
||||
useColor = false,
|
||||
}: AnimatedQuantityProps) {
|
||||
const prevRef = useRef(value);
|
||||
const [diff, setDiff] = useState<number | null>(null);
|
||||
const [flash, setFlash] = useState<"up" | "down" | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevRef.current === value) return;
|
||||
|
||||
const delta = value - prevRef.current;
|
||||
prevRef.current = value;
|
||||
|
||||
if (delta === 0) return;
|
||||
|
||||
setDiff(delta);
|
||||
setFlash(delta > 0 ? "up" : "down");
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setDiff(null);
|
||||
setFlash(null);
|
||||
}, 1200);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-flex items-center gap-1 tabular-nums",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* 배경 깜빡임 */}
|
||||
<AnimatePresence>
|
||||
{useColor && flash && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0.5 }}
|
||||
animate={{ opacity: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className={cn(
|
||||
"absolute inset-0 z-0 rounded-sm",
|
||||
flash === "up" ? "bg-red-200/50" : "bg-blue-200/50",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 수량 값 */}
|
||||
<span className="relative z-10">{format(value)}</span>
|
||||
|
||||
{/* ±diff (인라인 표시) */}
|
||||
<AnimatePresence>
|
||||
{diff != null && diff !== 0 && (
|
||||
<motion.span
|
||||
initial={{ opacity: 1, scale: 1 }}
|
||||
animate={{ opacity: 0, scale: 0.85 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1.2, ease: "easeOut" }}
|
||||
className={cn(
|
||||
"relative z-20 whitespace-nowrap text-[9px] font-bold leading-none",
|
||||
diff > 0 ? "text-red-500" : "text-blue-500",
|
||||
)}
|
||||
>
|
||||
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
574
features/dashboard/components/orderbook/OrderBook.tsx
Normal file
574
features/dashboard/components/orderbook/OrderBook.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type {
|
||||
DashboardRealtimeTradeTick,
|
||||
DashboardStockOrderBookResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AnimatedQuantity } from "./AnimatedQuantity";
|
||||
|
||||
// ─── 타입 ───────────────────────────────────────────────
|
||||
|
||||
interface OrderBookProps {
|
||||
symbol?: string;
|
||||
referencePrice?: number;
|
||||
currentPrice?: number;
|
||||
latestTick: DashboardRealtimeTradeTick | null;
|
||||
recentTicks: DashboardRealtimeTradeTick[];
|
||||
orderBook: DashboardStockOrderBookResponse | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface BookRow {
|
||||
price: number;
|
||||
size: number;
|
||||
changePercent: number | null;
|
||||
isHighlighted: boolean;
|
||||
}
|
||||
|
||||
// ─── 유틸리티 함수 ──────────────────────────────────────
|
||||
|
||||
/** 천단위 구분 포맷 */
|
||||
function fmt(v: number) {
|
||||
return Number.isFinite(v) ? v.toLocaleString("ko-KR") : "0";
|
||||
}
|
||||
|
||||
/** 부호 포함 퍼센트 */
|
||||
function fmtPct(v: number) {
|
||||
return `${v > 0 ? "+" : ""}${v.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
/** 등락률 계산 */
|
||||
function pctChange(price: number, base: number) {
|
||||
return base > 0 ? ((price - base) / base) * 100 : 0;
|
||||
}
|
||||
|
||||
/** 체결 시각 포맷 */
|
||||
function fmtTime(hms: string) {
|
||||
if (!hms || hms.length !== 6) return "--:--:--";
|
||||
return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`;
|
||||
}
|
||||
|
||||
// ─── 메인 컴포넌트 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다.
|
||||
*/
|
||||
export function OrderBook({
|
||||
symbol,
|
||||
referencePrice,
|
||||
currentPrice,
|
||||
latestTick,
|
||||
recentTicks,
|
||||
orderBook,
|
||||
isLoading,
|
||||
}: OrderBookProps) {
|
||||
const levels = useMemo(() => orderBook?.levels ?? [], [orderBook]);
|
||||
|
||||
// 체결가: tick에서 우선, 없으면 0
|
||||
const latestPrice =
|
||||
latestTick?.price && latestTick.price > 0 ? latestTick.price : 0;
|
||||
|
||||
// 등락률 기준가
|
||||
const basePrice =
|
||||
(referencePrice ?? 0) > 0
|
||||
? referencePrice!
|
||||
: (currentPrice ?? 0) > 0
|
||||
? currentPrice!
|
||||
: latestPrice > 0
|
||||
? latestPrice
|
||||
: 0;
|
||||
|
||||
// 매도호가 (역순: 10호가 → 1호가)
|
||||
const askRows: BookRow[] = useMemo(
|
||||
() =>
|
||||
[...levels].reverse().map((l) => ({
|
||||
price: l.askPrice,
|
||||
size: Math.max(l.askSize, 0),
|
||||
changePercent:
|
||||
l.askPrice > 0 && basePrice > 0
|
||||
? pctChange(l.askPrice, basePrice)
|
||||
: null,
|
||||
isHighlighted: latestPrice > 0 && l.askPrice === latestPrice,
|
||||
})),
|
||||
[levels, basePrice, latestPrice],
|
||||
);
|
||||
|
||||
// 매수호가 (1호가 → 10호가)
|
||||
const bidRows: BookRow[] = useMemo(
|
||||
() =>
|
||||
levels.map((l) => ({
|
||||
price: l.bidPrice,
|
||||
size: Math.max(l.bidSize, 0),
|
||||
changePercent:
|
||||
l.bidPrice > 0 && basePrice > 0
|
||||
? pctChange(l.bidPrice, basePrice)
|
||||
: null,
|
||||
isHighlighted: latestPrice > 0 && l.bidPrice === latestPrice,
|
||||
})),
|
||||
[levels, basePrice, latestPrice],
|
||||
);
|
||||
|
||||
const askMax = Math.max(1, ...askRows.map((r) => r.size));
|
||||
const bidMax = Math.max(1, ...bidRows.map((r) => r.size));
|
||||
|
||||
// 스프레드·수급 불균형
|
||||
const bestAsk = levels.find((l) => l.askPrice > 0)?.askPrice ?? 0;
|
||||
const bestBid = levels.find((l) => l.bidPrice > 0)?.bidPrice ?? 0;
|
||||
const spread = bestAsk > 0 && bestBid > 0 ? bestAsk - bestBid : 0;
|
||||
const totalAsk = orderBook?.totalAskSize ?? 0;
|
||||
const totalBid = orderBook?.totalBidSize ?? 0;
|
||||
const imbalance =
|
||||
totalAsk + totalBid > 0
|
||||
? ((totalBid - totalAsk) / (totalAsk + totalBid)) * 100
|
||||
: 0;
|
||||
|
||||
// 체결가 행 중앙 스크롤
|
||||
const centerRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
centerRef.current?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}, [latestPrice]);
|
||||
|
||||
// ─── 빈/로딩 상태 ───
|
||||
if (!symbol) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
종목을 선택해주세요.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isLoading && !orderBook) return <OrderBookSkeleton />;
|
||||
if (!orderBook) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
호가 정보를 가져오지 못했습니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
<Tabs defaultValue="normal" className="h-full min-h-0">
|
||||
{/* 탭 헤더 */}
|
||||
<div className="border-b px-2 pt-2">
|
||||
<TabsList variant="line" className="w-full justify-start">
|
||||
<TabsTrigger value="normal" className="px-3">
|
||||
일반호가
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cumulative" className="px-3">
|
||||
누적호가
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="order" className="px-3">
|
||||
호가주문
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* ── 일반호가 탭 ── */}
|
||||
<TabsContent value="normal" className="min-h-0 flex-1">
|
||||
<div className="grid h-full min-h-0 grid-rows-[1fr_190px] overflow-hidden border-t">
|
||||
<div className="grid min-h-0 grid-cols-[minmax(0,1fr)_150px] overflow-hidden">
|
||||
{/* 호가 테이블 */}
|
||||
<div className="min-h-0 border-r">
|
||||
<BookHeader />
|
||||
<ScrollArea className="h-[calc(100%-32px)]">
|
||||
{/* 매도호가 */}
|
||||
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
|
||||
|
||||
{/* 중앙 바: 현재 체결가 */}
|
||||
<div
|
||||
ref={centerRef}
|
||||
className="grid h-9 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30"
|
||||
>
|
||||
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground">
|
||||
{totalAsk > 0 ? fmt(totalAsk) : ""}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="text-xs font-bold tabular-nums">
|
||||
{latestPrice > 0
|
||||
? fmt(latestPrice)
|
||||
: bestAsk > 0
|
||||
? fmt(bestAsk)
|
||||
: "-"}
|
||||
</span>
|
||||
{latestPrice > 0 && basePrice > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-medium",
|
||||
latestPrice >= basePrice
|
||||
? "text-red-500"
|
||||
: "text-blue-500",
|
||||
)}
|
||||
>
|
||||
{fmtPct(pctChange(latestPrice, basePrice))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2 text-left text-[10px] font-medium text-muted-foreground">
|
||||
{totalBid > 0 ? fmt(totalBid) : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 매수호가 */}
|
||||
<BookSideRows rows={bidRows} side="bid" maxSize={bidMax} />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* 우측 요약 패널 */}
|
||||
<SummaryPanel
|
||||
orderBook={orderBook}
|
||||
latestTick={latestTick}
|
||||
spread={spread}
|
||||
imbalance={imbalance}
|
||||
totalAsk={totalAsk}
|
||||
totalBid={totalBid}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 체결 목록 */}
|
||||
<TradeTape ticks={recentTicks} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── 누적호가 탭 ── */}
|
||||
<TabsContent value="cumulative" className="min-h-0 flex-1">
|
||||
<ScrollArea className="h-full border-t">
|
||||
<div className="p-3">
|
||||
<div className="mb-2 grid grid-cols-3 text-[11px] font-medium text-muted-foreground">
|
||||
<span>매도누적</span>
|
||||
<span className="text-center">호가</span>
|
||||
<span className="text-right">매수누적</span>
|
||||
</div>
|
||||
<CumulativeRows asks={askRows} bids={bidRows} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── 호가주문 탭 ── */}
|
||||
<TabsContent value="order" className="min-h-0 flex-1">
|
||||
<div className="flex h-full items-center justify-center border-t px-4 text-sm text-muted-foreground">
|
||||
호가주문 탭은 주문 입력 패널과 연동해 확장할 수 있습니다.
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 하위 컴포넌트 ──────────────────────────────────────
|
||||
|
||||
/** 호가 표 헤더 */
|
||||
function BookHeader() {
|
||||
return (
|
||||
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 text-[11px] font-medium text-muted-foreground">
|
||||
<div className="flex items-center justify-end px-2">매도잔량</div>
|
||||
<div className="flex items-center justify-center border-x">호가</div>
|
||||
<div className="flex items-center justify-start px-2">매수잔량</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 매도 또는 매수 호가 행 목록 */
|
||||
function BookSideRows({
|
||||
rows,
|
||||
side,
|
||||
maxSize,
|
||||
}: {
|
||||
rows: BookRow[];
|
||||
side: "ask" | "bid";
|
||||
maxSize: number;
|
||||
}) {
|
||||
const isAsk = side === "ask";
|
||||
|
||||
return (
|
||||
<div className={isAsk ? "bg-red-50/15" : "bg-blue-50/15"}>
|
||||
{rows.map((row, i) => {
|
||||
const ratio =
|
||||
maxSize > 0 ? Math.min((row.size / maxSize) * 100, 100) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${side}-${row.price}-${i}`}
|
||||
className={cn(
|
||||
"grid h-8 grid-cols-3 border-b border-border/40 text-xs",
|
||||
row.isHighlighted &&
|
||||
"ring-1 ring-inset ring-amber-400 bg-amber-100/50 dark:bg-amber-900/30",
|
||||
)}
|
||||
>
|
||||
{/* 매도잔량 (좌측) */}
|
||||
<div className="relative flex items-center justify-end overflow-hidden px-2">
|
||||
{isAsk && (
|
||||
<>
|
||||
<DepthBar ratio={ratio} side="ask" />
|
||||
<AnimatedQuantity
|
||||
value={row.size}
|
||||
format={fmt}
|
||||
useColor
|
||||
className="relative z-10"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 호가 (중앙) */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center border-x font-medium tabular-nums gap-1",
|
||||
row.isHighlighted &&
|
||||
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-900/25",
|
||||
)}
|
||||
>
|
||||
<span className={isAsk ? "text-red-600" : "text-blue-600"}>
|
||||
{row.price > 0 ? fmt(row.price) : "-"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
row.changePercent !== null
|
||||
? row.changePercent >= 0
|
||||
? "text-red-500"
|
||||
: "text-blue-500"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{row.changePercent === null ? "-" : fmtPct(row.changePercent)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 매수잔량 (우측) */}
|
||||
<div className="relative flex items-center justify-start overflow-hidden px-1">
|
||||
{!isAsk && (
|
||||
<>
|
||||
<DepthBar ratio={ratio} side="bid" />
|
||||
<AnimatedQuantity
|
||||
value={row.size}
|
||||
format={fmt}
|
||||
useColor
|
||||
className="relative z-10"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 우측 요약 패널 */
|
||||
function SummaryPanel({
|
||||
orderBook,
|
||||
latestTick,
|
||||
spread,
|
||||
imbalance,
|
||||
totalAsk,
|
||||
totalBid,
|
||||
}: {
|
||||
orderBook: DashboardStockOrderBookResponse;
|
||||
latestTick: DashboardRealtimeTradeTick | null;
|
||||
spread: number;
|
||||
imbalance: number;
|
||||
totalAsk: number;
|
||||
totalBid: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-l bg-muted/15 p-2 text-[11px]">
|
||||
<Row
|
||||
label="실시간"
|
||||
value={orderBook ? "연결됨" : "끊김"}
|
||||
tone={orderBook ? "bid" : undefined}
|
||||
/>
|
||||
<Row
|
||||
label="거래량"
|
||||
value={fmt(latestTick?.tradeVolume ?? orderBook.anticipatedVolume ?? 0)}
|
||||
/>
|
||||
<Row
|
||||
label="누적거래량"
|
||||
value={fmt(
|
||||
latestTick?.accumulatedVolume ?? orderBook.accumulatedVolume ?? 0,
|
||||
)}
|
||||
/>
|
||||
<Row
|
||||
label="체결강도"
|
||||
value={
|
||||
latestTick
|
||||
? `${latestTick.tradeStrength.toFixed(2)}%`
|
||||
: orderBook.anticipatedChangeRate !== undefined
|
||||
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
|
||||
: "-"
|
||||
}
|
||||
/>
|
||||
<Row label="예상체결가" value={fmt(orderBook.anticipatedPrice ?? 0)} />
|
||||
<Row
|
||||
label="매도1호가"
|
||||
value={latestTick ? fmt(latestTick.askPrice1) : "-"}
|
||||
tone="ask"
|
||||
/>
|
||||
<Row
|
||||
label="매수1호가"
|
||||
value={latestTick ? fmt(latestTick.bidPrice1) : "-"}
|
||||
tone="bid"
|
||||
/>
|
||||
<Row
|
||||
label="매수체결"
|
||||
value={latestTick ? fmt(latestTick.buyExecutionCount) : "-"}
|
||||
/>
|
||||
<Row
|
||||
label="매도체결"
|
||||
value={latestTick ? fmt(latestTick.sellExecutionCount) : "-"}
|
||||
/>
|
||||
<Row
|
||||
label="순매수체결"
|
||||
value={latestTick ? fmt(latestTick.netBuyExecutionCount) : "-"}
|
||||
/>
|
||||
<Row label="총 매도잔량" value={fmt(totalAsk)} tone="ask" />
|
||||
<Row label="총 매수잔량" value={fmt(totalBid)} tone="bid" />
|
||||
<Row label="스프레드" value={fmt(spread)} />
|
||||
<Row
|
||||
label="수급 불균형"
|
||||
value={`${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`}
|
||||
tone={imbalance >= 0 ? "bid" : "ask"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 요약 패널 단일 행 */
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "ask" | "bid";
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-1.5 flex items-center justify-between rounded border bg-background px-2 py-1">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium tabular-nums",
|
||||
tone === "ask" && "text-red-600",
|
||||
tone === "bid" && "text-blue-600",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 잔량 깊이 바 */
|
||||
function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
||||
if (ratio <= 0) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-1 z-0 rounded-sm",
|
||||
side === "ask" ? "right-1 bg-red-200/50" : "left-1 bg-blue-200/50",
|
||||
)}
|
||||
style={{ width: `${ratio}%` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 체결 목록 (Trade Tape) */
|
||||
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
|
||||
return (
|
||||
<div className="border-t bg-background">
|
||||
<div className="grid h-7 grid-cols-4 border-b bg-muted/20 px-2 text-[11px] font-medium text-muted-foreground">
|
||||
<div className="flex items-center">체결시각</div>
|
||||
<div className="flex items-center justify-end">체결가</div>
|
||||
<div className="flex items-center justify-end">체결량</div>
|
||||
<div className="flex items-center justify-end">체결강도</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[162px]">
|
||||
<div>
|
||||
{ticks.length === 0 && (
|
||||
<div className="flex h-[160px] items-center justify-center text-xs text-muted-foreground">
|
||||
체결 데이터가 아직 없습니다.
|
||||
</div>
|
||||
)}
|
||||
{ticks.map((t, i) => (
|
||||
<div
|
||||
key={`${t.tickTime}-${t.price}-${i}`}
|
||||
className="grid h-8 grid-cols-4 border-b border-border/40 px-2 text-xs"
|
||||
>
|
||||
<div className="flex items-center tabular-nums">
|
||||
{fmtTime(t.tickTime)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end tabular-nums text-red-600">
|
||||
{fmt(t.price)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end tabular-nums text-blue-600">
|
||||
{fmt(t.tradeVolume)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end tabular-nums">
|
||||
{t.tradeStrength.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 누적호가 행 */
|
||||
function CumulativeRows({ asks, bids }: { asks: BookRow[]; bids: BookRow[] }) {
|
||||
const rows = useMemo(() => {
|
||||
const len = Math.max(asks.length, bids.length);
|
||||
const result: { askAcc: number; bidAcc: number; price: number }[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
const prevAsk = result[i - 1]?.askAcc ?? 0;
|
||||
const prevBid = result[i - 1]?.bidAcc ?? 0;
|
||||
result.push({
|
||||
askAcc: prevAsk + (asks[i]?.size ?? 0),
|
||||
bidAcc: prevBid + (bids[i]?.size ?? 0),
|
||||
price: asks[i]?.price || bids[i]?.price || 0,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [asks, bids]);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{rows.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid h-7 grid-cols-3 items-center rounded border bg-background px-2 text-xs"
|
||||
>
|
||||
<span className="tabular-nums text-red-600">{fmt(r.askAcc)}</span>
|
||||
<span className="text-center font-medium tabular-nums">
|
||||
{fmt(r.price)}
|
||||
</span>
|
||||
<span className="text-right tabular-nums text-blue-600">
|
||||
{fmt(r.bidAcc)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 로딩 스켈레톤 */
|
||||
function OrderBookSkeleton() {
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3">
|
||||
<div className="mb-3 grid grid-cols-3 gap-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 16 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-7 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
features/dashboard/components/search/StockSearchForm.tsx
Normal file
37
features/dashboard/components/search/StockSearchForm.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { FormEvent } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
interface StockSearchFormProps {
|
||||
keyword: string;
|
||||
onKeywordChange: (value: string) => void;
|
||||
onSubmit: (e: FormEvent) => void;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function StockSearchForm({
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
onSubmit,
|
||||
disabled,
|
||||
isLoading,
|
||||
}: StockSearchFormProps) {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="종목명 또는 코드(6자리) 입력..."
|
||||
className="pl-9"
|
||||
value={keyword}
|
||||
onChange={(e) => onKeywordChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={disabled || isLoading}>
|
||||
{isLoading ? "검색 중..." : "검색"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
47
features/dashboard/components/search/StockSearchResults.tsx
Normal file
47
features/dashboard/components/search/StockSearchResults.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
// import { Activity, TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DashboardStockSearchItem } from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
interface StockSearchResultsProps {
|
||||
items: DashboardStockSearchItem[];
|
||||
onSelect: (item: DashboardStockSearchItem) => void;
|
||||
selectedSymbol?: string;
|
||||
}
|
||||
|
||||
export function StockSearchResults({
|
||||
items,
|
||||
onSelect,
|
||||
selectedSymbol,
|
||||
}: StockSearchResultsProps) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col p-2">
|
||||
{items.map((item) => {
|
||||
const isSelected = item.symbol === selectedSymbol;
|
||||
return (
|
||||
<Button
|
||||
key={item.symbol}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-auto w-full flex-col items-start gap-1 p-3 text-left",
|
||||
isSelected && "border-brand-500 bg-brand-50 hover:bg-brand-100",
|
||||
)}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span className="font-semibold truncate">{item.name}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{item.symbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{item.market}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user