feat(ui): 우측 시세 정보 사이드바 추가 (시가/고가/저가/거래량/52주범위)
토스 종목 페이지 우측 카드와 동일 위치/구성. ChartPayload(1Y, 1d) 한 번 받아 오늘 OHLC + 1년 high/low 만 계산. 백엔드가 PER/EPS/시총 미제공이라 제외. lg 브레이크포인트에서 chart(1fr) + sidebar(280px) 그리드. mobile 에서는 사이드바가 chart 아래로 스택. verify: tsc --noEmit clean, next lint clean.
This commit is contained in:
@@ -8,6 +8,7 @@ import { PeriodTabs, periodSpec, type Period } from "../../components/PeriodTabs
|
||||
import { PredictionPanel } from "../../components/PredictionPanel";
|
||||
import { PriceHero } from "../../components/PriceHero";
|
||||
import { StockChart } from "../../components/StockChart";
|
||||
import { SymbolSidebar } from "../../components/SymbolSidebar";
|
||||
import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api";
|
||||
|
||||
export default function CodePage({ params }: { params: { code: string } }) {
|
||||
@@ -100,6 +101,8 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
||||
prev={prev}
|
||||
asOf={asOf}
|
||||
/>
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
|
||||
<div>
|
||||
<StockChart chart={chart} prediction={prediction} />
|
||||
{isIntraday && chart.intraday_status && (
|
||||
<div className="mt-2 text-right text-[11px] text-zinc-500">
|
||||
@@ -109,6 +112,9 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
||||
<div className="mt-6">
|
||||
<PredictionPanel code={code} initial={prediction} onResult={setPrediction} />
|
||||
</div>
|
||||
</div>
|
||||
<SymbolSidebar code={code} />
|
||||
</div>
|
||||
<div className="mt-6 grid gap-6 md:grid-cols-2">
|
||||
<MetricsPanel code={code} />
|
||||
<NewsList code={code} />
|
||||
|
||||
117
web/components/SymbolSidebar.tsx
Normal file
117
web/components/SymbolSidebar.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
// 토스증권 우측 정보 사이드바를 흉내낸 카드 묶음.
|
||||
//
|
||||
// 표시 항목 (백엔드가 안정적으로 제공하는 값으로 한정):
|
||||
// - 오늘: 시가 / 고가 / 저가 / 거래량
|
||||
// - 52주: 최고 / 최저 (1Y 일봉에서 계산)
|
||||
//
|
||||
// 백엔드에 PER/EPS/시총 데이터가 아직 없으므로 그 항목들은 의도적으로 제외.
|
||||
// 차트 폴링과 독립적으로 1Y 일봉을 한 번만 받아 52주 범위만 계산한다.
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, type ChartPayload } from "../lib/api";
|
||||
|
||||
export function SymbolSidebar({ code }: { code: string }) {
|
||||
const [chart, setChart] = useState<ChartPayload | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setErr(null);
|
||||
setChart(null);
|
||||
api
|
||||
.getChart(code, 365, "1d")
|
||||
.then((c) => {
|
||||
if (alive) setChart(c);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!chart) return null;
|
||||
const points = chart.ohlcv.filter((p) => p.close != null);
|
||||
if (!points.length) return null;
|
||||
const last = points[points.length - 1];
|
||||
|
||||
let yearHigh = -Infinity;
|
||||
let yearLow = Infinity;
|
||||
for (const p of points) {
|
||||
if (p.high != null && p.high > yearHigh) yearHigh = p.high;
|
||||
if (p.low != null && p.low < yearLow) yearLow = p.low;
|
||||
}
|
||||
return {
|
||||
open: last.open,
|
||||
high: last.high,
|
||||
low: last.low,
|
||||
volume: last.volume,
|
||||
yearHigh: yearHigh === -Infinity ? null : yearHigh,
|
||||
yearLow: yearLow === Infinity ? null : yearLow,
|
||||
};
|
||||
}, [chart]);
|
||||
|
||||
if (err) {
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-red-400">
|
||||
시세 정보 로딩 실패: {err}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
||||
시세 정보 로딩 중…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<div className="mb-3 text-sm font-medium text-zinc-200">시세 정보</div>
|
||||
<dl className="grid grid-cols-2 gap-y-2 text-sm">
|
||||
<Row label="시가" value={fmt(stats.open)} />
|
||||
<Row label="고가" value={fmt(stats.high)} tone="up" />
|
||||
<Row label="저가" value={fmt(stats.low)} tone="down" />
|
||||
<Row label="거래량" value={fmtCompact(stats.volume)} />
|
||||
<Row label="52주 최고" value={fmt(stats.yearHigh)} tone="up" />
|
||||
<Row label="52주 최저" value={fmt(stats.yearLow)} tone="down" />
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "up" | "down";
|
||||
}) {
|
||||
const toneCls =
|
||||
tone === "up" ? "text-rose-300" : tone === "down" ? "text-sky-300" : "text-zinc-100";
|
||||
return (
|
||||
<>
|
||||
<dt className="text-zinc-500">{label}</dt>
|
||||
<dd className={`text-right tabular-nums ${toneCls}`}>{value}</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(n: number | null | undefined): string {
|
||||
if (n == null || !Number.isFinite(n)) return "-";
|
||||
return n.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function fmtCompact(n: number | null | undefined): string {
|
||||
if (n == null || !Number.isFinite(n)) return "-";
|
||||
if (n >= 1e8) return `${(n / 1e8).toFixed(1)}억`;
|
||||
if (n >= 1e4) return `${(n / 1e4).toFixed(1)}만`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
Reference in New Issue
Block a user