Files
auto-trade/features/dashboard/components/dashboard-main.tsx
2026-02-06 17:50:35 +09:00

1000 lines
37 KiB
TypeScript

"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>
);
}