전체적인 리팩토링
This commit is contained in:
@@ -8,6 +8,7 @@ import type {
|
||||
DashboardActivityResponse,
|
||||
DashboardBalanceResponse,
|
||||
DashboardIndicesResponse,
|
||||
DashboardMarketHubResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
/**
|
||||
@@ -92,3 +93,31 @@ export async function fetchDashboardActivity(
|
||||
|
||||
return payload as DashboardActivityResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 급등/인기/뉴스 시장 허브 데이터를 조회합니다.
|
||||
* @param credentials KIS 인증 정보
|
||||
* @returns 시장 허브 응답
|
||||
* @see app/api/kis/domestic/market-hub/route.ts 서버 라우트
|
||||
*/
|
||||
export async function fetchDashboardMarketHub(
|
||||
credentials: KisRuntimeCredentials,
|
||||
): Promise<DashboardMarketHubResponse> {
|
||||
const response = await fetch("/api/kis/domestic/market-hub", {
|
||||
method: "GET",
|
||||
headers: buildKisRequestHeaders(credentials),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as
|
||||
| DashboardMarketHubResponse
|
||||
| KisApiErrorPayload;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
resolveKisApiErrorMessage(payload, "시장 허브 조회 중 오류가 발생했습니다."),
|
||||
);
|
||||
}
|
||||
|
||||
return payload as DashboardMarketHubResponse;
|
||||
}
|
||||
|
||||
@@ -46,15 +46,15 @@ export function ActivitySection({
|
||||
const warnings = activity?.warnings ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="border-brand-200/70 bg-background/90 shadow-sm dark:border-brand-800/45">
|
||||
<CardHeader className="pb-3">
|
||||
{/* ========== TITLE ========== */}
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ClipboardList className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||
주문내역 · 매매일지
|
||||
매수 · 매도 기록 (주문내역/매매일지)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
최근 주문 체결 내역과 실현손익 기록을 확인합니다.
|
||||
최근 매수/매도 주문 흐름과 실현손익을 한 번에 확인합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -106,9 +106,19 @@ export function ActivitySection({
|
||||
|
||||
{/* ========== TABS ========== */}
|
||||
<Tabs defaultValue="orders" className="gap-3">
|
||||
<TabsList className="w-full justify-start">
|
||||
<TabsTrigger value="orders">주문내역 {orders.length}건</TabsTrigger>
|
||||
<TabsTrigger value="journal">매매일지 {journalRows.length}건</TabsTrigger>
|
||||
<TabsList className="h-auto w-full justify-start rounded-xl border border-brand-200/70 bg-background/80 p-1 dark:border-brand-800/50 dark:bg-background/60">
|
||||
<TabsTrigger
|
||||
value="orders"
|
||||
className="h-9 rounded-lg px-3 data-[state=active]:bg-brand-600 data-[state=active]:text-white"
|
||||
>
|
||||
주문내역 {orders.length}건
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="journal"
|
||||
className="h-9 rounded-lg px-3 data-[state=active]:bg-brand-600 data-[state=active]:text-white"
|
||||
>
|
||||
매매일지 {journalRows.length}건
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="orders">
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { BriefcaseBusiness, Gauge, Sparkles } from "lucide-react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ActivitySection } from "@/features/dashboard/components/ActivitySection";
|
||||
import { DashboardAccessGate } from "@/features/dashboard/components/DashboardAccessGate";
|
||||
import { DashboardSkeleton } from "@/features/dashboard/components/DashboardSkeleton";
|
||||
import { HoldingsList } from "@/features/dashboard/components/HoldingsList";
|
||||
import { MarketHubSection } from "@/features/dashboard/components/MarketHubSection";
|
||||
import { MarketSummary } from "@/features/dashboard/components/MarketSummary";
|
||||
import { StatusHeader } from "@/features/dashboard/components/StatusHeader";
|
||||
import { StockDetailPreview } from "@/features/dashboard/components/StockDetailPreview";
|
||||
@@ -70,6 +73,8 @@ export function DashboardContainer() {
|
||||
activityError,
|
||||
balanceError,
|
||||
indicesError,
|
||||
marketHub,
|
||||
marketHubError,
|
||||
lastUpdatedAt,
|
||||
refresh,
|
||||
} = useDashboardData(canAccess ? verifiedCredentials : null);
|
||||
@@ -125,6 +130,15 @@ export function DashboardContainer() {
|
||||
wsApprovalKey && wsUrl && isWsConnected && !hasRealtimeStreaming,
|
||||
);
|
||||
const effectiveIndicesError = indices.length === 0 ? indicesError : null;
|
||||
const restStatusLabel = isKisRestConnected ? "REST 정상" : "REST 점검 필요";
|
||||
const realtimeStatusLabel = isWsConnected
|
||||
? isRealtimePending
|
||||
? "실시간 대기중"
|
||||
: "실시간 수신중"
|
||||
: "실시간 미연결";
|
||||
const profileStatusLabel = isKisProfileVerified
|
||||
? "계좌 인증 완료"
|
||||
: "계좌 인증 필요";
|
||||
const indicesWarning =
|
||||
indices.length > 0 && indicesError
|
||||
? "지수 API 재요청 중입니다. 화면 값은 실시간/최근 성공 데이터입니다."
|
||||
@@ -181,71 +195,161 @@ export function DashboardContainer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto flex w-full max-w-7xl flex-col gap-4 p-4 md:p-6">
|
||||
{/* ========== 상단 상태 영역: 계좌 연결 정보 및 새로고침 ========== */}
|
||||
<StatusHeader
|
||||
summary={mergedSummary}
|
||||
isKisRestConnected={isKisRestConnected}
|
||||
isWebSocketReady={Boolean(wsApprovalKey && wsUrl && isWsConnected)}
|
||||
isRealtimePending={isRealtimePending}
|
||||
isProfileVerified={isKisProfileVerified}
|
||||
verifiedAccountNo={verifiedAccountNo}
|
||||
isRefreshing={isRefreshing}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
onRefresh={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
/>
|
||||
<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="grid gap-4 lg:grid-cols-[1.15fr_1fr]">
|
||||
{/* 왼쪽 섹션: 보유 종목 목록 리스트 */}
|
||||
<HoldingsList
|
||||
holdings={mergedHoldings}
|
||||
selectedSymbol={selectedSymbol}
|
||||
isLoading={isLoading}
|
||||
error={balanceError}
|
||||
onRetry={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
onSelect={setSelectedSymbol}
|
||||
/>
|
||||
<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-4">
|
||||
{/* 시장 지수 현황 (코스피/코스닥) */}
|
||||
<MarketSummary
|
||||
items={indices}
|
||||
isLoading={isLoading}
|
||||
error={effectiveIndicesError}
|
||||
warning={indicesWarning}
|
||||
<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)}
|
||||
isRealtimePending={isRealtimePending}
|
||||
onRetry={() => {
|
||||
isProfileVerified={isKisProfileVerified}
|
||||
verifiedAccountNo={verifiedAccountNo}
|
||||
isRefreshing={isRefreshing}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
onRefresh={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 선택된 종목의 실시간 상세 요약 정보 */}
|
||||
<StockDetailPreview
|
||||
holding={realtimeSelectedHolding}
|
||||
totalAmount={mergedSummary?.totalAmount ?? 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ========== 하단 섹션: 최근 매매/충전 활동 내역 ========== */}
|
||||
<ActivitySection
|
||||
activity={activity}
|
||||
isLoading={isLoading}
|
||||
error={activityError}
|
||||
onRetry={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description REST 지수 데이터가 없을 때 실시간 수신값만으로 코스피/코스닥 표시 데이터를 구성합니다.
|
||||
* @param realtimeIndices 실시간 지수 맵
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
import { AlertCircle, Wallet2 } from "lucide-react";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
formatPercent,
|
||||
getChangeToneClass,
|
||||
} from "@/features/dashboard/utils/dashboard-format";
|
||||
import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
|
||||
|
||||
@@ -59,8 +61,22 @@ export function HoldingsList({
|
||||
onRetry,
|
||||
onSelect,
|
||||
}: HoldingsListProps) {
|
||||
const router = useRouter();
|
||||
const setPendingTarget = useTradeNavigationStore(
|
||||
(state) => state.setPendingTarget,
|
||||
);
|
||||
|
||||
const handleNavigateToTrade = (holding: DashboardHoldingItem) => {
|
||||
setPendingTarget({
|
||||
symbol: holding.symbol,
|
||||
name: holding.name,
|
||||
market: holding.market,
|
||||
});
|
||||
router.push("/trade");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<Card className="h-full border-brand-200/70 bg-background/90 shadow-sm dark:border-brand-800/45">
|
||||
{/* ========== 카드 헤더: 타이틀 및 설명 ========== */}
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@@ -113,7 +129,7 @@ export function HoldingsList({
|
||||
|
||||
{/* 종목 리스트 렌더링 영역 */}
|
||||
{holdings.length > 0 && (
|
||||
<ScrollArea className="h-[420px] pr-3">
|
||||
<ScrollArea className="h-[360px] pr-3">
|
||||
<div className="space-y-2">
|
||||
{holdings.map((holding) => (
|
||||
<HoldingItemRow
|
||||
@@ -121,6 +137,7 @@ export function HoldingsList({
|
||||
holding={holding}
|
||||
isSelected={selectedSymbol === holding.symbol}
|
||||
onSelect={onSelect}
|
||||
onNavigateToTrade={handleNavigateToTrade}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -138,6 +155,8 @@ interface HoldingItemRowProps {
|
||||
isSelected: boolean;
|
||||
/** 클릭 핸들러 */
|
||||
onSelect: (symbol: string) => void;
|
||||
/** 거래 페이지 이동 핸들러 */
|
||||
onNavigateToTrade: (holding: DashboardHoldingItem) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,6 +171,7 @@ function HoldingItemRow({
|
||||
holding,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onNavigateToTrade,
|
||||
}: HoldingItemRowProps) {
|
||||
// [State/Hook] 현재가 기반 가격 반짝임 효과 상태 관리
|
||||
// @see use-price-flash.ts - 현재가 변경 감지 시 symbol을 기준으로 이펙트 발생 여부 결정
|
||||
@@ -163,13 +183,16 @@ function HoldingItemRow({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
// [Step 1] 종목 클릭 시 부모의 선택 핸들러 호출
|
||||
onClick={() => onSelect(holding.symbol)}
|
||||
// [Step 1] 종목 클릭 시 선택 상태 갱신 후 거래 화면으로 이동
|
||||
onClick={() => {
|
||||
onSelect(holding.symbol);
|
||||
onNavigateToTrade(holding);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full rounded-xl border px-3 py-3 text-left transition-all relative overflow-hidden",
|
||||
"relative w-full overflow-hidden rounded-xl border px-3 py-3 text-left shadow-sm 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",
|
||||
: "border-border/70 bg-background hover:-translate-y-0.5 hover:border-brand-200 hover:bg-brand-50/30 hover:shadow-md dark:hover:border-brand-700 dark:hover:bg-brand-900/15",
|
||||
)}
|
||||
>
|
||||
{/* ========== 행 상단: 종목명, 심볼 및 현재가 정보 ========== */}
|
||||
@@ -180,7 +203,8 @@ function HoldingItemRow({
|
||||
{holding.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{holding.symbol} · {holding.market} · {holding.quantity}주
|
||||
{holding.symbol} · {holding.market} · 보유 {holding.quantity}주 · 매도가능{" "}
|
||||
{holding.sellableQuantity}주
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
348
features/dashboard/components/MarketHubSection.tsx
Normal file
348
features/dashboard/components/MarketHubSection.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
Flame,
|
||||
Newspaper,
|
||||
RefreshCcw,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type {
|
||||
DashboardMarketHubResponse,
|
||||
DashboardMarketRankItem,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatSignedCurrency,
|
||||
formatSignedPercent,
|
||||
getChangeToneClass,
|
||||
} from "@/features/dashboard/utils/dashboard-format";
|
||||
import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarketHubSectionProps {
|
||||
marketHub: DashboardMarketHubResponse | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 시장 탭의 급등/인기/뉴스 요약 섹션입니다.
|
||||
* @remarks UI 흐름: DashboardContainer -> MarketHubSection -> 급등/인기/뉴스 카드 렌더링
|
||||
*/
|
||||
export function MarketHubSection({
|
||||
marketHub,
|
||||
isLoading,
|
||||
error,
|
||||
onRetry,
|
||||
}: MarketHubSectionProps) {
|
||||
const router = useRouter();
|
||||
const setPendingTarget = useTradeNavigationStore(
|
||||
(state) => state.setPendingTarget,
|
||||
);
|
||||
const gainers = marketHub?.gainers ?? [];
|
||||
const losers = marketHub?.losers ?? [];
|
||||
const popularByVolume = marketHub?.popularByVolume ?? [];
|
||||
const popularByValue = marketHub?.popularByValue ?? [];
|
||||
const news = marketHub?.news ?? [];
|
||||
const warnings = marketHub?.warnings ?? [];
|
||||
const pulse = marketHub?.pulse;
|
||||
|
||||
const navigateToTrade = (item: DashboardMarketRankItem) => {
|
||||
setPendingTarget({
|
||||
symbol: item.symbol,
|
||||
name: item.name,
|
||||
market: item.market,
|
||||
});
|
||||
router.push("/trade");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<Card className="border-brand-200/80 bg-linear-to-br from-brand-100/60 via-brand-50/20 to-background shadow-sm dark:border-brand-800/45 dark:from-brand-900/30 dark:via-brand-950/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base text-brand-700 dark:text-brand-300">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
시장 허브
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
급등/급락, 인기 종목, 주요 뉴스를 한 화면에서 확인합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<PulseMetric label="급등주" value={`${pulse?.gainersCount ?? 0}개`} tone="up" />
|
||||
<PulseMetric label="급락주" value={`${pulse?.losersCount ?? 0}개`} tone="down" />
|
||||
<PulseMetric
|
||||
label="인기종목(거래량)"
|
||||
value={`${pulse?.popularByVolumeCount ?? 0}개`}
|
||||
tone="neutral"
|
||||
/>
|
||||
<PulseMetric
|
||||
label="거래대금 상위"
|
||||
value={`${pulse?.popularByValueCount ?? 0}개`}
|
||||
tone="neutral"
|
||||
/>
|
||||
<PulseMetric label="주요 뉴스" value={`${pulse?.newsCount ?? 0}건`} tone="brand" />
|
||||
</div>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{warnings.map((warning) => (
|
||||
<Badge
|
||||
key={warning}
|
||||
variant="outline"
|
||||
className="border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{warning}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && !marketHub && (
|
||||
<p className="text-sm text-muted-foreground">시장 허브를 불러오는 중입니다.</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<RankListCard
|
||||
title="급등주식"
|
||||
description="전일 대비 상승률 상위 종목"
|
||||
icon={Flame}
|
||||
items={gainers}
|
||||
tone="up"
|
||||
onSelectItem={navigateToTrade}
|
||||
secondaryLabel="거래량"
|
||||
secondaryValue={(item) => `${formatCurrency(item.volume)}주`}
|
||||
/>
|
||||
<RankListCard
|
||||
title="급락주식"
|
||||
description="전일 대비 하락률 상위 종목"
|
||||
icon={TrendingDown}
|
||||
items={losers}
|
||||
tone="down"
|
||||
onSelectItem={navigateToTrade}
|
||||
secondaryLabel="거래량"
|
||||
secondaryValue={(item) => `${formatCurrency(item.volume)}주`}
|
||||
/>
|
||||
<RankListCard
|
||||
title="인기종목"
|
||||
description="거래량 상위 종목"
|
||||
icon={BarChart3}
|
||||
items={popularByVolume}
|
||||
tone="neutral"
|
||||
onSelectItem={navigateToTrade}
|
||||
secondaryLabel="거래량"
|
||||
secondaryValue={(item) => `${formatCurrency(item.volume)}주`}
|
||||
/>
|
||||
<RankListCard
|
||||
title="거래대금 상위"
|
||||
description="거래대금 상위 종목"
|
||||
icon={TrendingUp}
|
||||
items={popularByValue}
|
||||
tone="brand"
|
||||
onSelectItem={navigateToTrade}
|
||||
secondaryLabel="거래대금"
|
||||
secondaryValue={(item) => `${formatCurrency(item.tradingValue)}원`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="border-brand-200/70 bg-background/90 dark:border-brand-800/45">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Newspaper className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||
주요 뉴스
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
국내 시장 시황/공시 제목을 최신순으로 보여줍니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[220px] pr-3">
|
||||
{news.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">표시할 뉴스가 없습니다.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{news.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="rounded-xl border border-border/70 bg-linear-to-br from-background to-brand-50/30 px-3 py-2 dark:from-background dark:to-brand-950/20"
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{item.source} · {item.publishedAt}
|
||||
</p>
|
||||
{item.symbols.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{item.symbols.slice(0, 3).map((symbol) => (
|
||||
<Badge
|
||||
key={`${item.id}-${symbol}`}
|
||||
variant="outline"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{symbol}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PulseMetric({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: "up" | "down" | "neutral" | "brand";
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === "up"
|
||||
? "border-red-200/70 bg-red-50/70 dark:border-red-900/40 dark:bg-red-950/20"
|
||||
: tone === "down"
|
||||
? "border-blue-200/70 bg-blue-50/70 dark:border-blue-900/40 dark:bg-blue-950/20"
|
||||
: tone === "brand"
|
||||
? "border-brand-200/70 bg-brand-50/70 dark:border-brand-700/60 dark:bg-brand-900/30"
|
||||
: "border-border/70 bg-background/80";
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-xl border p-3", toneClass)}>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RankListCard({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
items,
|
||||
tone,
|
||||
onSelectItem,
|
||||
secondaryLabel,
|
||||
secondaryValue,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
items: DashboardMarketRankItem[];
|
||||
tone: "up" | "down" | "neutral" | "brand";
|
||||
onSelectItem: (item: DashboardMarketRankItem) => void;
|
||||
secondaryLabel: string;
|
||||
secondaryValue: (item: DashboardMarketRankItem) => string;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === "up"
|
||||
? "border-red-200/70 bg-red-50/35 dark:border-red-900/35 dark:bg-red-950/15"
|
||||
: tone === "down"
|
||||
? "border-blue-200/70 bg-blue-50/35 dark:border-blue-900/35 dark:bg-blue-950/15"
|
||||
: tone === "brand"
|
||||
? "border-brand-200/70 bg-brand-50/35 dark:border-brand-800/50 dark:bg-brand-900/20"
|
||||
: "border-border/70 bg-background/90";
|
||||
|
||||
return (
|
||||
<Card className={cn("overflow-hidden shadow-sm", toneClass)}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Icon className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[220px] pr-3">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">표시할 데이터가 없습니다.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const toneClass = getChangeToneClass(item.change);
|
||||
return (
|
||||
<div
|
||||
key={`${title}-${item.symbol}-${item.rank}`}
|
||||
className="rounded-xl border border-border/70 bg-background/80 px-3 py-2 shadow-sm"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectItem(item)}
|
||||
className="w-full text-left hover:opacity-90"
|
||||
title={`${item.name} 거래 화면으로 이동`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-foreground">{item.name}</p>
|
||||
<p className={cn("text-xs font-medium", toneClass)}>
|
||||
{formatSignedPercent(item.changeRate)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
#{item.rank} · {item.symbol} · {item.market}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
현재가 {formatCurrency(item.price)}원
|
||||
</span>
|
||||
<span className={cn("font-medium", toneClass)}>
|
||||
{formatSignedCurrency(item.change)}원
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{secondaryLabel} {secondaryValue(item)}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BarChart3, TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -22,6 +23,7 @@ interface MarketSummaryProps {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
warning?: string | null;
|
||||
isWebSocketReady?: boolean;
|
||||
isRealtimePending?: boolean;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
@@ -35,22 +37,46 @@ export function MarketSummary({
|
||||
isLoading,
|
||||
error,
|
||||
warning = null,
|
||||
isWebSocketReady = false,
|
||||
isRealtimePending = false,
|
||||
onRetry,
|
||||
}: MarketSummaryProps) {
|
||||
const realtimeBadgeText = isRealtimePending
|
||||
? "실시간 대기중"
|
||||
: isWebSocketReady
|
||||
? "실시간 수신중"
|
||||
: items.length > 0
|
||||
? "REST 데이터"
|
||||
: "데이터 준비중";
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Card className="overflow-hidden border-brand-200/80 bg-linear-to-br from-brand-100/65 via-brand-50/30 to-background shadow-sm dark:border-brand-800/45 dark:from-brand-900/35 dark:via-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>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-brand-300/70 bg-background/80 text-[11px] font-medium dark:border-brand-700/60 dark:bg-brand-950/30",
|
||||
isRealtimePending
|
||||
? "text-amber-700 dark:text-amber-300"
|
||||
: isWebSocketReady
|
||||
? "text-emerald-700 dark:text-emerald-300"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{realtimeBadgeText}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>실시간 코스피/코스닥 지수 현황입니다.</CardDescription>
|
||||
<CardDescription>
|
||||
코스피/코스닥 핵심 지수와 전일 대비 흐름을 빠르게 확인합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2">
|
||||
{/* ========== LOADING STATE ========== */}
|
||||
{isLoading && items.length === 0 && (
|
||||
<div className="col-span-full py-4 text-center text-sm text-muted-foreground animate-pulse">
|
||||
@@ -133,23 +159,23 @@ function IndexItem({ item }: { item: DashboardMarketIndexItem }) {
|
||||
: "text-muted-foreground";
|
||||
|
||||
const bgClass = isUp
|
||||
? "bg-red-50/50 dark:bg-red-950/10 border-red-100 dark:border-red-900/30"
|
||||
? "bg-linear-to-br from-red-50/90 to-background dark:from-red-950/20 dark:to-background border-red-100/80 dark:border-red-900/40"
|
||||
: isDown
|
||||
? "bg-blue-50/50 dark:bg-blue-950/10 border-blue-100 dark:border-blue-900/30"
|
||||
: "bg-muted/50 border-border/50";
|
||||
? "bg-linear-to-br from-blue-50/90 to-background dark:from-blue-950/20 dark:to-background border-blue-100/80 dark:border-blue-900/40"
|
||||
: "bg-linear-to-br from-muted/60 to-background 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",
|
||||
"relative flex flex-col justify-between rounded-2xl border p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md",
|
||||
bgClass,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{item.market}
|
||||
{item.name} ({item.market})
|
||||
</span>
|
||||
{isUp ? (
|
||||
<TrendingUp className="h-4 w-4 text-red-500/70" />
|
||||
@@ -158,7 +184,7 @@ function IndexItem({ item }: { item: DashboardMarketIndexItem }) {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-2xl font-bold tracking-tight relative w-fit">
|
||||
<div className="relative mt-2 w-fit text-2xl font-bold tracking-tight">
|
||||
{formatCurrency(item.price)}
|
||||
|
||||
{/* Flash Indicator */}
|
||||
@@ -176,14 +202,9 @@ function IndexItem({ item }: { item: DashboardMarketIndexItem }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex items-center gap-2 text-sm font-medium",
|
||||
toneClass,
|
||||
)}
|
||||
>
|
||||
<div className={cn("mt-2 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">
|
||||
<span className="rounded-md bg-background/70 px-1.5 py-0.5 text-xs shadow-sm">
|
||||
{formatSignedPercent(item.changeRate)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Activity, RefreshCcw, Settings2, Wifi } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -58,153 +59,155 @@ export function StatusHeader({
|
||||
? "수신 대기중"
|
||||
: "연결됨"
|
||||
: "미연결";
|
||||
const displayGrossTotalAmount = hasApiTotalAmount
|
||||
? summary?.apiReportedTotalAmount ?? 0
|
||||
: summary?.totalAmount ?? 0;
|
||||
|
||||
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" />
|
||||
<Card className="relative overflow-hidden border-brand-200/80 bg-linear-to-br from-brand-50/80 via-background to-brand-50/20 shadow-sm dark:border-brand-800/45 dark:from-brand-900/25 dark:via-background dark:to-background">
|
||||
<div className="pointer-events-none absolute -right-14 -top-14 h-52 w-52 rounded-full bg-brand-300/30 blur-3xl dark:bg-brand-700/20" />
|
||||
<div className="pointer-events-none absolute -left-16 bottom-0 h-44 w-44 rounded-full bg-brand-200/25 blur-3xl dark:bg-brand-800/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>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
주식 평가금{" "}
|
||||
{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)}원
|
||||
<CardContent className="relative space-y-3 p-4 md:p-5">
|
||||
<div className="grid gap-3 xl:grid-cols-[1fr_1fr_auto]">
|
||||
<div className="rounded-2xl border border-brand-200/70 bg-background/85 p-4 shadow-sm dark:border-brand-800/60 dark:bg-brand-950/20">
|
||||
<p className="text-xs font-semibold tracking-wide text-muted-foreground">
|
||||
TOTAL ASSET
|
||||
</p>
|
||||
) : null}
|
||||
{hasApiNetAssetAmount ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
KIS 집계 순자산 {formatCurrency(summary?.apiReportedNetAssetAmount ?? 0)}원
|
||||
<p className="mt-2 text-2xl font-bold tracking-tight text-foreground md:text-3xl">
|
||||
{summary ? `${formatCurrency(displayGrossTotalAmount)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
순자산 {summary ? `${formatCurrency(summary.netAssetAmount)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
현금 {summary ? `${formatCurrency(summary.cashBalance)}원` : "-"} · 평가금{" "}
|
||||
{summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}
|
||||
</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 ? `${formatSignedCurrency(summary.totalProfitLoss)}원` : "-"}
|
||||
</p>
|
||||
<p className={cn("mt-1 text-xs font-medium", toneClass)}>
|
||||
{summary ? formatSignedPercent(summary.totalProfitRate) : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
현재 평가금액{" "}
|
||||
{summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
총 매수금액{" "}
|
||||
{summary ? `${formatCurrency(summary.purchaseAmount)}원` : "-"}
|
||||
</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" />
|
||||
서버 {isKisRestConnected ? "연결됨" : "연결 끊김"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1",
|
||||
isWebSocketReady
|
||||
? 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" />
|
||||
실시간 시세 {realtimeStatusLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1",
|
||||
isProfileVerified
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
||||
)}
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
계좌 인증 {isProfileVerified ? "완료" : "미완료"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
마지막 업데이트 {updatedLabel}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
계좌 {maskAccountNo(verifiedAccountNo)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
대출금 {summary ? `${formatCurrency(summary.loanAmount)}원` : "-"}
|
||||
</p>
|
||||
|
||||
<div className="rounded-2xl border border-border/70 bg-background/85 p-4 shadow-sm">
|
||||
<p className="text-xs font-semibold tracking-wide text-muted-foreground">
|
||||
TODAY P/L
|
||||
</p>
|
||||
<p className={cn("mt-2 text-2xl font-bold tracking-tight md:text-3xl", toneClass)}>
|
||||
{summary ? `${formatSignedCurrency(summary.totalProfitLoss)}원` : "-"}
|
||||
</p>
|
||||
<p className={cn("mt-1 text-sm font-semibold", toneClass)}>
|
||||
{summary ? formatSignedPercent(summary.totalProfitRate) : "-"}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
매수금 {summary ? `${formatCurrency(summary.purchaseAmount)}원` : "-"} · 대출금{" "}
|
||||
{summary ? `${formatCurrency(summary.loanAmount)}원` : "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-2xl border border-border/70 bg-background/85 p-3">
|
||||
<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("mr-2 h-4 w-4", isRefreshing ? "animate-spin" : "")} />
|
||||
다시 불러오기
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
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="mr-2 h-4 w-4" />
|
||||
연결 설정
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="mt-1 rounded-xl border border-border/70 bg-muted/30 px-2.5 py-2 text-[11px] text-muted-foreground">
|
||||
<p>업데이트 {updatedLabel}</p>
|
||||
<p>계좌 {maskAccountNo(verifiedAccountNo)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== QUICK ACTIONS ========== */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row md:flex-col md:items-stretch md:justify-center">
|
||||
<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 mr-2", isRefreshing ? "animate-spin" : "")}
|
||||
/>
|
||||
지금 다시 불러오기
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
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 mr-2" />
|
||||
연결 설정
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<OverviewMetric
|
||||
icon={<Wifi className="h-3.5 w-3.5" />}
|
||||
label="REST 연결"
|
||||
value={isKisRestConnected ? "정상" : "끊김"}
|
||||
toneClass={
|
||||
isKisRestConnected
|
||||
? "border-emerald-300/70 bg-emerald-50/60 text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-950/30 dark:text-emerald-300"
|
||||
: "border-red-300/70 bg-red-50/60 text-red-700 dark:border-red-700/50 dark:bg-red-950/30 dark:text-red-300"
|
||||
}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<Activity className="h-3.5 w-3.5" />}
|
||||
label="실시간 시세"
|
||||
value={realtimeStatusLabel}
|
||||
toneClass={
|
||||
isWebSocketReady
|
||||
? isRealtimePending
|
||||
? "border-amber-300/70 bg-amber-50/60 text-amber-700 dark:border-amber-700/50 dark:bg-amber-950/30 dark:text-amber-300"
|
||||
: "border-emerald-300/70 bg-emerald-50/60 text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-950/30 dark:text-emerald-300"
|
||||
: "border-slate-300/70 bg-slate-50/60 text-slate-700 dark:border-slate-700/50 dark:bg-slate-900/30 dark:text-slate-300"
|
||||
}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<Activity className="h-3.5 w-3.5" />}
|
||||
label="계좌 인증"
|
||||
value={isProfileVerified ? "완료" : "미완료"}
|
||||
toneClass={
|
||||
isProfileVerified
|
||||
? "border-emerald-300/70 bg-emerald-50/60 text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-950/30 dark:text-emerald-300"
|
||||
: "border-amber-300/70 bg-amber-50/60 text-amber-700 dark:border-amber-700/50 dark:bg-amber-950/30 dark:text-amber-300"
|
||||
}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<Activity className="h-3.5 w-3.5" />}
|
||||
label="총예수금(KIS)"
|
||||
value={summary ? `${formatCurrency(summary.totalDepositAmount)}원` : "-"}
|
||||
toneClass="border-brand-200/80 bg-brand-50/70 text-brand-700 dark:border-brand-700/60 dark:bg-brand-900/35 dark:text-brand-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasApiTotalAmount && isApiTotalAmountDifferent ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
내부 계산 총자산 {formatCurrency(summary?.totalAmount ?? 0)}원 · KIS 총자산{" "}
|
||||
{formatCurrency(summary?.apiReportedTotalAmount ?? 0)}원
|
||||
</p>
|
||||
) : null}
|
||||
{hasApiNetAssetAmount ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
KIS 집계 순자산 {formatCurrency(summary?.apiReportedNetAssetAmount ?? 0)}원
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewMetric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
toneClass,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
toneClass: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("rounded-xl border px-3 py-2", toneClass)}>
|
||||
<p className="flex items-center gap-1 text-[11px] font-medium opacity-85">
|
||||
{icon}
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs font-semibold">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 계좌번호를 마스킹해 표시합니다.
|
||||
* @param value 계좌번호(8-2)
|
||||
|
||||
@@ -57,7 +57,7 @@ export function StockDetailPreview({
|
||||
// [Step 1] 종목이 선택되지 않은 경우 초기 안내 화면 렌더링
|
||||
if (!holding) {
|
||||
return (
|
||||
<Card>
|
||||
<Card className="border-brand-200/70 bg-background/90 shadow-sm dark:border-brand-800/45">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<BarChartBig className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||
@@ -86,7 +86,7 @@ export function StockDetailPreview({
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="border-brand-200/70 bg-background/90 shadow-sm dark:border-brand-800/45">
|
||||
{/* ========== 카드 헤더: 종목명 및 기본 정보 ========== */}
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@@ -131,6 +131,10 @@ export function StockDetailPreview({
|
||||
label="보유 수량"
|
||||
value={`${holding.quantity.toLocaleString("ko-KR")}주`}
|
||||
/>
|
||||
<Metric
|
||||
label="매도가능 수량"
|
||||
value={`${holding.sellableQuantity.toLocaleString("ko-KR")}주`}
|
||||
/>
|
||||
<Metric
|
||||
label="매입 평균가"
|
||||
value={`${formatCurrency(holding.averagePrice)}원`}
|
||||
@@ -171,7 +175,7 @@ export function StockDetailPreview({
|
||||
</div>
|
||||
|
||||
{/* ========== 추가 기능 예고 영역 (Placeholder) ========== */}
|
||||
<div className="rounded-xl border border-dashed border-border/80 bg-muted/30 p-3">
|
||||
<div className="rounded-xl border border-dashed border-brand-300/60 bg-brand-50/40 p-3 dark:border-brand-700/50 dark:bg-brand-900/20">
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground/80">
|
||||
<MousePointerClick className="h-4 w-4 text-brand-500" />
|
||||
빠른 주문(준비 중)
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
fetchDashboardActivity,
|
||||
fetchDashboardBalance,
|
||||
fetchDashboardIndices,
|
||||
fetchDashboardMarketHub,
|
||||
} from "@/features/dashboard/apis/dashboard.api";
|
||||
import type {
|
||||
DashboardActivityResponse,
|
||||
DashboardBalanceResponse,
|
||||
DashboardIndicesResponse,
|
||||
DashboardMarketHubResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
interface UseDashboardDataResult {
|
||||
@@ -24,6 +26,8 @@ interface UseDashboardDataResult {
|
||||
activityError: string | null;
|
||||
balanceError: string | null;
|
||||
indicesError: string | null;
|
||||
marketHub: DashboardMarketHubResponse | null;
|
||||
marketHubError: string | null;
|
||||
lastUpdatedAt: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
@@ -50,6 +54,8 @@ export function useDashboardData(
|
||||
const [activityError, setActivityError] = useState<string | null>(null);
|
||||
const [balanceError, setBalanceError] = useState<string | null>(null);
|
||||
const [indicesError, setIndicesError] = useState<string | null>(null);
|
||||
const [marketHub, setMarketHub] = useState<DashboardMarketHubResponse | null>(null);
|
||||
const [marketHubError, setMarketHubError] = useState<string | null>(null);
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
|
||||
|
||||
const requestSeqRef = useRef(0);
|
||||
@@ -78,6 +84,7 @@ export function useDashboardData(
|
||||
Promise<DashboardBalanceResponse | null>,
|
||||
Promise<DashboardIndicesResponse>,
|
||||
Promise<DashboardActivityResponse | null>,
|
||||
Promise<DashboardMarketHubResponse>,
|
||||
] = [
|
||||
hasAccountNo
|
||||
? fetchDashboardBalance(credentials)
|
||||
@@ -86,9 +93,15 @@ export function useDashboardData(
|
||||
hasAccountNo
|
||||
? fetchDashboardActivity(credentials)
|
||||
: Promise.resolve(null),
|
||||
fetchDashboardMarketHub(credentials),
|
||||
];
|
||||
|
||||
const [balanceResult, indicesResult, activityResult] = await Promise.allSettled(tasks);
|
||||
const [
|
||||
balanceResult,
|
||||
indicesResult,
|
||||
activityResult,
|
||||
marketHubResult,
|
||||
] = await Promise.allSettled(tasks);
|
||||
if (requestSeq !== requestSeqRef.current) return;
|
||||
|
||||
let hasAnySuccess = false;
|
||||
@@ -136,6 +149,18 @@ export function useDashboardData(
|
||||
setIndicesError(indicesResult.reason instanceof Error ? indicesResult.reason.message : "시장 지수 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (marketHubResult.status === "fulfilled") {
|
||||
hasAnySuccess = true;
|
||||
setMarketHub(marketHubResult.value);
|
||||
setMarketHubError(null);
|
||||
} else {
|
||||
setMarketHubError(
|
||||
marketHubResult.reason instanceof Error
|
||||
? marketHubResult.reason.message
|
||||
: "시장 허브 조회에 실패했습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasAnySuccess) {
|
||||
setLastUpdatedAt(new Date().toISOString());
|
||||
}
|
||||
@@ -192,6 +217,8 @@ export function useDashboardData(
|
||||
activityError,
|
||||
balanceError,
|
||||
indicesError,
|
||||
marketHub,
|
||||
marketHubError,
|
||||
lastUpdatedAt,
|
||||
refresh,
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface DashboardHoldingItem {
|
||||
name: string;
|
||||
market: DashboardMarket;
|
||||
quantity: number;
|
||||
sellableQuantity: number;
|
||||
averagePrice: number;
|
||||
currentPrice: number;
|
||||
evaluationAmount: number;
|
||||
@@ -139,3 +140,56 @@ export interface DashboardActivityResponse {
|
||||
warnings: string[];
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 시장 허브(급등/인기/뉴스) 공통 종목 항목
|
||||
*/
|
||||
export interface DashboardMarketRankItem {
|
||||
rank: number;
|
||||
symbol: string;
|
||||
name: string;
|
||||
market: DashboardMarket;
|
||||
price: number;
|
||||
change: number;
|
||||
changeRate: number;
|
||||
volume: number;
|
||||
tradingValue: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 주요 뉴스 항목
|
||||
*/
|
||||
export interface DashboardNewsHeadlineItem {
|
||||
id: string;
|
||||
title: string;
|
||||
source: string;
|
||||
publishedAt: string;
|
||||
symbols: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 시장 허브 요약 지표
|
||||
*/
|
||||
export interface DashboardMarketPulse {
|
||||
gainersCount: number;
|
||||
losersCount: number;
|
||||
popularByVolumeCount: number;
|
||||
popularByValueCount: number;
|
||||
newsCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 시장 허브 API 응답 모델
|
||||
*/
|
||||
export interface DashboardMarketHubResponse {
|
||||
source: "kis";
|
||||
tradingEnv: KisTradingEnv;
|
||||
gainers: DashboardMarketRankItem[];
|
||||
losers: DashboardMarketRankItem[];
|
||||
popularByVolume: DashboardMarketRankItem[];
|
||||
popularByValue: DashboardMarketRankItem[];
|
||||
news: DashboardNewsHeadlineItem[];
|
||||
pulse: DashboardMarketPulse;
|
||||
warnings: string[];
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user