144 lines
4.9 KiB
TypeScript
144 lines
4.9 KiB
TypeScript
|
|
import { Activity, ShieldCheck } from "lucide-react";
|
||
|
|
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>
|
||
|
|
);
|
||
|
|
}
|