대시보드 실시간 기능 추가
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { AlertCircle, ClipboardList, FileText } from "lucide-react";
|
||||
import { AlertCircle, ClipboardList, FileText, RefreshCcw } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -24,6 +25,7 @@ interface ActivitySectionProps {
|
||||
activity: DashboardActivityResponse | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,7 +34,12 @@ interface ActivitySectionProps {
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 하단 영역에서 호출합니다.
|
||||
* @see app/api/kis/domestic/activity/route.ts 주문내역/매매일지 데이터 소스
|
||||
*/
|
||||
export function ActivitySection({ activity, isLoading, error }: ActivitySectionProps) {
|
||||
export function ActivitySection({
|
||||
activity,
|
||||
isLoading,
|
||||
error,
|
||||
onRetry,
|
||||
}: ActivitySectionProps) {
|
||||
const orders = activity?.orders ?? [];
|
||||
const journalRows = activity?.tradeJournal ?? [];
|
||||
const summary = activity?.journalSummary;
|
||||
@@ -59,10 +66,27 @@ export function ActivitySection({ activity, isLoading, error }: ActivitySectionP
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="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>
|
||||
<div className="rounded-md border border-red-200 bg-red-50/60 p-2.5 dark:border-red-900/40 dark:bg-red-950/20">
|
||||
<p className="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>
|
||||
<p className="mt-1 text-xs text-red-700/80 dark:text-red-300/80">
|
||||
주문/매매일지 API는 장중 혼잡 시간에 간헐적 실패가 발생할 수 있습니다.
|
||||
</p>
|
||||
{onRetry ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
className="mt-2 border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/40"
|
||||
>
|
||||
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
주문/매매일지 다시 불러오기
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{warnings.length > 0 && (
|
||||
|
||||
@@ -11,15 +11,29 @@ 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 { useMarketRealtime } from "@/features/dashboard/hooks/use-market-realtime";
|
||||
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
|
||||
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
||||
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";
|
||||
|
||||
/**
|
||||
* @description 대시보드 메인 컨테이너입니다.
|
||||
* @remarks UI 흐름: 대시보드 진입 -> useDashboardData API 호출 -> StatusHeader/MarketSummary/HoldingsList/StockDetailPreview 순으로 렌더링
|
||||
* @see app/(main)/dashboard/page.tsx 로그인 완료 후 이 컴포넌트를 렌더링합니다.
|
||||
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 데이터 조회/갱신 상태를 관리합니다.
|
||||
* @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
|
||||
*/
|
||||
export function DashboardContainer() {
|
||||
// [Store] KIS 런타임 설정 상태 (인증 여부, 접속 계좌, 웹소켓 정보 등)
|
||||
const {
|
||||
verifiedCredentials,
|
||||
isKisVerified,
|
||||
@@ -40,13 +54,15 @@ export function DashboardContainer() {
|
||||
})),
|
||||
);
|
||||
|
||||
// KIS 접근 가능 여부 판단
|
||||
const canAccess = isKisVerified && Boolean(verifiedCredentials);
|
||||
|
||||
// [Hooks] 기본적인 대시보드 데이터(잔고, 지수, 활동내역) 조회 및 선택 상태 관리
|
||||
// @see use-dashboard-data.ts - 초기 데이터 로딩 및 폴링 처리
|
||||
const {
|
||||
activity,
|
||||
balance,
|
||||
indices,
|
||||
selectedHolding,
|
||||
indices: initialIndices,
|
||||
selectedSymbol,
|
||||
setSelectedSymbol,
|
||||
isLoading,
|
||||
@@ -58,12 +74,94 @@ export function DashboardContainer() {
|
||||
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]);
|
||||
// [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;
|
||||
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 }),
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 실시간 보유종목 데이터를 기반으로 전체 자산 요약을 계산합니다.
|
||||
* @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]);
|
||||
|
||||
// 하이드레이션 이전에는 로딩 스피너 표시
|
||||
if (!_hasHydrated) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
@@ -72,60 +170,193 @@ export function DashboardContainer() {
|
||||
);
|
||||
}
|
||||
|
||||
// KIS 인증이 되지 않은 경우 접근 제한 게이트 표시
|
||||
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}
|
||||
summary={mergedSummary}
|
||||
isKisRestConnected={isKisRestConnected}
|
||||
isWebSocketReady={Boolean(wsApprovalKey && wsUrl)}
|
||||
isWebSocketReady={Boolean(wsApprovalKey && wsUrl && isWsConnected)}
|
||||
isRealtimePending={isRealtimePending}
|
||||
isProfileVerified={isKisProfileVerified}
|
||||
verifiedAccountNo={verifiedAccountNo}
|
||||
isRefreshing={isRefreshing}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
onRefresh={() => {
|
||||
void refresh();
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ========== MAIN CONTENT GRID ========== */}
|
||||
{/* ========== 메인 그리드 구성 ========== */}
|
||||
<div className="grid gap-4 lg:grid-cols-[1.15fr_1fr]">
|
||||
{/* 왼쪽 섹션: 보유 종목 목록 리스트 */}
|
||||
<HoldingsList
|
||||
holdings={balance?.holdings ?? []}
|
||||
holdings={mergedHoldings}
|
||||
selectedSymbol={selectedSymbol}
|
||||
isLoading={isLoading}
|
||||
error={balanceError}
|
||||
onRetry={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
onSelect={setSelectedSymbol}
|
||||
/>
|
||||
|
||||
{/* 오른쪽 섹션: 시장 지수 요약 및 선택 종목 상세 정보 */}
|
||||
<div className="grid gap-4">
|
||||
{/* 시장 지수 현황 (코스피/코스닥) */}
|
||||
<MarketSummary
|
||||
items={indices}
|
||||
isLoading={isLoading}
|
||||
error={indicesError}
|
||||
error={effectiveIndicesError}
|
||||
warning={indicesWarning}
|
||||
isRealtimePending={isRealtimePending}
|
||||
onRetry={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 선택된 종목의 실시간 상세 요약 정보 */}
|
||||
<StockDetailPreview
|
||||
holding={selectedHolding}
|
||||
totalAmount={balance?.summary.totalAmount ?? 0}
|
||||
holding={realtimeSelectedHolding}
|
||||
totalAmount={mergedSummary?.totalAmount ?? 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== ACTIVITY SECTION ========== */}
|
||||
{/* ========== 하단 섹션: 최근 매매/충전 활동 내역 ========== */}
|
||||
<ActivitySection
|
||||
activity={activity}
|
||||
isLoading={isLoading}
|
||||
error={activityError}
|
||||
onRetry={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
/**
|
||||
* @file HoldingsList.tsx
|
||||
* @description 대시보드 좌측 영역의 보유 종목 리스트 컴포넌트
|
||||
* @remarks
|
||||
* - [레이어] Components / UI
|
||||
* - [사용자 행동] 종목 리스트 스크롤 -> 특정 종목 클릭(선택) -> 우측 상세 프레뷰 갱신
|
||||
* - [데이터 흐름] DashboardContainer(mergedHoldings) -> HoldingsList -> HoldingItemRow -> onSelect(Callback)
|
||||
* - [연관 파일] DashboardContainer.tsx, dashboard.types.ts, use-price-flash.ts
|
||||
* @author jihoon87.lee
|
||||
*/
|
||||
import { AlertCircle, Wallet2 } from "lucide-react";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -14,30 +26,43 @@ import {
|
||||
getChangeToneClass,
|
||||
} from "@/features/dashboard/utils/dashboard-format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
|
||||
|
||||
interface HoldingsListProps {
|
||||
/** 보유 종목 데이터 리스트 (실시간 시세 병합됨) */
|
||||
holdings: DashboardHoldingItem[];
|
||||
/** 현재 선택된 종목의 심볼 (없으면 null) */
|
||||
selectedSymbol: string | null;
|
||||
/** 데이터 로딩 상태 */
|
||||
isLoading: boolean;
|
||||
/** 에러 메시지 (없으면 null) */
|
||||
error: string | null;
|
||||
/** 섹션 재조회 핸들러 */
|
||||
onRetry?: () => void;
|
||||
/** 종목 선택 시 호출되는 핸들러 */
|
||||
onSelect: (symbol: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 보유 종목 리스트 카드입니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 좌측 메인 영역에서 호출합니다.
|
||||
* [컴포넌트] 보유 종목 리스트
|
||||
* 사용자의 잔고 정보를 바탕으로 실시간 시세가 반영된 종목 카드 목록을 렌더링합니다.
|
||||
*
|
||||
* @param props HoldingsListProps
|
||||
* @see DashboardContainer.tsx - 좌측 메인 영역에서 실시간 병합 데이터를 전달받아 호출
|
||||
* @see DashboardContainer.tsx - setSelectedSymbol 핸들러를 onSelect로 전달
|
||||
*/
|
||||
export function HoldingsList({
|
||||
holdings,
|
||||
selectedSymbol,
|
||||
isLoading,
|
||||
error,
|
||||
onRetry,
|
||||
onSelect,
|
||||
}: HoldingsListProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Card className="h-full">
|
||||
{/* ========== 카드 헤더: 타이틀 및 설명 ========== */}
|
||||
<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" />
|
||||
보유 종목
|
||||
@@ -47,76 +72,57 @@ export function HoldingsList({
|
||||
</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 className="text-sm text-muted-foreground">
|
||||
보유 종목을 불러오는 중입니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 에러 발생 상태 */}
|
||||
{error && (
|
||||
<div className="mb-2 rounded-md border border-red-200 bg-red-50/60 p-2.5 dark:border-red-900/40 dark:bg-red-950/20">
|
||||
<p className="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>
|
||||
<p className="mt-1 text-xs text-red-700/80 dark:text-red-300/80">
|
||||
한국투자증권 API가 일시적으로 불안정할 수 있습니다. 잠시 후 다시 시도해 주세요.
|
||||
</p>
|
||||
{onRetry ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
className="mt-2 border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/40"
|
||||
>
|
||||
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
보유종목 다시 불러오기
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 없음 상태 */}
|
||||
{!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 grid grid-cols-3 gap-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
평균 매수가 {formatCurrency(holding.averagePrice)}원
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
현재 평가금액 {formatCurrency(holding.evaluationAmount)}원
|
||||
</span>
|
||||
<span className={cn("text-right font-medium", toneClass)}>
|
||||
현재 손익 {formatCurrency(holding.profitLoss)}원
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{holdings.map((holding) => (
|
||||
<HoldingItemRow
|
||||
key={holding.symbol}
|
||||
holding={holding}
|
||||
isSelected={selectedSymbol === holding.symbol}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
@@ -124,3 +130,99 @@ export function HoldingsList({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface HoldingItemRowProps {
|
||||
/** 개별 종목 정보 */
|
||||
holding: DashboardHoldingItem;
|
||||
/** 선택 여부 */
|
||||
isSelected: boolean;
|
||||
/** 클릭 핸들러 */
|
||||
onSelect: (symbol: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* [컴포넌트] 보유 종목 개별 행 (아이템)
|
||||
* 종목의 기본 정보와 실시간 시세, 현재 손익 상태를 표시합니다.
|
||||
*
|
||||
* @param props HoldingItemRowProps
|
||||
* @see HoldingsList.tsx - holdings.map 내에서 호출
|
||||
* @see use-price-flash.ts - 현재가 변경 감지 및 애니메이션 효과 트리거
|
||||
*/
|
||||
function HoldingItemRow({
|
||||
holding,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: HoldingItemRowProps) {
|
||||
// [State/Hook] 현재가 기반 가격 반짝임 효과 상태 관리
|
||||
// @see use-price-flash.ts - 현재가 변경 감지 시 symbol을 기준으로 이펙트 발생 여부 결정
|
||||
const flash = usePriceFlash(holding.currentPrice, holding.symbol);
|
||||
|
||||
// [UI] 손익 상태에 따른 텍스트 색상 클래스 결정 (상승: red, 하락: blue)
|
||||
const toneClass = getChangeToneClass(holding.profitLoss);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
// [Step 1] 종목 클릭 시 부모의 선택 핸들러 호출
|
||||
onClick={() => onSelect(holding.symbol)}
|
||||
className={cn(
|
||||
"w-full rounded-xl border px-3 py-3 text-left transition-all relative overflow-hidden",
|
||||
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",
|
||||
)}
|
||||
>
|
||||
{/* ========== 행 상단: 종목명, 심볼 및 현재가 정보 ========== */}
|
||||
<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">
|
||||
<div className="relative inline-flex items-center justify-end gap-1">
|
||||
{/* 시세 변동 애니메이션 (Flash) 표시 영역 */}
|
||||
{flash && (
|
||||
<span
|
||||
key={flash.id}
|
||||
className={cn(
|
||||
"pointer-events-none absolute -left-12 top-0 whitespace-nowrap text-xs font-bold animate-in fade-in slide-in-from-bottom-1 fill-mode-forwards duration-300",
|
||||
flash.type === "up" ? "text-red-500" : "text-blue-500",
|
||||
)}
|
||||
>
|
||||
{flash.type === "up" ? "+" : ""}
|
||||
{flash.val.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{/* 실시간 현재가 */}
|
||||
<p className="text-sm font-semibold text-foreground transition-colors duration-300">
|
||||
{formatCurrency(holding.currentPrice)}원
|
||||
</p>
|
||||
</div>
|
||||
{/* 실시간 수익률 */}
|
||||
<p className={cn("text-xs font-medium", toneClass)}>
|
||||
{formatPercent(holding.profitRate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== 행 하단: 평단가, 평가액 및 실시간 손익 ========== */}
|
||||
<div className="mt-2 grid grid-cols-3 gap-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
평균 {formatCurrency(holding.averagePrice)}원
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
평가 {formatCurrency(holding.evaluationAmount)}원
|
||||
</span>
|
||||
<span className={cn("text-right font-medium", toneClass)}>
|
||||
손익 {formatCurrency(holding.profitLoss)}원
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AlertCircle, BarChart3 } from "lucide-react";
|
||||
import { BarChart3, TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -7,79 +8,185 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type { DashboardMarketIndexItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatSignedCurrency,
|
||||
formatSignedPercent,
|
||||
getChangeToneClass,
|
||||
} from "@/features/dashboard/utils/dashboard-format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
|
||||
|
||||
interface MarketSummaryProps {
|
||||
items: DashboardMarketIndexItem[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
warning?: string | null;
|
||||
isRealtimePending?: boolean;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 코스피/코스닥 지수 요약 카드입니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 우측 상단 영역에서 호출합니다.
|
||||
*/
|
||||
export function MarketSummary({ items, isLoading, error }: MarketSummaryProps) {
|
||||
export function MarketSummary({
|
||||
items,
|
||||
isLoading,
|
||||
error,
|
||||
warning = null,
|
||||
isRealtimePending = false,
|
||||
onRetry,
|
||||
}: 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>
|
||||
코스피/코스닥 지수 움직임을 보여줍니다.
|
||||
</CardDescription>
|
||||
<Card className="overflow-hidden border-brand-200/80 bg-linear-to-br from-brand-50/50 to-background dark:border-brand-800/45 dark:from-brand-950/20 dark:to-background">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base text-brand-700 dark:text-brand-300">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
시장 지수
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription>실시간 코스피/코스닥 지수 현황입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2">
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
|
||||
{/* ========== LOADING STATE ========== */}
|
||||
{isLoading && items.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">지수 데이터를 불러오는 중입니다.</p>
|
||||
<div className="col-span-full py-4 text-center text-sm text-muted-foreground animate-pulse">
|
||||
지수 데이터를 불러오는 중입니다...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== REALTIME PENDING STATE ========== */}
|
||||
{isRealtimePending && items.length === 0 && !isLoading && !error && (
|
||||
<div className="col-span-full rounded-md bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-950/25 dark:text-amber-400">
|
||||
실시간 시세 연결은 완료되었고 첫 지수 데이터를 기다리는 중입니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== ERROR/WARNING STATE ========== */}
|
||||
{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>
|
||||
<div className="col-span-full rounded-md bg-red-50 p-3 text-sm text-red-600 dark:bg-red-950/30 dark:text-red-400">
|
||||
<p>지수 정보를 가져오는데 실패했습니다.</p>
|
||||
<p className="mt-1 text-xs opacity-80">
|
||||
{toCompactErrorMessage(error)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs opacity-80">
|
||||
토큰이 정상이어도 한국투자증권 API 점검/지연 시 일시적으로 실패할 수 있습니다.
|
||||
</p>
|
||||
{onRetry ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
className="mt-2 border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/40"
|
||||
>
|
||||
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
지수 다시 불러오기
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{!error && warning && (
|
||||
<div className="col-span-full rounded-md bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-950/25 dark:text-amber-400">
|
||||
{warning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
{/* ========== INDEX CARDS ========== */}
|
||||
{items.map((item) => (
|
||||
<IndexItem key={item.code} item={item} />
|
||||
))}
|
||||
|
||||
{!isLoading && items.length === 0 && !error && (
|
||||
<p className="text-sm text-muted-foreground">표시할 지수 데이터가 없습니다.</p>
|
||||
<div className="col-span-full py-4 text-center text-sm text-muted-foreground">
|
||||
표시할 데이터가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 길고 복잡한 서버 오류를 대시보드 카드에 맞는 짧은 문구로 축약합니다.
|
||||
* @param error 원본 오류 문자열
|
||||
* @returns 화면 노출용 오류 메시지
|
||||
* @see features/dashboard/components/MarketSummary.tsx 지수 오류 배너 상세 문구
|
||||
*/
|
||||
function toCompactErrorMessage(error: string) {
|
||||
const normalized = error.replaceAll(/\s+/g, " ").trim();
|
||||
if (!normalized) return "잠시 후 다시 시도해 주세요.";
|
||||
if (normalized.length <= 120) return normalized;
|
||||
return `${normalized.slice(0, 120)}...`;
|
||||
}
|
||||
|
||||
function IndexItem({ item }: { item: DashboardMarketIndexItem }) {
|
||||
const isUp = item.change > 0;
|
||||
const isDown = item.change < 0;
|
||||
const toneClass = isUp
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: isDown
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-muted-foreground";
|
||||
|
||||
const bgClass = isUp
|
||||
? "bg-red-50/50 dark:bg-red-950/10 border-red-100 dark:border-red-900/30"
|
||||
: isDown
|
||||
? "bg-blue-50/50 dark:bg-blue-950/10 border-blue-100 dark:border-blue-900/30"
|
||||
: "bg-muted/50 border-border/50";
|
||||
|
||||
const flash = usePriceFlash(item.price, item.code);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col justify-between rounded-xl border p-4 transition-all hover:bg-background/80",
|
||||
bgClass,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{item.market}
|
||||
</span>
|
||||
{isUp ? (
|
||||
<TrendingUp className="h-4 w-4 text-red-500/70" />
|
||||
) : isDown ? (
|
||||
<TrendingDown className="h-4 w-4 text-blue-500/70" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-2xl font-bold tracking-tight relative w-fit">
|
||||
{formatCurrency(item.price)}
|
||||
|
||||
{/* Flash Indicator */}
|
||||
{flash && (
|
||||
<div
|
||||
key={flash.id} // Force re-render for animation restart using state ID
|
||||
className={cn(
|
||||
"absolute left-full top-1 ml-2 text-sm font-bold animate-out fade-out slide-out-to-top-2 duration-1000 fill-mode-forwards pointer-events-none whitespace-nowrap",
|
||||
flash.type === "up" ? "text-red-500" : "text-blue-500",
|
||||
)}
|
||||
>
|
||||
{flash.type === "up" ? "+" : ""}
|
||||
{flash.val.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex items-center gap-2 text-sm font-medium",
|
||||
toneClass,
|
||||
)}
|
||||
>
|
||||
<span>{formatSignedCurrency(item.change)}</span>
|
||||
<span className="rounded-md bg-background/50 px-1.5 py-0.5 text-xs shadow-sm">
|
||||
{formatSignedPercent(item.changeRate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import type { DashboardBalanceSummary } from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatPercent,
|
||||
formatSignedCurrency,
|
||||
formatSignedPercent,
|
||||
getChangeToneClass,
|
||||
} from "@/features/dashboard/utils/dashboard-format";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -14,6 +15,7 @@ interface StatusHeaderProps {
|
||||
summary: DashboardBalanceSummary | null;
|
||||
isKisRestConnected: boolean;
|
||||
isWebSocketReady: boolean;
|
||||
isRealtimePending: boolean;
|
||||
isProfileVerified: boolean;
|
||||
verifiedAccountNo: string | null;
|
||||
isRefreshing: boolean;
|
||||
@@ -29,6 +31,7 @@ export function StatusHeader({
|
||||
summary,
|
||||
isKisRestConnected,
|
||||
isWebSocketReady,
|
||||
isRealtimePending,
|
||||
isProfileVerified,
|
||||
verifiedAccountNo,
|
||||
isRefreshing,
|
||||
@@ -41,6 +44,20 @@ export function StatusHeader({
|
||||
hour12: false,
|
||||
})
|
||||
: "--:--:--";
|
||||
const hasApiTotalAmount =
|
||||
Boolean(summary) && (summary?.apiReportedTotalAmount ?? 0) > 0;
|
||||
const hasApiNetAssetAmount =
|
||||
Boolean(summary) && (summary?.apiReportedNetAssetAmount ?? 0) > 0;
|
||||
const isApiTotalAmountDifferent =
|
||||
Boolean(summary) &&
|
||||
Math.abs(
|
||||
(summary?.apiReportedTotalAmount ?? 0) - (summary?.totalAmount ?? 0),
|
||||
) >= 1;
|
||||
const realtimeStatusLabel = isWebSocketReady
|
||||
? isRealtimePending
|
||||
? "수신 대기중"
|
||||
: "연결됨"
|
||||
: "미연결";
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden border-brand-200 shadow-sm dark:border-brand-800/50">
|
||||
@@ -50,32 +67,61 @@ export function StatusHeader({
|
||||
<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="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)}원` : "-"}
|
||||
현금(예수금) {summary ? `${formatCurrency(summary.cashBalance)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
실제 자산 {summary ? `${formatCurrency(summary.netAssetAmount)}원` : "-"}
|
||||
주식 평가금{" "}
|
||||
{summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
총예수금(KIS){" "}
|
||||
{summary ? `${formatCurrency(summary.totalDepositAmount)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/80">
|
||||
총예수금은 결제 대기 금액이 포함될 수 있어 체감 현금과 다를 수 있습니다.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
순자산(대출 반영){" "}
|
||||
{summary ? `${formatCurrency(summary.netAssetAmount)}원` : "-"}
|
||||
</p>
|
||||
{hasApiTotalAmount && isApiTotalAmountDifferent ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
KIS 집계 총자산 {formatCurrency(summary?.apiReportedTotalAmount ?? 0)}원
|
||||
</p>
|
||||
) : null}
|
||||
{hasApiNetAssetAmount ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
KIS 집계 순자산 {formatCurrency(summary?.apiReportedNetAssetAmount ?? 0)}원
|
||||
</p>
|
||||
) : null}
|
||||
</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
|
||||
className={cn(
|
||||
"mt-1 text-xl font-semibold tracking-tight",
|
||||
toneClass,
|
||||
)}
|
||||
>
|
||||
{summary ? `${formatSignedCurrency(summary.totalProfitLoss)}원` : "-"}
|
||||
</p>
|
||||
<p className={cn("mt-1 text-xs font-medium", toneClass)}>
|
||||
{summary ? formatPercent(summary.totalProfitRate) : "-"}
|
||||
{summary ? formatSignedPercent(summary.totalProfitRate) : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
현재 평가금액 {summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}
|
||||
현재 평가금액{" "}
|
||||
{summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
총 매수금액 {summary ? `${formatCurrency(summary.purchaseAmount)}원` : "-"}
|
||||
총 매수금액{" "}
|
||||
{summary ? `${formatCurrency(summary.purchaseAmount)}원` : "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -98,12 +144,14 @@ export function StatusHeader({
|
||||
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"
|
||||
? isRealtimePending
|
||||
? "bg-amber-500/10 text-amber-700 dark:text-amber-400"
|
||||
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
실시간 시세 {isWebSocketReady ? "연결됨" : "미연결"}
|
||||
실시간 시세 {realtimeStatusLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -129,7 +177,7 @@ export function StatusHeader({
|
||||
</div>
|
||||
|
||||
{/* ========== QUICK ACTIONS ========== */}
|
||||
<div className="flex items-end gap-2 md:flex-col md:items-stretch md:justify-between">
|
||||
<div className="flex flex-col gap-2 sm:flex-row md:flex-col md:items-stretch md:justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -138,16 +186,16 @@ export function StatusHeader({
|
||||
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" : "")}
|
||||
className={cn("h-4 w-4 mr-2", isRefreshing ? "animate-spin" : "")}
|
||||
/>
|
||||
지금 다시 불러오기
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
className="w-full bg-brand-600 text-white hover:bg-brand-700"
|
||||
className="w-full bg-brand-600 text-white hover:bg-brand-700 dark:bg-brand-600 dark:text-white dark:hover:bg-brand-500"
|
||||
>
|
||||
<Link href="/settings">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
<Settings2 className="h-4 w-4 mr-2" />
|
||||
연결 설정
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { BarChartBig, MousePointerClick } from "lucide-react";
|
||||
/**
|
||||
* @file StockDetailPreview.tsx
|
||||
* @description 대시보드 우측 영역의 선택 종목 상세 정보 및 실시간 시세 반영 컴포넌트
|
||||
* @remarks
|
||||
* - [레이어] Components / UI
|
||||
* - [사용자 행동] 종목 리스트에서 항목 선택 -> 상세 정보 조회 -> 실시간 시세 변동 확인
|
||||
* - [데이터 흐름] DashboardContainer(realtimeSelectedHolding) -> StockDetailPreview -> Metric(UI)
|
||||
* - [연관 파일] DashboardContainer.tsx, dashboard.types.ts, use-price-flash.ts
|
||||
* @author jihoon87.lee
|
||||
*/
|
||||
import { BarChartBig, ExternalLink, MousePointerClick } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -6,6 +16,8 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
|
||||
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
formatCurrency,
|
||||
@@ -15,18 +27,30 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StockDetailPreviewProps {
|
||||
/** 선택된 종목 정보 (없으면 null) */
|
||||
holding: DashboardHoldingItem | null;
|
||||
/** 현재 총 자산 (비중 계산용) */
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 선택 종목 상세 요약 카드입니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx HoldingsList 선택 결과를 전달받아 렌더링합니다.
|
||||
* [컴포넌트] 선택 종목 상세 요약 카드
|
||||
* 대시보드에서 선택된 특정 종목의 매입가, 현재가, 수익률 등 상세 지표를 실시간으로 보여줍니다.
|
||||
*
|
||||
* @param props StockDetailPreviewProps
|
||||
* @see DashboardContainer.tsx - HoldingsList 선택 결과를 실시간 데이터로 전달받아 렌더링
|
||||
*/
|
||||
export function StockDetailPreview({
|
||||
holding,
|
||||
totalAmount,
|
||||
}: StockDetailPreviewProps) {
|
||||
const router = useRouter();
|
||||
// [State/Hook] 실시간 가격 변동 애니메이션 상태 관리
|
||||
// @remarks 종목이 선택되지 않았을 때를 대비해 safe value(0)를 전달하며, 종목 변경 시 효과를 초기화하도록 symbol 전달
|
||||
const currentPrice = holding?.currentPrice ?? 0;
|
||||
const priceFlash = usePriceFlash(currentPrice, holding?.symbol);
|
||||
|
||||
// [Step 1] 종목이 선택되지 않은 경우 초기 안내 화면 렌더링
|
||||
if (!holding) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -48,29 +72,67 @@ export function StockDetailPreview({
|
||||
);
|
||||
}
|
||||
|
||||
// [Step 2] 수익/손실 여부에 따른 UI 톤(색상) 결정
|
||||
const profitToneClass = getChangeToneClass(holding.profitLoss);
|
||||
|
||||
// [Step 3] 총 자산 대비 비중 계산
|
||||
const allocationRate =
|
||||
totalAmount > 0 ? Math.min((holding.evaluationAmount / totalAmount) * 100, 100) : 0;
|
||||
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 className="flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/trade?symbol=${holding.symbol}&name=${encodeURIComponent(holding.name)}`,
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"group flex items-center gap-1.5 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5",
|
||||
"text-sm font-bold text-brand-700 transition-all cursor-pointer",
|
||||
"hover:border-brand-400 hover:bg-brand-100 hover:shadow-sm",
|
||||
"dark:border-brand-800/60 dark:bg-brand-900/40 dark:text-brand-400 dark:hover:border-brand-600 dark:hover:bg-brand-900/60",
|
||||
)}
|
||||
title={`${holding.name} 종목 상세 거래로 이동`}
|
||||
>
|
||||
<span className="truncate">{holding.name}</span>
|
||||
<span className="text-[10px] font-medium opacity-70">
|
||||
({holding.symbol})
|
||||
</span>
|
||||
<ExternalLink className="h-3 w-3 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
· {holding.market}
|
||||
</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* ========== PRIMARY METRICS ========== */}
|
||||
{/* ========== 실시간 주요 지표 영역 (Grid) ========== */}
|
||||
<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={`${holding.quantity.toLocaleString("ko-KR")}주`}
|
||||
/>
|
||||
<Metric
|
||||
label="매입 평균가"
|
||||
value={`${formatCurrency(holding.averagePrice)}원`}
|
||||
/>
|
||||
<Metric
|
||||
label="현재가"
|
||||
value={`${formatCurrency(holding.currentPrice)}원`}
|
||||
flash={priceFlash}
|
||||
/>
|
||||
<Metric
|
||||
label="수익률"
|
||||
value={formatPercent(holding.profitRate)}
|
||||
@@ -87,7 +149,7 @@ export function StockDetailPreview({
|
||||
/>
|
||||
</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>
|
||||
@@ -101,7 +163,7 @@ export function StockDetailPreview({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== QUICK ORDER PLACEHOLDER ========== */}
|
||||
{/* ========== 추가 기능 예고 영역 (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" />
|
||||
@@ -117,23 +179,48 @@ export function StockDetailPreview({
|
||||
}
|
||||
|
||||
interface MetricProps {
|
||||
/** 지표 레이블 */
|
||||
label: string;
|
||||
/** 표시될 값 */
|
||||
value: string;
|
||||
/** 값 텍스트 추가 스타일 */
|
||||
valueClassName?: string;
|
||||
/** 가격 변동 애니메이션 상태 */
|
||||
flash?: { type: "up" | "down"; val: number; id: number } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 상세 카드에서 공통으로 사용하는 지표 행입니다.
|
||||
* @param label 지표명
|
||||
* @param value 지표값
|
||||
* @param valueClassName 값 텍스트 색상 클래스
|
||||
* @see features/dashboard/components/StockDetailPreview.tsx 종목 상세 지표 표시
|
||||
* [컴포넌트] 상세 카드용 개별 지표 아이템
|
||||
* 레이블과 값을 박스 형태로 렌더링하며, 필요한 경우 시세 변동 Flash 애니메이션을 처리합니다.
|
||||
*
|
||||
* @param props MetricProps
|
||||
* @see StockDetailPreview.tsx - 내부 그리드 영역에서 여러 개 호출
|
||||
*/
|
||||
function Metric({ label, value, valueClassName }: MetricProps) {
|
||||
function Metric({ label, value, valueClassName, flash }: MetricProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/70 bg-background/70 p-3">
|
||||
<div className="relative overflow-hidden rounded-xl border border-border/70 bg-background/70 p-3 transition-colors">
|
||||
{/* 시세 변동 시 나타나는 일시적인 수치 표시 (Flash) */}
|
||||
{flash && (
|
||||
<span
|
||||
key={flash.id}
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-2 top-2 text-xs font-bold animate-in fade-in slide-in-from-bottom-1 fill-mode-forwards duration-300",
|
||||
flash.type === "up" ? "text-red-500" : "text-blue-500",
|
||||
)}
|
||||
>
|
||||
{flash.type === "up" ? "+" : ""}
|
||||
{flash.val.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 지표 레이블 및 본체 값 */}
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={cn("mt-1 text-sm font-semibold text-foreground", valueClassName)}>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-sm font-semibold text-foreground transition-colors",
|
||||
valueClassName,
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user