대시보드

This commit is contained in:
2026-02-06 17:50:35 +09:00
parent 35916430b7
commit 851a2acd69
34 changed files with 45632 additions and 108 deletions

View File

@@ -0,0 +1,999 @@
"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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
import rawStocks from "@/features/dashboard/data/korean-stocks.json";
import type { KoreanStockIndexItem } from "@/features/dashboard/types/dashboard.types";
/**
* 국내주식 검색 인덱스(KOSPI + KOSDAQ)
* - 파일 원본: korean-stocks.json
* - 사용처: /api/kis/domestic/search 라우트의 메모리 검색
* @see app/api/kis/domestic/search/route.ts 종목명/종목코드 검색에 사용합니다.
*/
export const KOREAN_STOCK_INDEX = rawStocks as KoreanStockIndexItem[];

View File

@@ -0,0 +1,126 @@
/**
* @file features/dashboard/data/mock-stocks.ts
* @description 대시보드 1단계 UI 검증용 목업 종목 데이터
* @remarks
* - 한국투자증권 API 연동 전까지 화면 동작 검증에 사용합니다.
* - 2단계 이후 실제 화면은 app/api/kis/* 응답을 사용합니다.
* - 현재는 레거시/비교용 샘플 데이터로만 남겨둔 상태입니다.
*/
import type { DashboardStockItem } from "@/features/dashboard/types/dashboard.types";
/**
* 대시보드 목업 종목 목록
* @see app/(main)/dashboard/page.tsx DashboardPage가 DashboardMain을 통해 이 데이터를 조회합니다.
* @see features/dashboard/components/dashboard-main.tsx 검색/차트/지표 카드의 기본 데이터 소스입니다.
*/
export const MOCK_STOCKS: DashboardStockItem[] = [
{
symbol: "005930",
name: "삼성전자",
market: "KOSPI",
currentPrice: 78500,
change: 1200,
changeRate: 1.55,
open: 77300,
high: 78900,
low: 77000,
prevClose: 77300,
volume: 15234012,
candles: [
{ time: "09:00", price: 74400 },
{ time: "09:10", price: 74650 },
{ time: "09:20", price: 75100 },
{ time: "09:30", price: 74950 },
{ time: "09:40", price: 75300 },
{ time: "09:50", price: 75600 },
{ time: "10:00", price: 75400 },
{ time: "10:10", price: 75850 },
{ time: "10:20", price: 76100 },
{ time: "10:30", price: 75950 },
{ time: "10:40", price: 76350 },
{ time: "10:50", price: 76700 },
{ time: "11:00", price: 76900 },
{ time: "11:10", price: 77250 },
{ time: "11:20", price: 77100 },
{ time: "11:30", price: 77400 },
{ time: "11:40", price: 77700 },
{ time: "11:50", price: 78150 },
{ time: "12:00", price: 77900 },
{ time: "12:10", price: 78300 },
{ time: "12:20", price: 78500 },
],
},
{
symbol: "000660",
name: "SK하이닉스",
market: "KOSPI",
currentPrice: 214500,
change: -1500,
changeRate: -0.69,
open: 216000,
high: 218000,
low: 213000,
prevClose: 216000,
volume: 3210450,
candles: [
{ time: "09:00", price: 221000 },
{ time: "09:10", price: 220400 },
{ time: "09:20", price: 219900 },
{ time: "09:30", price: 220200 },
{ time: "09:40", price: 219300 },
{ time: "09:50", price: 218500 },
{ time: "10:00", price: 217900 },
{ time: "10:10", price: 218300 },
{ time: "10:20", price: 217600 },
{ time: "10:30", price: 216900 },
{ time: "10:40", price: 216500 },
{ time: "10:50", price: 216800 },
{ time: "11:00", price: 215900 },
{ time: "11:10", price: 215300 },
{ time: "11:20", price: 214800 },
{ time: "11:30", price: 215100 },
{ time: "11:40", price: 214200 },
{ time: "11:50", price: 214700 },
{ time: "12:00", price: 214300 },
{ time: "12:10", price: 214600 },
{ time: "12:20", price: 214500 },
],
},
{
symbol: "035420",
name: "NAVER",
market: "KOSPI",
currentPrice: 197800,
change: 2200,
changeRate: 1.12,
open: 195500,
high: 198600,
low: 194900,
prevClose: 195600,
volume: 1904123,
candles: [
{ time: "09:00", price: 191800 },
{ time: "09:10", price: 192400 },
{ time: "09:20", price: 193000 },
{ time: "09:30", price: 192700 },
{ time: "09:40", price: 193600 },
{ time: "09:50", price: 194200 },
{ time: "10:00", price: 194000 },
{ time: "10:10", price: 194900 },
{ time: "10:20", price: 195100 },
{ time: "10:30", price: 194700 },
{ time: "10:40", price: 195800 },
{ time: "10:50", price: 196400 },
{ time: "11:00", price: 196100 },
{ time: "11:10", price: 196900 },
{ time: "11:20", price: 197200 },
{ time: "11:30", price: 197000 },
{ time: "11:40", price: 197600 },
{ time: "11:50", price: 198000 },
{ time: "12:00", price: 197400 },
{ time: "12:10", price: 198300 },
{ time: "12:20", price: 197800 },
],
},
];

