349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|