Files
auto-trade/features/dashboard/components/DashboardContainer.tsx

467 lines
18 KiB
TypeScript
Raw Normal View History

2026-02-12 14:20:07 +09:00
"use client";
import { useMemo } from "react";
2026-03-12 09:26:27 +09:00
import { BriefcaseBusiness, Gauge, Sparkles } from "lucide-react";
2026-02-12 14:20:07 +09:00
import { useShallow } from "zustand/react/shallow";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
2026-03-12 09:26:27 +09:00
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ActivitySection } from "@/features/dashboard/components/ActivitySection";
2026-02-12 14:20:07 +09:00
import { DashboardAccessGate } from "@/features/dashboard/components/DashboardAccessGate";
import { DashboardSkeleton } from "@/features/dashboard/components/DashboardSkeleton";
import { HoldingsList } from "@/features/dashboard/components/HoldingsList";
2026-03-12 09:26:27 +09:00
import { MarketHubSection } from "@/features/dashboard/components/MarketHubSection";
2026-02-12 14:20:07 +09:00
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";
2026-02-13 12:17:35 +09:00
import { useMarketRealtime } from "@/features/dashboard/hooks/use-market-realtime";
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
2026-02-12 14:20:07 +09:00
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
2026-02-13 12:17:35 +09:00
import { useHoldingsRealtime } from "@/features/dashboard/hooks/use-holdings-realtime";
import type {
DashboardBalanceSummary,
DashboardHoldingItem,
DashboardMarketIndexItem,
} from "@/features/dashboard/types/dashboard.types";
import type { KisRealtimeStockTick } from "@/features/dashboard/utils/kis-stock-realtime.utils";
2026-02-12 14:20:07 +09:00
/**
2026-02-13 12:17:35 +09:00
* @file DashboardContainer.tsx
* @description
* @remarks
* - [] Components / Container
* - [ ] -> / / ->
* - [ ] API(REST/WS) -> Hooks(useDashboardData, useMarketRealtime, useHoldingsRealtime) -> UI ->
* - [ ] use-dashboard-data.ts, use-holdings-realtime.ts, StatusHeader.tsx, HoldingsList.tsx
* @author jihoon87.lee
2026-02-12 14:20:07 +09:00
*/
export function DashboardContainer() {
2026-02-13 12:17:35 +09:00
// [Store] KIS 런타임 설정 상태 (인증 여부, 접속 계좌, 웹소켓 정보 등)
2026-02-12 14:20:07 +09:00
const {
verifiedCredentials,
isKisVerified,
isKisProfileVerified,
verifiedAccountNo,
2026-02-12 14:20:07 +09:00
_hasHydrated,
wsApprovalKey,
wsUrl,
} = useKisRuntimeStore(
useShallow((state) => ({
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
isKisProfileVerified: state.isKisProfileVerified,
verifiedAccountNo: state.verifiedAccountNo,
2026-02-12 14:20:07 +09:00
_hasHydrated: state._hasHydrated,
wsApprovalKey: state.wsApprovalKey,
wsUrl: state.wsUrl,
})),
);
2026-02-13 12:17:35 +09:00
// KIS 접근 가능 여부 판단
2026-02-12 14:20:07 +09:00
const canAccess = isKisVerified && Boolean(verifiedCredentials);
2026-02-13 12:17:35 +09:00
// [Hooks] 기본적인 대시보드 데이터(잔고, 지수, 활동내역) 조회 및 선택 상태 관리
// @see use-dashboard-data.ts - 초기 데이터 로딩 및 폴링 처리
2026-02-12 14:20:07 +09:00
const {
activity,
2026-02-12 14:20:07 +09:00
balance,
2026-02-13 12:17:35 +09:00
indices: initialIndices,
2026-02-12 14:20:07 +09:00
selectedSymbol,
setSelectedSymbol,
isLoading,
isRefreshing,
activityError,
2026-02-12 14:20:07 +09:00
balanceError,
indicesError,
2026-03-12 09:26:27 +09:00
marketHub,
marketHubError,
2026-02-12 14:20:07 +09:00
lastUpdatedAt,
refresh,
} = useDashboardData(canAccess ? verifiedCredentials : null);
2026-02-13 12:17:35 +09:00
// [Hooks] 시장 지수(코스피/코스닥) 실시간 웹소켓 데이터 구독
// @see use-market-realtime.ts - 웹소켓 연결 및 지수 파싱
const { realtimeIndices, isConnected: isWsConnected } = useMarketRealtime(
verifiedCredentials,
isKisVerified,
);
// [Hooks] 보유 종목 실시간 시세 웹소켓 데이터 구독
// @see use-holdings-realtime.ts - 보유 종목 리스트 기반 시세 업데이트
const { realtimeData: realtimeHoldings } = useHoldingsRealtime(
balance?.holdings ?? [],
);
const reconnectWebSocket = useKisWebSocketStore((state) => state.reconnect);
// [Step 1] REST API로 가져온 기본 지수 정보와 실시간 웹소켓 시세 병합
const indices = useMemo(() => {
if (initialIndices.length === 0) {
return buildRealtimeOnlyIndices(realtimeIndices);
}
return initialIndices.map((item) => {
const realtime = realtimeIndices[item.code];
if (!realtime) return item;
return {
...item,
price: realtime.price,
change: realtime.change,
changeRate: realtime.changeRate,
};
});
}, [initialIndices, realtimeIndices]);
// [Step 2] 초기 잔고 데이터와 실시간 보유 종목 시세를 병합하여 손익 재계산
const mergedHoldings = useMemo(
() => mergeHoldingsWithRealtime(balance?.holdings ?? [], realtimeHoldings),
[balance?.holdings, realtimeHoldings],
);
const isKisRestConnected = Boolean(
(balance && !balanceError) ||
(initialIndices.length > 0 && !indicesError) ||
(activity && !activityError),
);
const hasRealtimeStreaming =
Object.keys(realtimeIndices).length > 0 ||
Object.keys(realtimeHoldings).length > 0;
const isRealtimePending = Boolean(
wsApprovalKey && wsUrl && isWsConnected && !hasRealtimeStreaming,
);
const effectiveIndicesError = indices.length === 0 ? indicesError : null;
2026-03-12 09:26:27 +09:00
const restStatusLabel = isKisRestConnected ? "REST 정상" : "REST 점검 필요";
const realtimeStatusLabel = isWsConnected
? isRealtimePending
? "실시간 대기중"
: "실시간 수신중"
: "실시간 미연결";
const profileStatusLabel = isKisProfileVerified
? "계좌 인증 완료"
: "계좌 인증 필요";
2026-02-13 12:17:35 +09:00
const indicesWarning =
indices.length > 0 && indicesError
? "지수 API 재요청 중입니다. 화면 값은 실시간/최근 성공 데이터입니다."
: null;
/**
* REST + .
* @remarks UI 흐름: StatusHeader/ -> handleRefreshAll -> REST + WS
* @see features/dashboard/components/StatusHeader.tsx
* @see features/kis-realtime/stores/kisWebSocketStore.ts reconnect
*/
const handleRefreshAll = async () => {
await Promise.allSettled([
refresh(),
reconnectWebSocket({ refreshApproval: false }),
]);
};
2026-02-12 14:20:07 +09:00
2026-02-13 12:17:35 +09:00
/**
* .
* @returns (, , )
*/
const mergedSummary = useMemo(
() => buildRealtimeSummary(balance?.summary ?? null, mergedHoldings),
[balance?.summary, mergedHoldings],
);
// [Step 3] 실시간 병합 데이터에서 현재 선택된 종목 정보를 추출
// @see StockDetailPreview.tsx - 선택된 종목의 상세 정보 표시
const realtimeSelectedHolding = useMemo(() => {
if (!selectedSymbol || mergedHoldings.length === 0) return null;
return (
mergedHoldings.find((item) => item.symbol === selectedSymbol) ?? null
);
}, [mergedHoldings, selectedSymbol]);
// 하이드레이션 이전에는 로딩 스피너 표시
2026-02-12 14:20:07 +09:00
if (!_hasHydrated) {
return (
<div className="flex h-full items-center justify-center p-6">
<LoadingSpinner />
</div>
);
}
2026-02-13 12:17:35 +09:00
// KIS 인증이 되지 않은 경우 접근 제한 게이트 표시
2026-02-12 14:20:07 +09:00
if (!canAccess) {
return <DashboardAccessGate canAccess={canAccess} />;
}
2026-02-13 12:17:35 +09:00
// 데이터 로딩 중이며 아직 데이터가 없는 경우 스켈레톤 표시
2026-02-12 14:20:07 +09:00
if (isLoading && !balance && indices.length === 0) {
return <DashboardSkeleton />;
}
return (
2026-03-12 09:26:27 +09:00
<section className="relative mx-auto flex w-full max-w-7xl flex-col gap-4 overflow-hidden p-4 md:p-6">
<div className="pointer-events-none absolute -left-24 top-10 h-52 w-52 rounded-full bg-brand-400/15 blur-3xl dark:bg-brand-600/15" />
<div className="pointer-events-none absolute -right-28 top-36 h-64 w-64 rounded-full bg-brand-300/20 blur-3xl dark:bg-brand-700/20" />
<div className="relative rounded-3xl border border-brand-200/70 bg-linear-to-br from-brand-100/70 via-brand-50/30 to-background p-4 shadow-sm dark:border-brand-800/50 dark:from-brand-900/35 dark:via-brand-950/15">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="space-y-2">
<p className="inline-flex items-center gap-1.5 rounded-full border border-brand-300/70 bg-background/80 px-3 py-1 text-[11px] font-semibold tracking-wide text-brand-700 dark:border-brand-700 dark:bg-brand-950/50 dark:text-brand-300">
<Sparkles className="h-3.5 w-3.5" />
TRADING OVERVIEW
</p>
<h1 className="text-xl font-bold tracking-tight text-foreground md:text-2xl">
</h1>
<p className="text-sm text-muted-foreground">
, .
</p>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<TopStatusPill label="서버" value={restStatusLabel} ok={isKisRestConnected} />
<TopStatusPill
label="실시간"
value={realtimeStatusLabel}
ok={isWsConnected}
warn={isRealtimePending}
/>
<TopStatusPill
label="계좌"
value={profileStatusLabel}
ok={isKisProfileVerified}
/>
</div>
</div>
</div>
<Tabs defaultValue="assets" className="relative gap-4">
<TabsList className="h-auto w-full justify-start rounded-2xl border border-brand-200/80 bg-background/90 p-1 backdrop-blur-sm dark:border-brand-800/50 dark:bg-background/60">
<TabsTrigger
value="market"
className="h-10 rounded-xl px-4 data-[state=active]:bg-brand-600 data-[state=active]:text-white data-[state=active]:shadow-md dark:data-[state=active]:bg-brand-600"
>
<Gauge className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger
value="assets"
className="h-10 rounded-xl px-4 data-[state=active]:bg-brand-600 data-[state=active]:text-white data-[state=active]:shadow-md dark:data-[state=active]:bg-brand-600"
>
<BriefcaseBusiness className="h-4 w-4" />
</TabsTrigger>
</TabsList>
<TabsContent value="market" className="space-y-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-300">
<div className="grid gap-4 xl:grid-cols-[1.05fr_1.45fr]">
<MarketSummary
items={indices}
isLoading={isLoading}
error={effectiveIndicesError}
warning={indicesWarning}
isWebSocketReady={isWsConnected}
isRealtimePending={isRealtimePending}
onRetry={() => {
void handleRefreshAll();
}}
/>
<MarketHubSection
marketHub={marketHub}
isLoading={isLoading}
error={marketHubError}
onRetry={() => {
void handleRefreshAll();
}}
/>
</div>
</TabsContent>
<TabsContent value="assets" className="space-y-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-300">
<StatusHeader
summary={mergedSummary}
isKisRestConnected={isKisRestConnected}
isWebSocketReady={Boolean(wsApprovalKey && wsUrl && isWsConnected)}
2026-02-13 12:17:35 +09:00
isRealtimePending={isRealtimePending}
2026-03-12 09:26:27 +09:00
isProfileVerified={isKisProfileVerified}
verifiedAccountNo={verifiedAccountNo}
isRefreshing={isRefreshing}
lastUpdatedAt={lastUpdatedAt}
onRefresh={() => {
2026-02-13 12:17:35 +09:00
void handleRefreshAll();
}}
2026-02-12 14:20:07 +09:00
/>
2026-03-12 09:26:27 +09:00
<div className="grid gap-4 xl:grid-cols-[1.25fr_0.75fr]">
<div className="space-y-4">
<ActivitySection
activity={activity}
isLoading={isLoading}
error={activityError}
onRetry={() => {
void handleRefreshAll();
}}
/>
2026-03-12 09:26:27 +09:00
<HoldingsList
holdings={mergedHoldings}
selectedSymbol={selectedSymbol}
isLoading={isLoading}
error={balanceError}
onRetry={() => {
void handleRefreshAll();
}}
onSelect={setSelectedSymbol}
/>
</div>
<div className="xl:sticky xl:top-5 xl:self-start">
<StockDetailPreview
holding={realtimeSelectedHolding}
totalAmount={mergedSummary?.totalAmount ?? 0}
/>
</div>
</div>
</TabsContent>
</Tabs>
2026-02-12 14:20:07 +09:00
</section>
);
}
2026-02-13 12:17:35 +09:00
2026-03-12 09:26:27 +09:00
function TopStatusPill({
label,
value,
ok,
warn = false,
}: {
label: string;
value: string;
ok: boolean;
warn?: boolean;
}) {
const toneClass = ok
? warn
? "border-amber-300/70 bg-amber-50/70 text-amber-700 dark:border-amber-700/50 dark:bg-amber-950/30 dark:text-amber-300"
: "border-emerald-300/70 bg-emerald-50/70 text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-950/30 dark:text-emerald-300"
: "border-red-300/70 bg-red-50/70 text-red-700 dark:border-red-700/50 dark:bg-red-950/30 dark:text-red-300";
return (
<div className={`rounded-xl border px-3 py-2 ${toneClass}`}>
<p className="text-[11px] font-medium opacity-80">{label}</p>
<p className="text-xs font-semibold">{value}</p>
</div>
);
}
2026-02-13 12:17:35 +09:00
/**
* @description REST / .
* @param realtimeIndices
* @returns
* @remarks UI 흐름: DashboardContainer -> buildRealtimeOnlyIndices -> MarketSummary
* @see features/dashboard/hooks/use-market-realtime.ts
*/
function buildRealtimeOnlyIndices(
realtimeIndices: Record<string, { price: number; change: number; changeRate: number }>,
) {
const baseItems: DashboardMarketIndexItem[] = [
{ market: "KOSPI", code: "0001", name: "코스피", price: 0, change: 0, changeRate: 0 },
{ market: "KOSDAQ", code: "1001", name: "코스닥", price: 0, change: 0, changeRate: 0 },
];
return baseItems
.map((item) => {
const realtime = realtimeIndices[item.code];
if (!realtime) return null;
return {
...item,
price: realtime.price,
change: realtime.change,
changeRate: realtime.changeRate,
} satisfies DashboardMarketIndexItem;
})
.filter((item): item is DashboardMarketIndexItem => Boolean(item));
}
/**
* @description // .
* @param holdings REST
* @param realtimeHoldings
* @returns
* @remarks UI 흐름: DashboardContainer -> mergeHoldingsWithRealtime -> HoldingsList/StockDetailPreview
* @see features/dashboard/hooks/use-holdings-realtime.ts
*/
function mergeHoldingsWithRealtime(
holdings: DashboardHoldingItem[],
realtimeHoldings: Record<string, KisRealtimeStockTick>,
) {
if (holdings.length === 0 || Object.keys(realtimeHoldings).length === 0) {
return holdings;
}
return holdings.map((item) => {
const tick = realtimeHoldings[item.symbol];
if (!tick) return item;
const currentPrice = tick.currentPrice;
const purchaseAmount = item.averagePrice * item.quantity;
const evaluationAmount = currentPrice * item.quantity;
const profitLoss = evaluationAmount - purchaseAmount;
const profitRate = purchaseAmount > 0 ? (profitLoss / purchaseAmount) * 100 : 0;
return {
...item,
currentPrice,
evaluationAmount,
profitLoss,
profitRate,
};
});
}
/**
* @description (/) .
* @param summary REST API
* @param holdings
* @returns
* @remarks UI 흐름: DashboardContainer -> buildRealtimeSummary -> StatusHeader
* @see features/dashboard/components/StatusHeader.tsx
*/
function buildRealtimeSummary(
summary: DashboardBalanceSummary | null,
holdings: DashboardHoldingItem[],
) {
if (!summary) return null;
if (holdings.length === 0) return summary;
const evaluationAmount = holdings.reduce(
(total, item) => total + item.evaluationAmount,
0,
);
const purchaseAmount = holdings.reduce(
(total, item) => total + item.averagePrice * item.quantity,
0,
);
const totalProfitLoss = evaluationAmount - purchaseAmount;
const totalProfitRate =
purchaseAmount > 0 ? (totalProfitLoss / purchaseAmount) * 100 : 0;
const evaluationDelta = evaluationAmount - summary.evaluationAmount;
const baseTotalAmount =
summary.apiReportedNetAssetAmount > 0
? summary.apiReportedNetAssetAmount
: summary.totalAmount;
// 실시간은 "기준 순자산 + 평가금 증감분"으로만 반영합니다.
const totalAmount = Math.max(baseTotalAmount + evaluationDelta, 0);
const netAssetAmount = totalAmount;
const cashBalance = Math.max(totalAmount - evaluationAmount, 0);
return {
...summary,
totalAmount,
netAssetAmount,
cashBalance,
evaluationAmount,
purchaseAmount,
totalProfitLoss,
totalProfitRate,
} satisfies DashboardBalanceSummary;
}