대시보드 구현
This commit is contained in:
@@ -21,3 +21,9 @@ KIS_APP_KEY_MOCK=
|
||||
KIS_APP_SECRET_MOCK=
|
||||
KIS_BASE_URL_MOCK=https://openapivts.koreainvestment.com:29443
|
||||
KIS_WS_URL_MOCK=ws://ops.koreainvestment.com:31000
|
||||
|
||||
# (선택) 서버에서 기본 계좌번호를 사용할 경우
|
||||
# 형식: KIS_ACCOUNT_NO=12345678 또는 12345678-01
|
||||
# KIS_ACCOUNT_PRODUCT_CODE=01
|
||||
KIS_ACCOUNT_NO=
|
||||
KIS_ACCOUNT_PRODUCT_CODE=
|
||||
|
||||
Submodule .tmp/open-trading-api deleted from aea5e779da
@@ -4,13 +4,13 @@
|
||||
*/
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { DashboardContainer } from "@/features/dashboard/components/DashboardContainer";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
|
||||
/**
|
||||
* 대시보드 페이지 (향후 확장용)
|
||||
* @returns 빈 대시보드 안내 UI
|
||||
* @see app/(main)/trade/page.tsx 트레이딩 기능은 `/trade` 경로에서 제공합니다.
|
||||
* @see app/(main)/settings/page.tsx KIS 인증 설정은 `/settings` 경로에서 제공합니다.
|
||||
* 대시보드 페이지
|
||||
* @returns DashboardContainer UI
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 상태 헤더/지수/보유종목 UI를 제공합니다.
|
||||
*/
|
||||
export default async function DashboardPage() {
|
||||
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
|
||||
@@ -21,17 +21,5 @@ export default async function DashboardPage() {
|
||||
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return (
|
||||
<section className="mx-auto flex h-full w-full max-w-5xl flex-col justify-center p-6">
|
||||
{/* ========== DASHBOARD PLACEHOLDER ========== */}
|
||||
<div className="rounded-2xl border border-brand-200 bg-background p-8 shadow-sm dark:border-brand-800/45 dark:bg-brand-900/14">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
대시보드
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
이 페이지는 향후 포트폴리오 요약과 리포트 기능을 위한 확장 영역입니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
return <DashboardContainer />;
|
||||
}
|
||||
|
||||
45
app/api/kis/domestic/_shared.ts
Normal file
45
app/api/kis/domestic/_shared.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { parseKisAccountParts } from "@/lib/kis/account";
|
||||
import {
|
||||
normalizeTradingEnv,
|
||||
type KisCredentialInput,
|
||||
} from "@/lib/kis/config";
|
||||
|
||||
/**
|
||||
* @description 요청 헤더에서 KIS 키를 읽어옵니다.
|
||||
* @param headers 요청 헤더
|
||||
* @returns KIS 인증 입력값
|
||||
* @see app/api/kis/domestic/balance/route.ts 대시보드 잔고 API 인증키 파싱
|
||||
* @see app/api/kis/domestic/indices/route.ts 대시보드 지수 API 인증키 파싱
|
||||
*/
|
||||
export function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
|
||||
const appKey = headers.get("x-kis-app-key")?.trim();
|
||||
const appSecret = headers.get("x-kis-app-secret")?.trim();
|
||||
const tradingEnv = normalizeTradingEnv(
|
||||
headers.get("x-kis-trading-env") ?? undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
appKey,
|
||||
appSecret,
|
||||
tradingEnv,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 요청 헤더(또는 서버 환경변수)에서 계좌번호(8-2)를 읽어옵니다.
|
||||
* @param headers 요청 헤더
|
||||
* @returns 계좌번호 파트(8 + 2) 또는 null
|
||||
* @see app/api/kis/domestic/balance/route.ts 잔고 조회 시 필수 계좌정보 파싱
|
||||
*/
|
||||
export function readKisAccountParts(headers: Headers) {
|
||||
const headerAccountNo = headers.get("x-kis-account-no");
|
||||
const headerAccountProductCode = headers.get("x-kis-account-product-code");
|
||||
|
||||
const envAccountNo = process.env.KIS_ACCOUNT_NO;
|
||||
const envAccountProductCode = process.env.KIS_ACCOUNT_PRODUCT_CODE;
|
||||
|
||||
return (
|
||||
parseKisAccountParts(headerAccountNo, headerAccountProductCode) ??
|
||||
parseKisAccountParts(envAccountNo, envAccountProductCode)
|
||||
);
|
||||
}
|
||||
65
app/api/kis/domestic/balance/route.ts
Normal file
65
app/api/kis/domestic/balance/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { DashboardBalanceResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { getDomesticDashboardBalance } from "@/lib/kis/dashboard";
|
||||
import {
|
||||
readKisAccountParts,
|
||||
readKisCredentialsFromHeaders,
|
||||
} from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/balance/route.ts
|
||||
* @description 국내주식 계좌 잔고/보유종목 조회 API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 대시보드 잔고 조회 API
|
||||
* @returns 총자산/손익/보유종목 목록
|
||||
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/새로고침에서 호출합니다.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "KIS API 키 설정이 필요합니다.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const account = readKisAccountParts(request.headers);
|
||||
if (!account) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getDomesticDashboardBalance(account, credentials);
|
||||
const response: DashboardBalanceResponse = {
|
||||
source: "kis",
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
summary: result.summary,
|
||||
holdings: result.holdings,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "잔고 조회 중 오류가 발생했습니다.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
50
app/api/kis/domestic/indices/route.ts
Normal file
50
app/api/kis/domestic/indices/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
|
||||
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/indices/route.ts
|
||||
* @description 국내 주요 지수(KOSPI/KOSDAQ) 조회 API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 대시보드 지수 조회 API
|
||||
* @returns 코스피/코스닥 지수 목록
|
||||
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/주기 갱신에서 호출합니다.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "KIS API 키 설정이 필요합니다.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await getDomesticDashboardIndices(credentials);
|
||||
const response: DashboardIndicesResponse = {
|
||||
source: "kis",
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
items,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "지수 조회 중 오류가 발생했습니다.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,12 @@ import { SESSION_TIMEOUT_MS } from "@/features/auth/constants";
|
||||
// 설정: 경고 표시 시간 (타임아웃 1분 전) - 현재 미사용 (즉시 로그아웃)
|
||||
// const WARNING_MS = 60 * 1000;
|
||||
|
||||
const SESSION_RELATED_STORAGE_KEYS = [
|
||||
"session-storage",
|
||||
"auth-storage",
|
||||
"autotrade-kis-runtime-store",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 세션 관리자 컴포넌트
|
||||
* 사용자 활동을 감지하여 세션 연장 및 타임아웃 처리
|
||||
@@ -51,6 +57,18 @@ export function SessionManager() {
|
||||
|
||||
const { setLastActive } = useSessionStore();
|
||||
|
||||
/**
|
||||
* @description 세션 만료 로그아웃 시 세션 관련 로컬 스토리지를 정리합니다.
|
||||
* @see features/layout/components/user-menu.tsx 수동 로그아웃 경로에서도 동일한 키를 제거합니다.
|
||||
*/
|
||||
const clearSessionRelatedStorage = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
for (const key of SESSION_RELATED_STORAGE_KEYS) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 로그아웃 처리 핸들러
|
||||
* @see session-timer.tsx - 타임아웃 시 동일한 로직 필요 가능성 있음
|
||||
@@ -64,11 +82,12 @@ export function SessionManager() {
|
||||
|
||||
// [Step 3] 로컬 스토어 및 세션 정보 초기화
|
||||
useSessionStore.persist.clearStorage();
|
||||
clearSessionRelatedStorage();
|
||||
|
||||
// [Step 4] 로그인 페이지로 리다이렉트 및 메시지 표시
|
||||
router.push("/login?message=세션이 만료되었습니다. 다시 로그인해주세요.");
|
||||
router.refresh();
|
||||
}, [router]);
|
||||
}, [clearSessionRelatedStorage, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthPage) return;
|
||||
@@ -79,6 +98,10 @@ export function SessionManager() {
|
||||
if (showWarning) setShowWarning(false);
|
||||
};
|
||||
|
||||
// [Step 0] 인증 페이지에서 메인 페이지로 진입한 직후를 "활동"으로 간주해
|
||||
// 이전 세션 잔여 시간(예: 00:00)으로 즉시 로그아웃되는 현상을 방지합니다.
|
||||
updateLastActive();
|
||||
|
||||
// [Step 1] 사용자 활동 이벤트 감지 (마우스, 키보드, 스크롤, 터치)
|
||||
const events = ["mousedown", "keydown", "scroll", "touchstart"];
|
||||
const handleActivity = () => updateLastActive();
|
||||
|
||||
86
features/dashboard/apis/dashboard.api.ts
Normal file
86
features/dashboard/apis/dashboard.api.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardBalanceResponse,
|
||||
DashboardIndicesResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
/**
|
||||
* @file features/dashboard/apis/dashboard.api.ts
|
||||
* @description 대시보드 잔고/지수 API 클라이언트
|
||||
*/
|
||||
|
||||
/**
|
||||
* 계좌 잔고/보유종목을 조회합니다.
|
||||
* @param credentials KIS 인증 정보
|
||||
* @returns 잔고 응답
|
||||
* @see app/api/kis/domestic/balance/route.ts 서버 라우트
|
||||
*/
|
||||
export async function fetchDashboardBalance(
|
||||
credentials: KisRuntimeCredentials,
|
||||
): Promise<DashboardBalanceResponse> {
|
||||
const response = await fetch("/api/kis/domestic/balance", {
|
||||
method: "GET",
|
||||
headers: buildKisRequestHeaders(credentials),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as
|
||||
| DashboardBalanceResponse
|
||||
| { error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
"error" in payload ? payload.error : "잔고 조회 중 오류가 발생했습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
return payload as DashboardBalanceResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시장 지수(KOSPI/KOSDAQ)를 조회합니다.
|
||||
* @param credentials KIS 인증 정보
|
||||
* @returns 지수 응답
|
||||
* @see app/api/kis/domestic/indices/route.ts 서버 라우트
|
||||
*/
|
||||
export async function fetchDashboardIndices(
|
||||
credentials: KisRuntimeCredentials,
|
||||
): Promise<DashboardIndicesResponse> {
|
||||
const response = await fetch("/api/kis/domestic/indices", {
|
||||
method: "GET",
|
||||
headers: buildKisRequestHeaders(credentials),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as
|
||||
| DashboardIndicesResponse
|
||||
| { error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
"error" in payload ? payload.error : "지수 조회 중 오류가 발생했습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
return payload as DashboardIndicesResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 API 공통 헤더를 구성합니다.
|
||||
* @param credentials KIS 인증 정보
|
||||
* @returns KIS 전달 헤더
|
||||
* @see features/dashboard/apis/dashboard.api.ts fetchDashboardBalance/fetchDashboardIndices
|
||||
*/
|
||||
function buildKisRequestHeaders(credentials: KisRuntimeCredentials) {
|
||||
const headers: Record<string, string> = {
|
||||
"x-kis-app-key": credentials.appKey,
|
||||
"x-kis-app-secret": credentials.appSecret,
|
||||
"x-kis-trading-env": credentials.tradingEnv,
|
||||
};
|
||||
|
||||
if (credentials.accountNo?.trim()) {
|
||||
headers["x-kis-account-no"] = credentials.accountNo.trim();
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
37
features/dashboard/components/DashboardAccessGate.tsx
Normal file
37
features/dashboard/components/DashboardAccessGate.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface DashboardAccessGateProps {
|
||||
canAccess: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description KIS 인증 여부에 따라 대시보드 접근 가이드를 렌더링합니다.
|
||||
* @param canAccess 대시보드 접근 가능 여부
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 인증되지 않은 경우 이 컴포넌트를 렌더링합니다.
|
||||
*/
|
||||
export function DashboardAccessGate({ canAccess }: DashboardAccessGateProps) {
|
||||
if (canAccess) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<section className="w-full max-w-xl rounded-2xl border border-brand-200 bg-background p-6 shadow-sm dark:border-brand-800/45 dark:bg-brand-900/18">
|
||||
{/* ========== UNVERIFIED NOTICE ========== */}
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
대시보드를 보려면 KIS API 인증이 필요합니다.
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
설정 페이지에서 App Key/App Secret(그리고 계좌번호)을 입력하고 연결을
|
||||
완료해 주세요.
|
||||
</p>
|
||||
|
||||
{/* ========== ACTION ========== */}
|
||||
<div className="mt-4">
|
||||
<Button asChild className="bg-brand-600 hover:bg-brand-700">
|
||||
<Link href="/settings">설정 페이지로 이동</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
features/dashboard/components/DashboardContainer.tsx
Normal file
115
features/dashboard/components/DashboardContainer.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { DashboardAccessGate } from "@/features/dashboard/components/DashboardAccessGate";
|
||||
import { DashboardSkeleton } from "@/features/dashboard/components/DashboardSkeleton";
|
||||
import { HoldingsList } from "@/features/dashboard/components/HoldingsList";
|
||||
import { MarketSummary } from "@/features/dashboard/components/MarketSummary";
|
||||
import { StatusHeader } from "@/features/dashboard/components/StatusHeader";
|
||||
import { StockDetailPreview } from "@/features/dashboard/components/StockDetailPreview";
|
||||
import { useDashboardData } from "@/features/dashboard/hooks/use-dashboard-data";
|
||||
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
||||
|
||||
/**
|
||||
* @description 대시보드 메인 컨테이너입니다.
|
||||
* @remarks UI 흐름: 대시보드 진입 -> useDashboardData API 호출 -> StatusHeader/MarketSummary/HoldingsList/StockDetailPreview 순으로 렌더링
|
||||
* @see app/(main)/dashboard/page.tsx 로그인 완료 후 이 컴포넌트를 렌더링합니다.
|
||||
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 데이터 조회/갱신 상태를 관리합니다.
|
||||
*/
|
||||
export function DashboardContainer() {
|
||||
const {
|
||||
verifiedCredentials,
|
||||
isKisVerified,
|
||||
_hasHydrated,
|
||||
wsApprovalKey,
|
||||
wsUrl,
|
||||
} = useKisRuntimeStore(
|
||||
useShallow((state) => ({
|
||||
verifiedCredentials: state.verifiedCredentials,
|
||||
isKisVerified: state.isKisVerified,
|
||||
_hasHydrated: state._hasHydrated,
|
||||
wsApprovalKey: state.wsApprovalKey,
|
||||
wsUrl: state.wsUrl,
|
||||
})),
|
||||
);
|
||||
|
||||
const canAccess = isKisVerified && Boolean(verifiedCredentials);
|
||||
|
||||
const {
|
||||
balance,
|
||||
indices,
|
||||
selectedHolding,
|
||||
selectedSymbol,
|
||||
setSelectedSymbol,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
balanceError,
|
||||
indicesError,
|
||||
lastUpdatedAt,
|
||||
refresh,
|
||||
} = useDashboardData(canAccess ? verifiedCredentials : null);
|
||||
|
||||
const isKisRestConnected = useMemo(() => {
|
||||
if (indices.length > 0) return true;
|
||||
if (balance && !balanceError) return true;
|
||||
return false;
|
||||
}, [balance, balanceError, indices.length]);
|
||||
|
||||
if (!_hasHydrated) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canAccess) {
|
||||
return <DashboardAccessGate canAccess={canAccess} />;
|
||||
}
|
||||
|
||||
if (isLoading && !balance && indices.length === 0) {
|
||||
return <DashboardSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto flex w-full max-w-7xl flex-col gap-4 p-4 md:p-6">
|
||||
{/* ========== STATUS HEADER ========== */}
|
||||
<StatusHeader
|
||||
summary={balance?.summary ?? null}
|
||||
isKisRestConnected={isKisRestConnected}
|
||||
isWebSocketReady={Boolean(wsApprovalKey && wsUrl)}
|
||||
isRefreshing={isRefreshing}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
onRefresh={() => {
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ========== MAIN CONTENT GRID ========== */}
|
||||
<div className="grid gap-4 lg:grid-cols-[1.15fr_1fr]">
|
||||
<HoldingsList
|
||||
holdings={balance?.holdings ?? []}
|
||||
selectedSymbol={selectedSymbol}
|
||||
isLoading={isLoading}
|
||||
error={balanceError}
|
||||
onSelect={setSelectedSymbol}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<MarketSummary
|
||||
items={indices}
|
||||
isLoading={isLoading}
|
||||
error={indicesError}
|
||||
/>
|
||||
|
||||
<StockDetailPreview
|
||||
holding={selectedHolding}
|
||||
totalAmount={balance?.summary.totalAmount ?? 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
59
features/dashboard/components/DashboardSkeleton.tsx
Normal file
59
features/dashboard/components/DashboardSkeleton.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* @description 대시보드 초기 로딩 스켈레톤 UI입니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx isLoading 상태에서 렌더링합니다.
|
||||
*/
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 p-4 md:p-6">
|
||||
{/* ========== HEADER SKELETON ========== */}
|
||||
<Card className="border-brand-200 dark:border-brand-800/50">
|
||||
<CardContent className="grid gap-3 p-4 md:grid-cols-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ========== BODY SKELETON ========== */}
|
||||
<div className="grid gap-4 lg:grid-cols-[1.15fr_1fr]">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-14 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-14 w-full" />
|
||||
<Skeleton className="h-14 w-full" />
|
||||
<Skeleton className="h-14 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
features/dashboard/components/HoldingsList.tsx
Normal file
123
features/dashboard/components/HoldingsList.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { AlertCircle, Wallet2 } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatPercent,
|
||||
getChangeToneClass,
|
||||
} from "@/features/dashboard/utils/dashboard-format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface HoldingsListProps {
|
||||
holdings: DashboardHoldingItem[];
|
||||
selectedSymbol: string | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onSelect: (symbol: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 보유 종목 리스트 카드입니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 좌측 메인 영역에서 호출합니다.
|
||||
*/
|
||||
export function HoldingsList({
|
||||
holdings,
|
||||
selectedSymbol,
|
||||
isLoading,
|
||||
error,
|
||||
onSelect,
|
||||
}: HoldingsListProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
{/* ========== TITLE ========== */}
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Wallet2 className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||
보유 종목
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
현재 보유 중인 종목을 선택하면 우측 상세가 갱신됩니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isLoading && holdings.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">보유 종목을 불러오는 중입니다.</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mb-2 flex items-start gap-1.5 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && holdings.length === 0 && !error && (
|
||||
<p className="text-sm text-muted-foreground">보유 종목이 없습니다.</p>
|
||||
)}
|
||||
|
||||
{holdings.length > 0 && (
|
||||
<ScrollArea className="h-[420px] pr-3">
|
||||
<div className="space-y-2">
|
||||
{holdings.map((holding) => {
|
||||
const isSelected = selectedSymbol === holding.symbol;
|
||||
const toneClass = getChangeToneClass(holding.profitLoss);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={holding.symbol}
|
||||
type="button"
|
||||
onClick={() => onSelect(holding.symbol)}
|
||||
className={cn(
|
||||
"w-full rounded-xl border px-3 py-3 text-left transition-all",
|
||||
isSelected
|
||||
? "border-brand-400 bg-brand-50/60 ring-1 ring-brand-300 dark:border-brand-600 dark:bg-brand-900/20 dark:ring-brand-700"
|
||||
: "border-border/70 bg-background hover:border-brand-200 hover:bg-brand-50/30 dark:hover:border-brand-700 dark:hover:bg-brand-900/15",
|
||||
)}
|
||||
>
|
||||
{/* ========== ROW TOP ========== */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{holding.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{holding.symbol} · {holding.market} · {holding.quantity}주
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{formatCurrency(holding.currentPrice)}원
|
||||
</p>
|
||||
<p className={cn("text-xs font-medium", toneClass)}>
|
||||
{formatPercent(holding.profitRate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== ROW BOTTOM ========== */}
|
||||
<div className="mt-2 flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
평가금액 {formatCurrency(holding.evaluationAmount)}원
|
||||
</span>
|
||||
<span className={cn("font-medium", toneClass)}>
|
||||
손익 {formatCurrency(holding.profitLoss)}원
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
85
features/dashboard/components/MarketSummary.tsx
Normal file
85
features/dashboard/components/MarketSummary.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { AlertCircle, BarChart3 } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type { DashboardMarketIndexItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatSignedCurrency,
|
||||
formatSignedPercent,
|
||||
getChangeToneClass,
|
||||
} from "@/features/dashboard/utils/dashboard-format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarketSummaryProps {
|
||||
items: DashboardMarketIndexItem[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 코스피/코스닥 지수 요약 카드입니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 우측 상단 영역에서 호출합니다.
|
||||
*/
|
||||
export function MarketSummary({ items, isLoading, error }: MarketSummaryProps) {
|
||||
return (
|
||||
<Card className="border-brand-200/80 dark:border-brand-800/45">
|
||||
<CardHeader className="pb-3">
|
||||
{/* ========== TITLE ========== */}
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<BarChart3 className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||
시장 요약
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
KOSPI/KOSDAQ 주요 지수 변동을 보여줍니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2">
|
||||
{isLoading && items.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">지수 데이터를 불러오는 중입니다.</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.map((item) => {
|
||||
const toneClass = getChangeToneClass(item.change);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.code}
|
||||
className="rounded-xl border border-border/70 bg-background/70 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{item.market}</p>
|
||||
<p className="text-sm font-semibold text-foreground">{item.name}</p>
|
||||
</div>
|
||||
<p className="text-lg font-semibold tracking-tight">
|
||||
{formatCurrency(item.price)}
|
||||
</p>
|
||||
</div>
|
||||
<div className={cn("mt-1 flex items-center gap-2 text-xs font-medium", toneClass)}>
|
||||
<span>{formatSignedCurrency(item.change)}</span>
|
||||
<span>{formatSignedPercent(item.changeRate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!isLoading && items.length === 0 && !error && (
|
||||
<p className="text-sm text-muted-foreground">표시할 지수 데이터가 없습니다.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
128
features/dashboard/components/StatusHeader.tsx
Normal file
128
features/dashboard/components/StatusHeader.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import Link from "next/link";
|
||||
import { Activity, RefreshCcw, Settings2, Wifi } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import type { DashboardBalanceSummary } from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatPercent,
|
||||
getChangeToneClass,
|
||||
} from "@/features/dashboard/utils/dashboard-format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StatusHeaderProps {
|
||||
summary: DashboardBalanceSummary | null;
|
||||
isKisRestConnected: boolean;
|
||||
isWebSocketReady: boolean;
|
||||
isRefreshing: boolean;
|
||||
lastUpdatedAt: string | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 대시보드 상단 상태 헤더(총자산/손익/연결상태/빠른액션) 컴포넌트입니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 루트에서 상태 값을 전달받아 렌더링합니다.
|
||||
*/
|
||||
export function StatusHeader({
|
||||
summary,
|
||||
isKisRestConnected,
|
||||
isWebSocketReady,
|
||||
isRefreshing,
|
||||
lastUpdatedAt,
|
||||
onRefresh,
|
||||
}: StatusHeaderProps) {
|
||||
const toneClass = getChangeToneClass(summary?.totalProfitLoss ?? 0);
|
||||
const updatedLabel = lastUpdatedAt
|
||||
? new Date(lastUpdatedAt).toLocaleTimeString("ko-KR", {
|
||||
hour12: false,
|
||||
})
|
||||
: "--:--:--";
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden border-brand-200 shadow-sm dark:border-brand-800/50">
|
||||
{/* ========== BACKGROUND DECORATION ========== */}
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-20 bg-linear-to-r from-brand-100/70 via-brand-50/50 to-transparent dark:from-brand-900/35 dark:via-brand-900/20" />
|
||||
|
||||
<CardContent className="relative grid gap-3 p-4 md:grid-cols-[1fr_1fr_1fr_auto]">
|
||||
{/* ========== TOTAL ASSET ========== */}
|
||||
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">총 자산</p>
|
||||
<p className="mt-1 text-xl font-semibold tracking-tight">
|
||||
{summary ? `${formatCurrency(summary.totalAmount)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
예수금 {summary ? `${formatCurrency(summary.cashBalance)}원` : "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ========== PROFIT/LOSS ========== */}
|
||||
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">실시간 손익</p>
|
||||
<p className={cn("mt-1 text-xl font-semibold tracking-tight", toneClass)}>
|
||||
{summary ? `${formatCurrency(summary.totalProfitLoss)}원` : "-"}
|
||||
</p>
|
||||
<p className={cn("mt-1 text-xs font-medium", toneClass)}>
|
||||
{summary ? formatPercent(summary.totalProfitRate) : "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ========== CONNECTION STATUS ========== */}
|
||||
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">시스템 상태</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs font-medium">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1",
|
||||
isKisRestConnected
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
)}
|
||||
>
|
||||
<Wifi className="h-3.5 w-3.5" />
|
||||
REST {isKisRestConnected ? "연결됨" : "연결 끊김"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1",
|
||||
isWebSocketReady
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
WS {isWebSocketReady ? "준비됨" : "미연결"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
마지막 갱신 {updatedLabel}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ========== QUICK ACTIONS ========== */}
|
||||
<div className="flex items-end gap-2 md:flex-col md:items-stretch md:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="w-full border-brand-200 text-brand-700 hover:bg-brand-50 dark:border-brand-700 dark:text-brand-300 dark:hover:bg-brand-900/30"
|
||||
>
|
||||
<RefreshCcw
|
||||
className={cn("h-4 w-4", isRefreshing ? "animate-spin" : "")}
|
||||
/>
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
className="w-full bg-brand-600 text-white hover:bg-brand-700"
|
||||
>
|
||||
<Link href="/settings">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
자동매매 설정
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
141
features/dashboard/components/StockDetailPreview.tsx
Normal file
141
features/dashboard/components/StockDetailPreview.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { BarChartBig, MousePointerClick } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatPercent,
|
||||
getChangeToneClass,
|
||||
} from "@/features/dashboard/utils/dashboard-format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StockDetailPreviewProps {
|
||||
holding: DashboardHoldingItem | null;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 선택 종목 상세 요약 카드입니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx HoldingsList 선택 결과를 전달받아 렌더링합니다.
|
||||
*/
|
||||
export function StockDetailPreview({
|
||||
holding,
|
||||
totalAmount,
|
||||
}: StockDetailPreviewProps) {
|
||||
if (!holding) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<BarChartBig className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||
종목 상세 미리보기
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
보유 종목을 선택하면 상세 요약이 표시됩니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
왼쪽 보유 종목 리스트에서 종목을 선택해 주세요.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const profitToneClass = getChangeToneClass(holding.profitLoss);
|
||||
const allocationRate =
|
||||
totalAmount > 0 ? Math.min((holding.evaluationAmount / totalAmount) * 100, 100) : 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
{/* ========== TITLE ========== */}
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<BarChartBig className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||
종목 상세 미리보기
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{holding.name} ({holding.symbol}) · {holding.market}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* ========== PRIMARY METRICS ========== */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<Metric label="보유 수량" value={`${holding.quantity.toLocaleString("ko-KR")}주`} />
|
||||
<Metric label="매입 평균가" value={`${formatCurrency(holding.averagePrice)}원`} />
|
||||
<Metric label="현재가" value={`${formatCurrency(holding.currentPrice)}원`} />
|
||||
<Metric
|
||||
label="수익률"
|
||||
value={formatPercent(holding.profitRate)}
|
||||
valueClassName={profitToneClass}
|
||||
/>
|
||||
<Metric
|
||||
label="평가손익"
|
||||
value={`${formatCurrency(holding.profitLoss)}원`}
|
||||
valueClassName={profitToneClass}
|
||||
/>
|
||||
<Metric
|
||||
label="평가금액"
|
||||
value={`${formatCurrency(holding.evaluationAmount)}원`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ========== ALLOCATION BAR ========== */}
|
||||
<div className="rounded-xl border border-border/70 bg-background/70 p-3">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>총 자산 대비 비중</span>
|
||||
<span>{formatPercent(allocationRate)}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-2 rounded-full bg-linear-to-r from-brand-500 to-brand-700"
|
||||
style={{ width: `${allocationRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== QUICK ORDER PLACEHOLDER ========== */}
|
||||
<div className="rounded-xl border border-dashed border-border/80 bg-muted/30 p-3">
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground/80">
|
||||
<MousePointerClick className="h-4 w-4 text-brand-500" />
|
||||
간편 주문(준비 중)
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
향후 이 영역에서 선택 종목의 빠른 매수/매도 기능을 제공합니다.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetricProps {
|
||||
label: string;
|
||||
value: string;
|
||||
valueClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 상세 카드에서 공통으로 사용하는 지표 행입니다.
|
||||
* @param label 지표명
|
||||
* @param value 지표값
|
||||
* @param valueClassName 값 텍스트 색상 클래스
|
||||
* @see features/dashboard/components/StockDetailPreview.tsx 종목 상세 지표 표시
|
||||
*/
|
||||
function Metric({ label, value, valueClassName }: MetricProps) {
|
||||
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={cn("mt-1 text-sm font-semibold text-foreground", valueClassName)}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
features/dashboard/hooks/use-dashboard-data.ts
Normal file
182
features/dashboard/hooks/use-dashboard-data.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import {
|
||||
fetchDashboardBalance,
|
||||
fetchDashboardIndices,
|
||||
} from "@/features/dashboard/apis/dashboard.api";
|
||||
import type {
|
||||
DashboardBalanceResponse,
|
||||
DashboardHoldingItem,
|
||||
DashboardIndicesResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
interface UseDashboardDataResult {
|
||||
balance: DashboardBalanceResponse | null;
|
||||
indices: DashboardIndicesResponse["items"];
|
||||
selectedHolding: DashboardHoldingItem | null;
|
||||
selectedSymbol: string | null;
|
||||
setSelectedSymbol: (symbol: string) => void;
|
||||
isLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
balanceError: string | null;
|
||||
indicesError: string | null;
|
||||
lastUpdatedAt: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const POLLING_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* @description 대시보드 잔고/지수 상태를 관리하는 훅입니다.
|
||||
* @param credentials KIS 인증 정보
|
||||
* @returns 대시보드 데이터/로딩/오류 상태
|
||||
* @remarks UI 흐름: 대시보드 진입 -> refresh("initial") -> balance/indices API 병렬 호출 -> 카드별 상태 반영
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 루트 컨테이너
|
||||
* @see features/dashboard/apis/dashboard.api.ts 실제 API 호출 함수
|
||||
*/
|
||||
export function useDashboardData(
|
||||
credentials: KisRuntimeCredentials | null,
|
||||
): UseDashboardDataResult {
|
||||
const [balance, setBalance] = useState<DashboardBalanceResponse | null>(null);
|
||||
const [indices, setIndices] = useState<DashboardIndicesResponse["items"]>([]);
|
||||
const [selectedSymbol, setSelectedSymbolState] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [balanceError, setBalanceError] = useState<string | null>(null);
|
||||
const [indicesError, setIndicesError] = useState<string | null>(null);
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
|
||||
|
||||
const requestSeqRef = useRef(0);
|
||||
|
||||
const hasAccountNo = Boolean(credentials?.accountNo?.trim());
|
||||
|
||||
/**
|
||||
* @description 잔고/지수 데이터를 병렬로 갱신합니다.
|
||||
* @param mode 초기 로드/수동 새로고침/주기 갱신 구분
|
||||
* @see features/dashboard/hooks/use-dashboard-data.ts useEffect 초기 호출/폴링/수동 새로고침
|
||||
*/
|
||||
const refreshInternal = useCallback(
|
||||
async (mode: "initial" | "manual" | "polling") => {
|
||||
if (!credentials) return;
|
||||
|
||||
const requestSeq = ++requestSeqRef.current;
|
||||
const isInitial = mode === "initial";
|
||||
|
||||
if (isInitial) {
|
||||
setIsLoading(true);
|
||||
} else {
|
||||
setIsRefreshing(true);
|
||||
}
|
||||
|
||||
const tasks: [
|
||||
Promise<DashboardBalanceResponse | null>,
|
||||
Promise<DashboardIndicesResponse>,
|
||||
] = [
|
||||
hasAccountNo
|
||||
? fetchDashboardBalance(credentials)
|
||||
: Promise.resolve(null),
|
||||
fetchDashboardIndices(credentials),
|
||||
];
|
||||
|
||||
const [balanceResult, indicesResult] = await Promise.allSettled(tasks);
|
||||
if (requestSeq !== requestSeqRef.current) return;
|
||||
|
||||
let hasAnySuccess = false;
|
||||
|
||||
if (!hasAccountNo) {
|
||||
setBalance(null);
|
||||
setBalanceError(
|
||||
"계좌번호가 없어 잔고를 조회할 수 없습니다. 설정에서 계좌번호(8-2)를 입력해 주세요.",
|
||||
);
|
||||
setSelectedSymbolState(null);
|
||||
} else if (balanceResult.status === "fulfilled") {
|
||||
hasAnySuccess = true;
|
||||
setBalance(balanceResult.value);
|
||||
setBalanceError(null);
|
||||
|
||||
setSelectedSymbolState((prev) => {
|
||||
const nextHoldings = balanceResult.value?.holdings ?? [];
|
||||
if (nextHoldings.length === 0) return null;
|
||||
if (prev && nextHoldings.some((item) => item.symbol === prev)) {
|
||||
return prev;
|
||||
}
|
||||
return nextHoldings[0]?.symbol ?? null;
|
||||
});
|
||||
} else {
|
||||
setBalanceError(balanceResult.reason instanceof Error ? balanceResult.reason.message : "잔고 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (indicesResult.status === "fulfilled") {
|
||||
hasAnySuccess = true;
|
||||
setIndices(indicesResult.value.items);
|
||||
setIndicesError(null);
|
||||
} else {
|
||||
setIndicesError(indicesResult.reason instanceof Error ? indicesResult.reason.message : "시장 지수 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (hasAnySuccess) {
|
||||
setLastUpdatedAt(new Date().toISOString());
|
||||
}
|
||||
|
||||
if (isInitial) {
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
},
|
||||
[credentials, hasAccountNo],
|
||||
);
|
||||
|
||||
/**
|
||||
* @description 대시보드 수동 새로고침 핸들러입니다.
|
||||
* @see features/dashboard/components/StatusHeader.tsx 새로고침 버튼 onClick
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
await refreshInternal("manual");
|
||||
}, [refreshInternal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!credentials) return;
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
void refreshInternal("initial");
|
||||
}, 0);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [credentials, refreshInternal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!credentials) return;
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void refreshInternal("polling");
|
||||
}, POLLING_INTERVAL_MS);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [credentials, refreshInternal]);
|
||||
|
||||
const selectedHolding = useMemo(() => {
|
||||
if (!selectedSymbol || !balance) return null;
|
||||
return balance.holdings.find((item) => item.symbol === selectedSymbol) ?? null;
|
||||
}, [balance, selectedSymbol]);
|
||||
|
||||
const setSelectedSymbol = useCallback((symbol: string) => {
|
||||
setSelectedSymbolState(symbol);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
balance,
|
||||
indices,
|
||||
selectedHolding,
|
||||
selectedSymbol,
|
||||
setSelectedSymbol,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
balanceError,
|
||||
indicesError,
|
||||
lastUpdatedAt,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
66
features/dashboard/types/dashboard.types.ts
Normal file
66
features/dashboard/types/dashboard.types.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @file features/dashboard/types/dashboard.types.ts
|
||||
* @description 대시보드(잔고/지수/보유종목) 전용 타입 정의
|
||||
*/
|
||||
|
||||
import type { KisTradingEnv } from "@/features/trade/types/trade.types";
|
||||
|
||||
export type DashboardMarket = "KOSPI" | "KOSDAQ";
|
||||
|
||||
/**
|
||||
* 대시보드 잔고 요약
|
||||
*/
|
||||
export interface DashboardBalanceSummary {
|
||||
totalAmount: number;
|
||||
cashBalance: number;
|
||||
totalProfitLoss: number;
|
||||
totalProfitRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 보유 종목 항목
|
||||
*/
|
||||
export interface DashboardHoldingItem {
|
||||
symbol: string;
|
||||
name: string;
|
||||
market: DashboardMarket;
|
||||
quantity: number;
|
||||
averagePrice: number;
|
||||
currentPrice: number;
|
||||
evaluationAmount: number;
|
||||
profitLoss: number;
|
||||
profitRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계좌 잔고 API 응답 모델
|
||||
*/
|
||||
export interface DashboardBalanceResponse {
|
||||
source: "kis";
|
||||
tradingEnv: KisTradingEnv;
|
||||
summary: DashboardBalanceSummary;
|
||||
holdings: DashboardHoldingItem[];
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시장 지수 항목
|
||||
*/
|
||||
export interface DashboardMarketIndexItem {
|
||||
market: DashboardMarket;
|
||||
code: string;
|
||||
name: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changeRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시장 지수 API 응답 모델
|
||||
*/
|
||||
export interface DashboardIndicesResponse {
|
||||
source: "kis";
|
||||
tradingEnv: KisTradingEnv;
|
||||
items: DashboardMarketIndexItem[];
|
||||
fetchedAt: string;
|
||||
}
|
||||
66
features/dashboard/utils/dashboard-format.ts
Normal file
66
features/dashboard/utils/dashboard-format.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @file features/dashboard/utils/dashboard-format.ts
|
||||
* @description 대시보드 숫자/색상 표현 유틸
|
||||
*/
|
||||
|
||||
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
|
||||
const PERCENT_FORMATTER = new Intl.NumberFormat("ko-KR", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* 원화 금액을 포맷합니다.
|
||||
* @param value 숫자 값
|
||||
* @returns 쉼표 포맷 문자열
|
||||
* @see features/dashboard/components/StatusHeader.tsx 자산/손익 금액 표시
|
||||
*/
|
||||
export function formatCurrency(value: number) {
|
||||
return KRW_FORMATTER.format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 퍼센트 값을 포맷합니다.
|
||||
* @param value 숫자 값
|
||||
* @returns 소수점 2자리 퍼센트 문자열
|
||||
* @see features/dashboard/components/StatusHeader.tsx 수익률 표시
|
||||
*/
|
||||
export function formatPercent(value: number) {
|
||||
return `${PERCENT_FORMATTER.format(value)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 값의 부호를 포함한 금액 문자열을 만듭니다.
|
||||
* @param value 숫자 값
|
||||
* @returns + 또는 - 부호가 포함된 금액 문자열
|
||||
* @see features/dashboard/components/MarketSummary.tsx 전일 대비 수치 표시
|
||||
*/
|
||||
export function formatSignedCurrency(value: number) {
|
||||
if (value > 0) return `+${formatCurrency(value)}`;
|
||||
if (value < 0) return `-${formatCurrency(Math.abs(value))}`;
|
||||
return "0";
|
||||
}
|
||||
|
||||
/**
|
||||
* 값의 부호를 포함한 퍼센트 문자열을 만듭니다.
|
||||
* @param value 숫자 값
|
||||
* @returns + 또는 - 부호가 포함된 퍼센트 문자열
|
||||
* @see features/dashboard/components/MarketSummary.tsx 전일 대비율 표시
|
||||
*/
|
||||
export function formatSignedPercent(value: number) {
|
||||
if (value > 0) return `+${formatPercent(value)}`;
|
||||
if (value < 0) return `-${formatPercent(Math.abs(value))}`;
|
||||
return "0.00%";
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자 값의 상승/하락/보합 텍스트 색상을 반환합니다.
|
||||
* @param value 숫자 값
|
||||
* @returns Tailwind 텍스트 클래스
|
||||
* @see features/dashboard/components/HoldingsList.tsx 수익률/손익 색상 적용
|
||||
*/
|
||||
export function getChangeToneClass(value: number) {
|
||||
if (value > 0) return "text-red-600 dark:text-red-400";
|
||||
if (value < 0) return "text-blue-600 dark:text-blue-400";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SESSION_RELATED_STORAGE_KEYS = [
|
||||
"session-storage",
|
||||
"auth-storage",
|
||||
"autotrade-kis-runtime-store",
|
||||
] as const;
|
||||
|
||||
interface UserMenuProps {
|
||||
/** Supabase User 객체 */
|
||||
user: User | null;
|
||||
@@ -39,6 +45,18 @@ export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) {
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
/**
|
||||
* @description 로그아웃 제출 직전에 세션 관련 로컬 스토리지를 정리합니다.
|
||||
* @see features/auth/actions.ts signout - 서버 세션 종료를 담당합니다.
|
||||
*/
|
||||
const clearSessionRelatedStorage = () => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
for (const key of SESSION_RELATED_STORAGE_KEYS) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -91,7 +109,7 @@ export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) {
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<form action={signout}>
|
||||
<form action={signout} onSubmit={clearSessionRelatedStorage}>
|
||||
<DropdownMenuItem asChild>
|
||||
<button className="w-full text-red-600 dark:text-red-400">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Lock,
|
||||
CreditCard,
|
||||
Sparkles,
|
||||
Zap,
|
||||
Activity,
|
||||
@@ -31,11 +32,13 @@ export function KisAuthForm() {
|
||||
kisTradingEnvInput,
|
||||
kisAppKeyInput,
|
||||
kisAppSecretInput,
|
||||
kisAccountNoInput,
|
||||
verifiedCredentials,
|
||||
isKisVerified,
|
||||
setKisTradingEnvInput,
|
||||
setKisAppKeyInput,
|
||||
setKisAppSecretInput,
|
||||
setKisAccountNoInput,
|
||||
setVerifiedKisSession,
|
||||
invalidateKisVerification,
|
||||
clearKisRuntimeSession,
|
||||
@@ -44,11 +47,13 @@ export function KisAuthForm() {
|
||||
kisTradingEnvInput: state.kisTradingEnvInput,
|
||||
kisAppKeyInput: state.kisAppKeyInput,
|
||||
kisAppSecretInput: state.kisAppSecretInput,
|
||||
kisAccountNoInput: state.kisAccountNoInput,
|
||||
verifiedCredentials: state.verifiedCredentials,
|
||||
isKisVerified: state.isKisVerified,
|
||||
setKisTradingEnvInput: state.setKisTradingEnvInput,
|
||||
setKisAppKeyInput: state.setKisAppKeyInput,
|
||||
setKisAppSecretInput: state.setKisAppSecretInput,
|
||||
setKisAccountNoInput: state.setKisAccountNoInput,
|
||||
setVerifiedKisSession: state.setVerifiedKisSession,
|
||||
invalidateKisVerification: state.invalidateKisVerification,
|
||||
clearKisRuntimeSession: state.clearKisRuntimeSession,
|
||||
@@ -62,7 +67,7 @@ export function KisAuthForm() {
|
||||
|
||||
// 입력 필드 Focus 상태 관리를 위한 State
|
||||
const [focusedField, setFocusedField] = useState<
|
||||
"appKey" | "appSecret" | null
|
||||
"appKey" | "appSecret" | "accountNo" | null
|
||||
>(null);
|
||||
|
||||
function handleValidate() {
|
||||
@@ -73,16 +78,23 @@ export function KisAuthForm() {
|
||||
|
||||
const appKey = kisAppKeyInput.trim();
|
||||
const appSecret = kisAppSecretInput.trim();
|
||||
const accountNo = kisAccountNoInput.trim();
|
||||
|
||||
if (!appKey || !appSecret) {
|
||||
throw new Error("App Key와 App Secret을 모두 입력해 주세요.");
|
||||
}
|
||||
|
||||
if (accountNo && !isValidAccountNo(accountNo)) {
|
||||
throw new Error(
|
||||
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
|
||||
);
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
appKey,
|
||||
appSecret,
|
||||
tradingEnv: kisTradingEnvInput,
|
||||
accountNo: verifiedCredentials?.accountNo ?? "",
|
||||
accountNo,
|
||||
};
|
||||
|
||||
const result = await validateKisCredentials(credentials);
|
||||
@@ -221,6 +233,33 @@ export function KisAuthForm() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Account No Input */}
|
||||
<div
|
||||
className={cn(
|
||||
"group/input relative flex items-center overflow-hidden rounded-lg border bg-white transition-all duration-200 dark:bg-zinc-900/30",
|
||||
focusedField === "accountNo"
|
||||
? "border-brand-500 ring-1 ring-brand-500"
|
||||
: "border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600",
|
||||
)}
|
||||
>
|
||||
<div className="hidden h-9 w-9 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 transition-colors group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-500 dark:group-focus-within/input:text-brand-400 sm:flex">
|
||||
<CreditCard className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex h-9 min-w-[70px] items-center justify-center border-r border-zinc-100 bg-zinc-50 px-2 text-[11px] font-semibold text-zinc-500 sm:hidden dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-500">
|
||||
계좌번호
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
value={kisAccountNoInput}
|
||||
onChange={(e) => setKisAccountNoInput(e.target.value)}
|
||||
onFocus={() => setFocusedField("accountNo")}
|
||||
onBlur={() => setFocusedField(null)}
|
||||
placeholder="12345678-01"
|
||||
className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* App Secret Input */}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -312,3 +351,14 @@ export function KisAuthForm() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description KIS 계좌번호(8-2) 입력 포맷을 검증합니다.
|
||||
* @param value 사용자 입력 계좌번호
|
||||
* @returns 형식 유효 여부
|
||||
* @see features/settings/components/KisAuthForm.tsx handleValidate 인증 전 계좌번호 검사
|
||||
*/
|
||||
function isValidAccountNo(value: string) {
|
||||
const digits = value.replace(/\D/g, "");
|
||||
return digits.length === 10;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FormEvent } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
@@ -24,6 +24,12 @@ export function StockSearchForm({
|
||||
disabled,
|
||||
isLoading,
|
||||
}: StockSearchFormProps) {
|
||||
const handleClear = () => {
|
||||
onKeywordChange("");
|
||||
// 포커스 로직이 필요하다면 상위에서 ref를 전달받거나 여기서 ref를 사용해야 함.
|
||||
// 현재는 단순 값 초기화만 수행.
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="flex gap-2">
|
||||
{/* ========== SEARCH INPUT ========== */}
|
||||
@@ -35,8 +41,19 @@ export function StockSearchForm({
|
||||
onFocus={onInputFocus}
|
||||
placeholder="종목명 또는 종목코드(6자리)를 입력하세요."
|
||||
autoComplete="off"
|
||||
className="pl-9 dark:border-brand-700/55 dark:bg-brand-900/28 dark:text-brand-100 dark:placeholder:text-brand-100/55"
|
||||
className="pl-9 pr-9 dark:border-brand-700/55 dark:bg-brand-900/28 dark:text-brand-100 dark:placeholder:text-brand-100/55"
|
||||
/>
|
||||
{keyword && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-2.5 top-2.5 text-muted-foreground hover:text-foreground dark:text-brand-100/65 dark:hover:text-brand-100"
|
||||
tabIndex={-1}
|
||||
aria-label="검색어 지우기"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ========== SUBMIT BUTTON ========== */}
|
||||
|
||||
50
lib/kis/account.ts
Normal file
50
lib/kis/account.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @file lib/kis/account.ts
|
||||
* @description KIS 계좌번호(8-2) 파싱/검증 유틸
|
||||
*/
|
||||
|
||||
export interface KisAccountParts {
|
||||
accountNo: string;
|
||||
accountProductCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력된 계좌 문자열을 KIS 표준(8-2)으로 변환합니다.
|
||||
* @param accountNoInput 계좌번호(예: 12345678-01 또는 1234567801)
|
||||
* @param accountProductCodeInput 계좌상품코드(2자리, 선택)
|
||||
* @returns 파싱 성공 시 accountNo/accountProductCode
|
||||
* @see app/api/kis/domestic/balance/route.ts 잔고 조회 API에서 계좌 파싱에 사용합니다.
|
||||
*/
|
||||
export function parseKisAccountParts(
|
||||
accountNoInput?: string | null,
|
||||
accountProductCodeInput?: string | null,
|
||||
): KisAccountParts | null {
|
||||
const accountDigits = toDigits(accountNoInput);
|
||||
const productDigits = toDigits(accountProductCodeInput);
|
||||
|
||||
if (accountDigits.length >= 10) {
|
||||
return {
|
||||
accountNo: accountDigits.slice(0, 8),
|
||||
accountProductCode: accountDigits.slice(8, 10),
|
||||
};
|
||||
}
|
||||
|
||||
if (accountDigits.length === 8 && productDigits.length === 2) {
|
||||
return {
|
||||
accountNo: accountDigits,
|
||||
accountProductCode: productDigits,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열에서 숫자만 추출합니다.
|
||||
* @param value 원본 문자열
|
||||
* @returns 숫자만 남긴 문자열
|
||||
* @see lib/kis/account.ts parseKisAccountParts 입력 정규화 처리
|
||||
*/
|
||||
function toDigits(value?: string | null) {
|
||||
return (value ?? "").replace(/\D/g, "");
|
||||
}
|
||||
336
lib/kis/dashboard.ts
Normal file
336
lib/kis/dashboard.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* @file lib/kis/dashboard.ts
|
||||
* @description 대시보드 전용 KIS 잔고/지수 데이터 어댑터
|
||||
*/
|
||||
|
||||
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
|
||||
import { kisGet } from "@/lib/kis/client";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import type { KisAccountParts } from "@/lib/kis/account";
|
||||
|
||||
interface KisBalanceOutput1Row {
|
||||
pdno?: string;
|
||||
prdt_name?: string;
|
||||
hldg_qty?: string;
|
||||
pchs_avg_pric?: string;
|
||||
prpr?: string;
|
||||
evlu_amt?: string;
|
||||
evlu_pfls_amt?: string;
|
||||
evlu_pfls_rt?: string;
|
||||
}
|
||||
|
||||
interface KisBalanceOutput2Row {
|
||||
dnca_tot_amt?: string;
|
||||
tot_evlu_amt?: string;
|
||||
scts_evlu_amt?: string;
|
||||
evlu_amt_smtl_amt?: string;
|
||||
evlu_pfls_smtl_amt?: string;
|
||||
asst_icdc_erng_rt?: string;
|
||||
}
|
||||
|
||||
interface KisIndexOutputRow {
|
||||
bstp_nmix_prpr?: string;
|
||||
bstp_nmix_prdy_vrss?: string;
|
||||
bstp_nmix_prdy_ctrt?: string;
|
||||
prdy_vrss_sign?: string;
|
||||
}
|
||||
|
||||
export interface DomesticBalanceSummary {
|
||||
totalAmount: number;
|
||||
cashBalance: number;
|
||||
totalProfitLoss: number;
|
||||
totalProfitRate: number;
|
||||
}
|
||||
|
||||
export interface DomesticHoldingItem {
|
||||
symbol: string;
|
||||
name: string;
|
||||
market: "KOSPI" | "KOSDAQ";
|
||||
quantity: number;
|
||||
averagePrice: number;
|
||||
currentPrice: number;
|
||||
evaluationAmount: number;
|
||||
profitLoss: number;
|
||||
profitRate: number;
|
||||
}
|
||||
|
||||
export interface DomesticBalanceResult {
|
||||
summary: DomesticBalanceSummary;
|
||||
holdings: DomesticHoldingItem[];
|
||||
}
|
||||
|
||||
export interface DomesticMarketIndexResult {
|
||||
market: "KOSPI" | "KOSDAQ";
|
||||
code: string;
|
||||
name: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changeRate: number;
|
||||
}
|
||||
|
||||
const MARKET_BY_SYMBOL = new Map(
|
||||
KOREAN_STOCK_INDEX.map((item) => [item.symbol, item.market] as const),
|
||||
);
|
||||
|
||||
const INDEX_TARGETS: Array<{
|
||||
market: "KOSPI" | "KOSDAQ";
|
||||
code: string;
|
||||
name: string;
|
||||
}> = [
|
||||
{ market: "KOSPI", code: "0001", name: "코스피" },
|
||||
{ market: "KOSDAQ", code: "1001", name: "코스닥" },
|
||||
];
|
||||
|
||||
/**
|
||||
* KIS 잔고조회 API를 호출해 대시보드 모델로 변환합니다.
|
||||
* @param account KIS 계좌번호(8-2) 파트
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns 대시보드 요약/보유종목 모델
|
||||
* @see app/api/kis/domestic/balance/route.ts 대시보드 잔고 API 응답 생성
|
||||
*/
|
||||
export async function getDomesticDashboardBalance(
|
||||
account: KisAccountParts,
|
||||
credentials?: KisCredentialInput,
|
||||
): Promise<DomesticBalanceResult> {
|
||||
const trId =
|
||||
normalizeTradingEnv(credentials?.tradingEnv) === "real"
|
||||
? "TTTC8434R"
|
||||
: "VTTC8434R";
|
||||
|
||||
const response = await kisGet<unknown>(
|
||||
"/uapi/domestic-stock/v1/trading/inquire-balance",
|
||||
trId,
|
||||
{
|
||||
CANO: account.accountNo,
|
||||
ACNT_PRDT_CD: account.accountProductCode,
|
||||
AFHR_FLPR_YN: "N",
|
||||
OFL_YN: "",
|
||||
INQR_DVSN: "02",
|
||||
UNPR_DVSN: "01",
|
||||
FUND_STTL_ICLD_YN: "N",
|
||||
FNCG_AMT_AUTO_RDPT_YN: "N",
|
||||
PRCS_DVSN: "00",
|
||||
CTX_AREA_FK100: "",
|
||||
CTX_AREA_NK100: "",
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
|
||||
const holdingRows = parseRows<KisBalanceOutput1Row>(response.output1);
|
||||
const summaryRow = parseFirstRow<KisBalanceOutput2Row>(response.output2);
|
||||
|
||||
const holdings = holdingRows
|
||||
.map((row) => {
|
||||
const symbol = (row.pdno ?? "").trim();
|
||||
if (!/^\d{6}$/.test(symbol)) return null;
|
||||
|
||||
return {
|
||||
symbol,
|
||||
name: (row.prdt_name ?? "").trim() || symbol,
|
||||
market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI",
|
||||
quantity: toNumber(row.hldg_qty),
|
||||
averagePrice: toNumber(row.pchs_avg_pric),
|
||||
currentPrice: toNumber(row.prpr),
|
||||
evaluationAmount: toNumber(row.evlu_amt),
|
||||
profitLoss: toNumber(row.evlu_pfls_amt),
|
||||
profitRate: toNumber(row.evlu_pfls_rt),
|
||||
} satisfies DomesticHoldingItem;
|
||||
})
|
||||
.filter((item): item is DomesticHoldingItem => Boolean(item));
|
||||
|
||||
const cashBalance = toNumber(summaryRow?.dnca_tot_amt);
|
||||
const holdingsEvalAmount = sumNumbers(holdings.map((item) => item.evaluationAmount));
|
||||
const stockEvalAmount = firstPositiveNumber(
|
||||
toNumber(summaryRow?.scts_evlu_amt),
|
||||
toNumber(summaryRow?.evlu_amt_smtl_amt),
|
||||
holdingsEvalAmount,
|
||||
);
|
||||
const totalAmount = firstPositiveNumber(
|
||||
stockEvalAmount + cashBalance,
|
||||
toNumber(summaryRow?.tot_evlu_amt),
|
||||
holdingsEvalAmount + cashBalance,
|
||||
);
|
||||
const totalProfitLoss = firstDefinedNumber(
|
||||
toOptionalNumber(summaryRow?.evlu_pfls_smtl_amt),
|
||||
sumNumbers(holdings.map((item) => item.profitLoss)),
|
||||
);
|
||||
const totalProfitRate = firstDefinedNumber(
|
||||
toOptionalNumber(summaryRow?.asst_icdc_erng_rt),
|
||||
calcProfitRate(totalProfitLoss, totalAmount),
|
||||
);
|
||||
|
||||
return {
|
||||
summary: {
|
||||
totalAmount,
|
||||
cashBalance,
|
||||
totalProfitLoss,
|
||||
totalProfitRate,
|
||||
},
|
||||
holdings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* KOSPI/KOSDAQ 지수를 조회해 대시보드 모델로 변환합니다.
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns 지수 목록(코스피/코스닥)
|
||||
* @see app/api/kis/domestic/indices/route.ts 대시보드 지수 API 응답 생성
|
||||
*/
|
||||
export async function getDomesticDashboardIndices(
|
||||
credentials?: KisCredentialInput,
|
||||
): Promise<DomesticMarketIndexResult[]> {
|
||||
const results = await Promise.all(
|
||||
INDEX_TARGETS.map(async (target) => {
|
||||
const response = await kisGet<KisIndexOutputRow>(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-index-price",
|
||||
"FHPUP02100000",
|
||||
{
|
||||
FID_COND_MRKT_DIV_CODE: "U",
|
||||
FID_INPUT_ISCD: target.code,
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
|
||||
const row = parseIndexRow(response.output);
|
||||
const rawChange = toNumber(row.bstp_nmix_prdy_vrss);
|
||||
const rawChangeRate = toNumber(row.bstp_nmix_prdy_ctrt);
|
||||
|
||||
return {
|
||||
market: target.market,
|
||||
code: target.code,
|
||||
name: target.name,
|
||||
price: toNumber(row.bstp_nmix_prpr),
|
||||
change: normalizeSignedValue(rawChange, row.prdy_vrss_sign),
|
||||
changeRate: normalizeSignedValue(rawChangeRate, row.prdy_vrss_sign),
|
||||
} satisfies DomesticMarketIndexResult;
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 숫자를 number로 변환합니다.
|
||||
* @param value KIS 숫자 문자열
|
||||
* @returns 파싱된 숫자(실패 시 0)
|
||||
* @see lib/kis/dashboard.ts 잔고/지수 필드 공통 파싱
|
||||
*/
|
||||
function toNumber(value?: string) {
|
||||
if (!value) return 0;
|
||||
const normalized = value.replaceAll(",", "").trim();
|
||||
if (!normalized) return 0;
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 숫자를 number로 변환하되 0도 유효값으로 유지합니다.
|
||||
* @param value KIS 숫자 문자열
|
||||
* @returns 파싱된 숫자 또는 undefined
|
||||
* @see lib/kis/dashboard.ts 요약값 폴백 순서 계산
|
||||
*/
|
||||
function toOptionalNumber(value?: string) {
|
||||
if (!value) return undefined;
|
||||
const normalized = value.replaceAll(",", "").trim();
|
||||
if (!normalized) return undefined;
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* output 계열 데이터를 배열 형태로 변환합니다.
|
||||
* @param value KIS output 값
|
||||
* @returns 레코드 배열
|
||||
* @see lib/kis/dashboard.ts 잔고 output1/output2 파싱
|
||||
*/
|
||||
function parseRows<T>(value: unknown): T[] {
|
||||
if (Array.isArray(value)) return value as T[];
|
||||
if (value && typeof value === "object") return [value as T];
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* output 계열 데이터의 첫 행을 반환합니다.
|
||||
* @param value KIS output 값
|
||||
* @returns 첫 번째 레코드
|
||||
* @see lib/kis/dashboard.ts 잔고 요약(output2) 파싱
|
||||
*/
|
||||
function parseFirstRow<T>(value: unknown) {
|
||||
const rows = parseRows<T>(value);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 지수 output을 단일 레코드로 정규화합니다.
|
||||
* @param output KIS output
|
||||
* @returns 지수 레코드
|
||||
* @see lib/kis/dashboard.ts getDomesticDashboardIndices
|
||||
*/
|
||||
function parseIndexRow(output: unknown): KisIndexOutputRow {
|
||||
if (Array.isArray(output) && output[0] && typeof output[0] === "object") {
|
||||
return output[0] as KisIndexOutputRow;
|
||||
}
|
||||
if (output && typeof output === "object") {
|
||||
return output as KisIndexOutputRow;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 부호 코드(1/2 상승, 4/5 하락)를 실제 부호로 반영합니다.
|
||||
* @param value 변동값
|
||||
* @param signCode 부호 코드
|
||||
* @returns 부호 적용 숫자
|
||||
* @see lib/kis/dashboard.ts getDomesticDashboardIndices
|
||||
*/
|
||||
function normalizeSignedValue(value: number, signCode?: string) {
|
||||
const abs = Math.abs(value);
|
||||
if (signCode === "4" || signCode === "5") return -abs;
|
||||
if (signCode === "1" || signCode === "2") return abs;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 양수 우선값을 반환합니다.
|
||||
* @param values 후보 숫자 목록
|
||||
* @returns 첫 번째 양수, 없으면 0
|
||||
* @see lib/kis/dashboard.ts 요약 금액 폴백 계산
|
||||
*/
|
||||
function firstPositiveNumber(...values: number[]) {
|
||||
return values.find((value) => value > 0) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* undefined가 아닌 첫 값을 반환합니다.
|
||||
* @param values 후보 숫자 목록
|
||||
* @returns 첫 번째 유효값, 없으면 0
|
||||
* @see lib/kis/dashboard.ts 요약 수익률/손익 폴백 계산
|
||||
*/
|
||||
function firstDefinedNumber(...values: Array<number | undefined>) {
|
||||
return values.find((value) => value !== undefined) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자 배열 합계를 계산합니다.
|
||||
* @param values 숫자 배열
|
||||
* @returns 합계
|
||||
* @see lib/kis/dashboard.ts 보유종목 합계 계산
|
||||
*/
|
||||
function sumNumbers(values: number[]) {
|
||||
return values.reduce((total, value) => total + value, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 총자산 대비 손익률을 계산합니다.
|
||||
* @param profit 손익 금액
|
||||
* @param totalAmount 총자산 금액
|
||||
* @returns 손익률(%)
|
||||
* @see lib/kis/dashboard.ts 요약 수익률 폴백 계산
|
||||
*/
|
||||
function calcProfitRate(profit: number, totalAmount: number) {
|
||||
if (totalAmount <= 0) return 0;
|
||||
const baseAmount = totalAmount - profit;
|
||||
if (baseAmount <= 0) return 0;
|
||||
return (profit / baseAmount) * 100;
|
||||
}
|
||||
Reference in New Issue
Block a user