193 lines
6.8 KiB
TypeScript
193 lines
6.8 KiB
TypeScript
import { BarChart3, TrendingDown, TrendingUp } from "lucide-react";
|
|
import { RefreshCcw } from "lucide-react";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import type { DashboardMarketIndexItem } from "@/features/dashboard/types/dashboard.types";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
formatCurrency,
|
|
formatSignedCurrency,
|
|
formatSignedPercent,
|
|
} from "@/features/dashboard/utils/dashboard-format";
|
|
import { cn } from "@/lib/utils";
|
|
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
|
|
|
|
interface MarketSummaryProps {
|
|
items: DashboardMarketIndexItem[];
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
warning?: string | null;
|
|
isRealtimePending?: boolean;
|
|
onRetry?: () => void;
|
|
}
|
|
|
|
/**
|
|
* @description 코스피/코스닥 지수 요약 카드입니다.
|
|
* @see features/dashboard/components/DashboardContainer.tsx 우측 상단 영역에서 호출합니다.
|
|
*/
|
|
export function MarketSummary({
|
|
items,
|
|
isLoading,
|
|
error,
|
|
warning = null,
|
|
isRealtimePending = false,
|
|
onRetry,
|
|
}: MarketSummaryProps) {
|
|
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">
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="flex items-center gap-2 text-base text-brand-700 dark:text-brand-300">
|
|
<BarChart3 className="h-4 w-4" />
|
|
시장 지수
|
|
</CardTitle>
|
|
</div>
|
|
<CardDescription>실시간 코스피/코스닥 지수 현황입니다.</CardDescription>
|
|
</CardHeader>
|
|
|
|
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl: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">
|
|
지수 데이터를 불러오는 중입니다...
|
|
</div>
|
|
)}
|
|
|
|
{/* ========== REALTIME PENDING STATE ========== */}
|
|
{isRealtimePending && items.length === 0 && !isLoading && !error && (
|
|
<div className="col-span-full rounded-md bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-950/25 dark:text-amber-400">
|
|
실시간 시세 연결은 완료되었고 첫 지수 데이터를 기다리는 중입니다.
|
|
</div>
|
|
)}
|
|
|
|
{/* ========== ERROR/WARNING STATE ========== */}
|
|
{error && (
|
|
<div className="col-span-full rounded-md bg-red-50 p-3 text-sm text-red-600 dark:bg-red-950/30 dark:text-red-400">
|
|
<p>지수 정보를 가져오는데 실패했습니다.</p>
|
|
<p className="mt-1 text-xs opacity-80">
|
|
{toCompactErrorMessage(error)}
|
|
</p>
|
|
<p className="mt-1 text-xs opacity-80">
|
|
토큰이 정상이어도 한국투자증권 API 점검/지연 시 일시적으로 실패할 수 있습니다.
|
|
</p>
|
|
{onRetry ? (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={onRetry}
|
|
className="mt-2 border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/40"
|
|
>
|
|
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
|
|
지수 다시 불러오기
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
{!error && warning && (
|
|
<div className="col-span-full rounded-md bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-950/25 dark:text-amber-400">
|
|
{warning}
|
|
</div>
|
|
)}
|
|
|
|
{/* ========== INDEX CARDS ========== */}
|
|
{items.map((item) => (
|
|
<IndexItem key={item.code} item={item} />
|
|
))}
|
|
|
|
{!isLoading && items.length === 0 && !error && (
|
|
<div className="col-span-full py-4 text-center text-sm text-muted-foreground">
|
|
표시할 데이터가 없습니다.
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @description 길고 복잡한 서버 오류를 대시보드 카드에 맞는 짧은 문구로 축약합니다.
|
|
* @param error 원본 오류 문자열
|
|
* @returns 화면 노출용 오류 메시지
|
|
* @see features/dashboard/components/MarketSummary.tsx 지수 오류 배너 상세 문구
|
|
*/
|
|
function toCompactErrorMessage(error: string) {
|
|
const normalized = error.replaceAll(/\s+/g, " ").trim();
|
|
if (!normalized) return "잠시 후 다시 시도해 주세요.";
|
|
if (normalized.length <= 120) return normalized;
|
|
return `${normalized.slice(0, 120)}...`;
|
|
}
|
|
|
|
function IndexItem({ item }: { item: DashboardMarketIndexItem }) {
|
|
const isUp = item.change > 0;
|
|
const isDown = item.change < 0;
|
|
const toneClass = isUp
|
|
? "text-red-600 dark:text-red-400"
|
|
: isDown
|
|
? "text-blue-600 dark:text-blue-400"
|
|
: "text-muted-foreground";
|
|
|
|
const bgClass = isUp
|
|
? "bg-red-50/50 dark:bg-red-950/10 border-red-100 dark:border-red-900/30"
|
|
: isDown
|
|
? "bg-blue-50/50 dark:bg-blue-950/10 border-blue-100 dark:border-blue-900/30"
|
|
: "bg-muted/50 border-border/50";
|
|
|
|
const flash = usePriceFlash(item.price, item.code);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative flex flex-col justify-between rounded-xl border p-4 transition-all hover:bg-background/80",
|
|
bgClass,
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-muted-foreground">
|
|
{item.market}
|
|
</span>
|
|
{isUp ? (
|
|
<TrendingUp className="h-4 w-4 text-red-500/70" />
|
|
) : isDown ? (
|
|
<TrendingDown className="h-4 w-4 text-blue-500/70" />
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="mt-2 text-2xl font-bold tracking-tight relative w-fit">
|
|
{formatCurrency(item.price)}
|
|
|
|
{/* Flash Indicator */}
|
|
{flash && (
|
|
<div
|
|
key={flash.id} // Force re-render for animation restart using state ID
|
|
className={cn(
|
|
"absolute left-full top-1 ml-2 text-sm font-bold animate-out fade-out slide-out-to-top-2 duration-1000 fill-mode-forwards pointer-events-none whitespace-nowrap",
|
|
flash.type === "up" ? "text-red-500" : "text-blue-500",
|
|
)}
|
|
>
|
|
{flash.type === "up" ? "+" : ""}
|
|
{flash.val.toFixed(2)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className={cn(
|
|
"mt-1 flex items-center gap-2 text-sm font-medium",
|
|
toneClass,
|
|
)}
|
|
>
|
|
<span>{formatSignedCurrency(item.change)}</span>
|
|
<span className="rounded-md bg-background/50 px-1.5 py-0.5 text-xs shadow-sm">
|
|
{formatSignedPercent(item.changeRate)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|