전체적인 리팩토링

This commit is contained in:
2026-03-12 09:26:27 +09:00
parent 406af7408a
commit e51d767878
97 changed files with 13651 additions and 363 deletions

View File

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

View File

@@ -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 실시간 지수 맵

View File

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

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

View File

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

View File

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

View File

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