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