대시보드 구현
This commit is contained in:
@@ -21,3 +21,9 @@ KIS_APP_KEY_MOCK=
|
|||||||
KIS_APP_SECRET_MOCK=
|
KIS_APP_SECRET_MOCK=
|
||||||
KIS_BASE_URL_MOCK=https://openapivts.koreainvestment.com:29443
|
KIS_BASE_URL_MOCK=https://openapivts.koreainvestment.com:29443
|
||||||
KIS_WS_URL_MOCK=ws://ops.koreainvestment.com:31000
|
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 { redirect } from "next/navigation";
|
||||||
|
import { DashboardContainer } from "@/features/dashboard/components/DashboardContainer";
|
||||||
import { createClient } from "@/utils/supabase/server";
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 페이지 (향후 확장용)
|
* 대시보드 페이지
|
||||||
* @returns 빈 대시보드 안내 UI
|
* @returns DashboardContainer UI
|
||||||
* @see app/(main)/trade/page.tsx 트레이딩 기능은 `/trade` 경로에서 제공합니다.
|
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 상태 헤더/지수/보유종목 UI를 제공합니다.
|
||||||
* @see app/(main)/settings/page.tsx KIS 인증 설정은 `/settings` 경로에서 제공합니다.
|
|
||||||
*/
|
*/
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
|
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
|
||||||
@@ -21,17 +21,5 @@ export default async function DashboardPage() {
|
|||||||
|
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
return (
|
return <DashboardContainer />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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분 전) - 현재 미사용 (즉시 로그아웃)
|
// 설정: 경고 표시 시간 (타임아웃 1분 전) - 현재 미사용 (즉시 로그아웃)
|
||||||
// const WARNING_MS = 60 * 1000;
|
// 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();
|
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 - 타임아웃 시 동일한 로직 필요 가능성 있음
|
* @see session-timer.tsx - 타임아웃 시 동일한 로직 필요 가능성 있음
|
||||||
@@ -64,11 +82,12 @@ export function SessionManager() {
|
|||||||
|
|
||||||
// [Step 3] 로컬 스토어 및 세션 정보 초기화
|
// [Step 3] 로컬 스토어 및 세션 정보 초기화
|
||||||
useSessionStore.persist.clearStorage();
|
useSessionStore.persist.clearStorage();
|
||||||
|
clearSessionRelatedStorage();
|
||||||
|
|
||||||
// [Step 4] 로그인 페이지로 리다이렉트 및 메시지 표시
|
// [Step 4] 로그인 페이지로 리다이렉트 및 메시지 표시
|
||||||
router.push("/login?message=세션이 만료되었습니다. 다시 로그인해주세요.");
|
router.push("/login?message=세션이 만료되었습니다. 다시 로그인해주세요.");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}, [router]);
|
}, [clearSessionRelatedStorage, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthPage) return;
|
if (isAuthPage) return;
|
||||||
@@ -79,6 +98,10 @@ export function SessionManager() {
|
|||||||
if (showWarning) setShowWarning(false);
|
if (showWarning) setShowWarning(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// [Step 0] 인증 페이지에서 메인 페이지로 진입한 직후를 "활동"으로 간주해
|
||||||
|
// 이전 세션 잔여 시간(예: 00:00)으로 즉시 로그아웃되는 현상을 방지합니다.
|
||||||
|
updateLastActive();
|
||||||
|
|
||||||
// [Step 1] 사용자 활동 이벤트 감지 (마우스, 키보드, 스크롤, 터치)
|
// [Step 1] 사용자 활동 이벤트 감지 (마우스, 키보드, 스크롤, 터치)
|
||||||
const events = ["mousedown", "keydown", "scroll", "touchstart"];
|
const events = ["mousedown", "keydown", "scroll", "touchstart"];
|
||||||
const handleActivity = () => updateLastActive();
|
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";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const SESSION_RELATED_STORAGE_KEYS = [
|
||||||
|
"session-storage",
|
||||||
|
"auth-storage",
|
||||||
|
"autotrade-kis-runtime-store",
|
||||||
|
] as const;
|
||||||
|
|
||||||
interface UserMenuProps {
|
interface UserMenuProps {
|
||||||
/** Supabase User 객체 */
|
/** Supabase User 객체 */
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -39,6 +45,18 @@ export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) {
|
|||||||
|
|
||||||
if (!user) return null;
|
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 (
|
return (
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -91,7 +109,7 @@ export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) {
|
|||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<form action={signout}>
|
<form action={signout} onSubmit={clearSessionRelatedStorage}>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<button className="w-full text-red-600 dark:text-red-400">
|
<button className="w-full text-red-600 dark:text-red-400">
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Lock,
|
Lock,
|
||||||
|
CreditCard,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Zap,
|
Zap,
|
||||||
Activity,
|
Activity,
|
||||||
@@ -31,11 +32,13 @@ export function KisAuthForm() {
|
|||||||
kisTradingEnvInput,
|
kisTradingEnvInput,
|
||||||
kisAppKeyInput,
|
kisAppKeyInput,
|
||||||
kisAppSecretInput,
|
kisAppSecretInput,
|
||||||
|
kisAccountNoInput,
|
||||||
verifiedCredentials,
|
verifiedCredentials,
|
||||||
isKisVerified,
|
isKisVerified,
|
||||||
setKisTradingEnvInput,
|
setKisTradingEnvInput,
|
||||||
setKisAppKeyInput,
|
setKisAppKeyInput,
|
||||||
setKisAppSecretInput,
|
setKisAppSecretInput,
|
||||||
|
setKisAccountNoInput,
|
||||||
setVerifiedKisSession,
|
setVerifiedKisSession,
|
||||||
invalidateKisVerification,
|
invalidateKisVerification,
|
||||||
clearKisRuntimeSession,
|
clearKisRuntimeSession,
|
||||||
@@ -44,11 +47,13 @@ export function KisAuthForm() {
|
|||||||
kisTradingEnvInput: state.kisTradingEnvInput,
|
kisTradingEnvInput: state.kisTradingEnvInput,
|
||||||
kisAppKeyInput: state.kisAppKeyInput,
|
kisAppKeyInput: state.kisAppKeyInput,
|
||||||
kisAppSecretInput: state.kisAppSecretInput,
|
kisAppSecretInput: state.kisAppSecretInput,
|
||||||
|
kisAccountNoInput: state.kisAccountNoInput,
|
||||||
verifiedCredentials: state.verifiedCredentials,
|
verifiedCredentials: state.verifiedCredentials,
|
||||||
isKisVerified: state.isKisVerified,
|
isKisVerified: state.isKisVerified,
|
||||||
setKisTradingEnvInput: state.setKisTradingEnvInput,
|
setKisTradingEnvInput: state.setKisTradingEnvInput,
|
||||||
setKisAppKeyInput: state.setKisAppKeyInput,
|
setKisAppKeyInput: state.setKisAppKeyInput,
|
||||||
setKisAppSecretInput: state.setKisAppSecretInput,
|
setKisAppSecretInput: state.setKisAppSecretInput,
|
||||||
|
setKisAccountNoInput: state.setKisAccountNoInput,
|
||||||
setVerifiedKisSession: state.setVerifiedKisSession,
|
setVerifiedKisSession: state.setVerifiedKisSession,
|
||||||
invalidateKisVerification: state.invalidateKisVerification,
|
invalidateKisVerification: state.invalidateKisVerification,
|
||||||
clearKisRuntimeSession: state.clearKisRuntimeSession,
|
clearKisRuntimeSession: state.clearKisRuntimeSession,
|
||||||
@@ -62,7 +67,7 @@ export function KisAuthForm() {
|
|||||||
|
|
||||||
// 입력 필드 Focus 상태 관리를 위한 State
|
// 입력 필드 Focus 상태 관리를 위한 State
|
||||||
const [focusedField, setFocusedField] = useState<
|
const [focusedField, setFocusedField] = useState<
|
||||||
"appKey" | "appSecret" | null
|
"appKey" | "appSecret" | "accountNo" | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
function handleValidate() {
|
function handleValidate() {
|
||||||
@@ -73,16 +78,23 @@ export function KisAuthForm() {
|
|||||||
|
|
||||||
const appKey = kisAppKeyInput.trim();
|
const appKey = kisAppKeyInput.trim();
|
||||||
const appSecret = kisAppSecretInput.trim();
|
const appSecret = kisAppSecretInput.trim();
|
||||||
|
const accountNo = kisAccountNoInput.trim();
|
||||||
|
|
||||||
if (!appKey || !appSecret) {
|
if (!appKey || !appSecret) {
|
||||||
throw new Error("App Key와 App Secret을 모두 입력해 주세요.");
|
throw new Error("App Key와 App Secret을 모두 입력해 주세요.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accountNo && !isValidAccountNo(accountNo)) {
|
||||||
|
throw new Error(
|
||||||
|
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const credentials = {
|
const credentials = {
|
||||||
appKey,
|
appKey,
|
||||||
appSecret,
|
appSecret,
|
||||||
tradingEnv: kisTradingEnvInput,
|
tradingEnv: kisTradingEnvInput,
|
||||||
accountNo: verifiedCredentials?.accountNo ?? "",
|
accountNo,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validateKisCredentials(credentials);
|
const result = await validateKisCredentials(credentials);
|
||||||
@@ -221,6 +233,33 @@ export function KisAuthForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* App Secret Input */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -312,3 +351,14 @@ export function KisAuthForm() {
|
|||||||
</div>
|
</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 type { FormEvent } from "react";
|
||||||
import { Search } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
@@ -24,6 +24,12 @@ export function StockSearchForm({
|
|||||||
disabled,
|
disabled,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: StockSearchFormProps) {
|
}: StockSearchFormProps) {
|
||||||
|
const handleClear = () => {
|
||||||
|
onKeywordChange("");
|
||||||
|
// 포커스 로직이 필요하다면 상위에서 ref를 전달받거나 여기서 ref를 사용해야 함.
|
||||||
|
// 현재는 단순 값 초기화만 수행.
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={onSubmit} className="flex gap-2">
|
<form onSubmit={onSubmit} className="flex gap-2">
|
||||||
{/* ========== SEARCH INPUT ========== */}
|
{/* ========== SEARCH INPUT ========== */}
|
||||||
@@ -35,8 +41,19 @@ export function StockSearchForm({
|
|||||||
onFocus={onInputFocus}
|
onFocus={onInputFocus}
|
||||||
placeholder="종목명 또는 종목코드(6자리)를 입력하세요."
|
placeholder="종목명 또는 종목코드(6자리)를 입력하세요."
|
||||||
autoComplete="off"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* ========== SUBMIT BUTTON ========== */}
|
{/* ========== 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