View File

@@ -0,0 +1,140 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { KisTradingEnv } from "@/features/dashboard/types/dashboard.types";
/**
* @file features/dashboard/store/use-kis-runtime-store.ts
* @description KIS 키 입력/검증 상태를 zustand로 관리하고 새로고침 시 복원합니다.
*/
export interface KisRuntimeCredentials {
appKey: string;
appSecret: string;
tradingEnv: KisTradingEnv;
}
interface KisRuntimeStoreState {
// [State] 입력 폼 상태
kisTradingEnvInput: KisTradingEnv;
kisAppKeyInput: string;
kisAppSecretInput: string;
// [State] 검증/연동 상태
verifiedCredentials: KisRuntimeCredentials | null;
isKisVerified: boolean;
tradingEnv: KisTradingEnv;
}
interface KisRuntimeStoreActions {
/**
* 거래 모드 입력값을 변경하고 기존 검증 상태를 무효화합니다.
* @param tradingEnv 거래 모드
* @see features/dashboard/components/dashboard-main.tsx 거래 모드 버튼 onClick 이벤트
*/
setKisTradingEnvInput: (tradingEnv: KisTradingEnv) => void;
/**
* 앱 키 입력값을 변경하고 기존 검증 상태를 무효화합니다.
* @param appKey 앱 키
* @see features/dashboard/components/dashboard-main.tsx App Key onChange 이벤트
*/
setKisAppKeyInput: (appKey: string) => void;
/**
* 앱 시크릿 입력값을 변경하고 기존 검증 상태를 무효화합니다.
* @param appSecret 앱 시크릿
* @see features/dashboard/components/dashboard-main.tsx App Secret onChange 이벤트
*/
setKisAppSecretInput: (appSecret: string) => void;
/**
* 검증 성공 상태를 저장합니다.
* @param credentials 검증 완료된 키
* @param tradingEnv 현재 연동 모드
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis
*/
setVerifiedKisSession: (credentials: KisRuntimeCredentials, tradingEnv: KisTradingEnv) => void;
/**
* 검증 실패 또는 입력 변경 시 검증 상태만 초기화합니다.
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis catch
*/
invalidateKisVerification: () => void;
/**
* 접근 폐기 시 입력값/검증값을 모두 제거합니다.
* @param tradingEnv 표시용 모드
* @see features/dashboard/components/dashboard-main.tsx handleRevokeKis
*/
clearKisRuntimeSession: (tradingEnv: KisTradingEnv) => void;
}
const INITIAL_STATE: KisRuntimeStoreState = {
kisTradingEnvInput: "real",
kisAppKeyInput: "",
kisAppSecretInput: "",
verifiedCredentials: null,
isKisVerified: false,
tradingEnv: "real",
};
export const useKisRuntimeStore = create<KisRuntimeStoreState & KisRuntimeStoreActions>()(
persist(
(set) => ({
...INITIAL_STATE,
setKisTradingEnvInput: (tradingEnv) =>
set({
kisTradingEnvInput: tradingEnv,
verifiedCredentials: null,
isKisVerified: false,
}),
setKisAppKeyInput: (appKey) =>
set({
kisAppKeyInput: appKey,
verifiedCredentials: null,
isKisVerified: false,
}),
setKisAppSecretInput: (appSecret) =>
set({
kisAppSecretInput: appSecret,
verifiedCredentials: null,
isKisVerified: false,
}),
setVerifiedKisSession: (credentials, tradingEnv) =>
set({
verifiedCredentials: credentials,
isKisVerified: true,
tradingEnv,
}),
invalidateKisVerification: () =>
set({
verifiedCredentials: null,
isKisVerified: false,
}),
clearKisRuntimeSession: (tradingEnv) =>
set({
kisTradingEnvInput: tradingEnv,
kisAppKeyInput: "",
kisAppSecretInput: "",
verifiedCredentials: null,
isKisVerified: false,
tradingEnv,
}),
}),
{
name: "autotrade-kis-runtime-store",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
kisTradingEnvInput: state.kisTradingEnvInput,
kisAppKeyInput: state.kisAppKeyInput,
kisAppSecretInput: state.kisAppSecretInput,
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
tradingEnv: state.tradingEnv,
}),
},
),
);

