대시보드 구현

This commit is contained in:
2026-02-12 14:20:07 +09:00
parent 8f1d75b4d5
commit 434a814246
23 changed files with 1759 additions and 24 deletions

View File

@@ -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

View File

@@ -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 />;
}

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

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

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

View File

@@ -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();

View 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;
}

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

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

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

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

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

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

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

View 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,
};
}

View 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;
}

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

View File

@@ -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" />

View File

@@ -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;
}

View File

@@ -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
View 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
View 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;
}