대시보드 중간 커밋

This commit is contained in:
2026-02-10 11:16:39 +09:00
parent 851a2acd69
commit 22aad3fb0e
51 changed files with 6594 additions and 1287 deletions

View File

@@ -0,0 +1,144 @@
import { Activity, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { StockLineChart } from "@/features/dashboard/components/chart/StockLineChart";
import { StockPriceBadge } from "@/features/dashboard/components/details/StockPriceBadge";
import type {
DashboardStockItem,
DashboardPriceSource,
DashboardMarketPhase,
} from "@/features/dashboard/types/dashboard.types";
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
function formatVolume(value: number) {
return `${PRICE_FORMATTER.format(value)}`;
}
function getPriceSourceLabel(
source: DashboardPriceSource,
marketPhase: DashboardMarketPhase,
) {
switch (source) {
case "inquire-overtime-price":
return "시간외 현재가(inquire-overtime-price)";
case "inquire-ccnl":
return marketPhase === "afterHours"
? "체결가 폴백(inquire-ccnl)"
: "체결가(inquire-ccnl)";
default:
return "현재가(inquire-price)";
}
}
function PriceStat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-border/70 bg-background/70 p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-sm font-semibold text-foreground">{value}</p>
</div>
);
}
interface StockOverviewCardProps {
stock: DashboardStockItem;
priceSource: DashboardPriceSource;
marketPhase: DashboardMarketPhase;
isRealtimeConnected: boolean;
realtimeTrId: string | null;
lastRealtimeTickAt: number | null;
}
export function StockOverviewCard({
stock,
priceSource,
marketPhase,
isRealtimeConnected,
realtimeTrId,
lastRealtimeTickAt,
}: StockOverviewCardProps) {
const apiPriceSourceLabel = getPriceSourceLabel(priceSource, marketPhase);
const effectivePriceSourceLabel =
isRealtimeConnected && lastRealtimeTickAt
? `실시간 체결(WebSocket ${realtimeTrId || ""})`
: apiPriceSourceLabel;
return (
<Card className="overflow-hidden border-brand-200">
<CardHeader className="border-b border-border/50 bg-muted/30 pb-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-xl font-bold">{stock.name}</CardTitle>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{stock.symbol}
</span>
<span className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700">
{stock.market}
</span>
</div>
<CardDescription className="mt-1 flex items-center gap-1.5">
<span>{effectivePriceSourceLabel}</span>
{isRealtimeConnected && (
<span className="inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-xs font-medium text-green-700">
<Activity className="h-3 w-3" />
</span>
)}
</CardDescription>
</div>
<div className="flex flex-col items-end gap-1">
<StockPriceBadge
currentPrice={stock.currentPrice}
change={stock.change}
changeRate={stock.changeRate}
/>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="grid border-b border-border/50 lg:grid-cols-3">
<div className="col-span-2 border-r border-border/50">
{/* Chart Area */}
<div className="p-6">
<StockLineChart candles={stock.candles} />
</div>
</div>
<div className="col-span-1 bg-muted/10 p-6">
<div className="mb-4 flex items-center gap-2 text-sm font-semibold text-foreground/80">
<ShieldCheck className="h-4 w-4 text-brand-600" />
</div>
<div className="grid grid-cols-2 gap-3">
<PriceStat
label="시가"
value={`${PRICE_FORMATTER.format(stock.open)}`}
/>
<PriceStat
label="고가"
value={`${PRICE_FORMATTER.format(stock.high)}`}
/>
<PriceStat
label="저가"
value={`${PRICE_FORMATTER.format(stock.low)}`}
/>
<PriceStat
label="전일종가"
value={`${PRICE_FORMATTER.format(stock.prevClose)}`}
/>
<div className="col-span-2">
<PriceStat label="거래량" value={formatVolume(stock.volume)} />
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,48 @@
import { TrendingDown, TrendingUp } from "lucide-react";
import { cn } from "@/lib/utils";
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
function formatPrice(value: number) {
return `${PRICE_FORMATTER.format(value)}`;
}
interface StockPriceBadgeProps {
currentPrice: number;
change: number;
changeRate: number;
}
export function StockPriceBadge({
currentPrice,
change,
changeRate,
}: StockPriceBadgeProps) {
const isPositive = change >= 0;
const ChangeIcon = isPositive ? TrendingUp : TrendingDown;
const changeColor = isPositive ? "text-red-500" : "text-blue-500";
const changeSign = isPositive ? "+" : "";
return (
<div className="flex items-baseline gap-2">
<span className={cn("text-3xl font-bold", changeColor)}>
{formatPrice(currentPrice)}
</span>
<div
className={cn(
"flex items-center gap-1 text-sm font-medium",
changeColor,
)}
>
<ChangeIcon className="h-4 w-4" />
<span>
{changeSign}
{PRICE_FORMATTER.format(change)}
</span>
<span>
({changeSign}
{changeRate.toFixed(2)}%)
</span>
</div>
</div>
);
}