View File

@@ -0,0 +1,108 @@
/**
* @file features/dashboard/types/dashboard.types.ts
* @description 대시보드(검색/시세/차트)에서 공통으로 쓰는 타입 모음
*/
export type KisTradingEnv = "real" | "mock";
export type DashboardPriceSource = "inquire-price" | "inquire-ccnl" | "inquire-overtime-price";
export type DashboardMarketPhase = "regular" | "afterHours";
/**
* KOSPI/KOSDAQ 종목 인덱스 항목
*/
export interface KoreanStockIndexItem {
symbol: string;
name: string;
market: "KOSPI" | "KOSDAQ";
standardCode: string;
}
/**
* 차트 1개 점(시점 + 가격)
*/
export interface StockCandlePoint {
time: string;
price: number;
}
/**
* 대시보드 종목 상세 모델
*/
export interface DashboardStockItem {
symbol: string;
name: string;
market: "KOSPI" | "KOSDAQ";
currentPrice: number;
change: number;
changeRate: number;
open: number;
high: number;
low: number;
prevClose: number;
volume: number;
candles: StockCandlePoint[];
}
/**
* 검색 결과 1개 항목
*/
export interface DashboardStockSearchItem {
symbol: string;
name: string;
market: "KOSPI" | "KOSDAQ";
}
/**
* 종목 검색 API 응답
*/
export interface DashboardStockSearchResponse {
query: string;
items: DashboardStockSearchItem[];
total: number;
}
/**
* 종목 개요 API 응답
*/
export interface DashboardStockOverviewResponse {
stock: DashboardStockItem;
source: "kis";
priceSource: DashboardPriceSource;
marketPhase: DashboardMarketPhase;
tradingEnv: KisTradingEnv;
fetchedAt: string;
}
/**
* KIS 키 검증 API 응답
*/
export interface DashboardKisValidateResponse {
ok: boolean;
tradingEnv: KisTradingEnv;
message: string;
sample?: {
symbol: string;
name: string;
currentPrice: number;
};
}
/**
* KIS 키 접근 폐기 API 응답
*/
export interface DashboardKisRevokeResponse {
ok: boolean;
tradingEnv: KisTradingEnv;
message: string;
}
/**
* KIS 웹소켓 승인키 발급 API 응답
*/
export interface DashboardKisWsApprovalResponse {
ok: boolean;
tradingEnv: KisTradingEnv;
message: string;
approvalKey?: string;
wsUrl?: string;
}