Просмотр исходного кода

feat(home): 국내 증시 요약 섹션 (코스피/코스닥/KOSPI200) — Phase 1

- 세계지도 다음에 카드 3개(첨부 이미지 레이아웃): 지수값·전일비·등락률 + 투자자별 순매수(Phase2 "–")
  + 등락종목수(코스피/코스닥) / 베이시스(KOSPI200)
- DomesticSummary 컴포넌트 + domestic-summary.scss + types/api + page 배선
- tsc/eslint 그린

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KIM-JINO5 2 недель назад
Родитель
Сommit
8c44b03b0c

+ 76 - 0
app/(main)/_components/DomesticSummary.tsx

@@ -0,0 +1,76 @@
+import './domestic-summary.scss';
+import { moveDir, formatFlucRate, formatIndexClose } from '@/types/worldIndex';
+import { type DomesticIndexRow, formatIndexChange, formatNet, formatCount, netDir } from '@/types/domesticSummary';
+
+// 국내 증시 요약 — 코스피/코스닥/KOSPI200 카드. 지수·등락 + 투자자별 순매수(Phase 2 "–") + 등락종목수(코스피/코스닥) / 베이시스(KOSPI200).
+type Props = {
+	rows: DomesticIndexRow[];
+};
+
+const ARROW = { up: '▲', down: '▼', flat: '–' } as const;
+
+export default function DomesticSummary({ rows }: Props)
+{
+	if (rows.length === 0) {
+		return null;
+	}
+
+	return (
+		<section className='dsum' aria-label='국내 증시 요약'>
+			<div className='dsum__grid'>
+				{rows.map((row) => {
+					const dir = moveDir(row.flucRateBp);
+					const isKospi200 = row.key === 'kospi200';
+					return (
+						<div key={row.key} className='dsum__card'>
+							<div className='dsum__name'>{row.name}</div>
+
+							<div className='dsum__price'>
+								<span className={`dsum__value dsum__value--${dir}`}>{formatIndexClose(row.close)}</span>
+								<span className={`dsum__chg dsum__chg--${dir}`}>
+									<i className='dsum__arrow' aria-hidden='true'>{ARROW[dir]}</i>
+									{formatIndexChange(row.changeVal)}
+									<em className='dsum__rate'>{formatFlucRate(row.flucRateBp)}</em>
+								</span>
+							</div>
+
+							<div className='dsum__investors'>
+								<div className='dsum__inv'>
+									<span className='dsum__inv-k'>개인</span>
+									<span className={`dsum__inv-v dsum__inv-v--${netDir(row.individualNet)}`}>{formatNet(row.individualNet)}</span>
+								</div>
+								<div className='dsum__inv'>
+									<span className='dsum__inv-k'>외국인</span>
+									<span className={`dsum__inv-v dsum__inv-v--${netDir(row.foreignNet)}`}>{formatNet(row.foreignNet)}</span>
+								</div>
+								<div className='dsum__inv'>
+									<span className='dsum__inv-k'>기관</span>
+									<span className={`dsum__inv-v dsum__inv-v--${netDir(row.institutionNet)}`}>{formatNet(row.institutionNet)}</span>
+								</div>
+								<div className='dsum__inv'>
+									<span className='dsum__inv-k'>프로그램</span>
+									<span className={`dsum__inv-v dsum__inv-v--${netDir(row.programNet)}`}>{formatNet(row.programNet)}</span>
+								</div>
+							</div>
+
+							{isKospi200 ? (
+								<div className='dsum__foot'>
+									<span className='dsum__basis-k'>베이시스</span>
+									<span className='dsum__basis-v'>{row.basis !== null && row.basis !== undefined ? row.basis.toFixed(2) : '–'}</span>
+								</div>
+							) : (
+								<div className='dsum__breadth'>
+									<span className='dsum__b'>상한 <em className='dsum__b--up'>{formatCount(row.limitUp)}</em></span>
+									<span className='dsum__b'>상승 <em className='dsum__b--up'>{formatCount(row.advances)}</em></span>
+									<span className='dsum__b'>보합 <em>{formatCount(row.unchanged)}</em></span>
+									<span className='dsum__b'>하락 <em className='dsum__b--down'>{formatCount(row.declines)}</em></span>
+									<span className='dsum__b'>하한 <em className='dsum__b--down'>{formatCount(row.limitDown)}</em></span>
+								</div>
+							)}
+						</div>
+					);
+				})}
+			</div>
+		</section>
+	);
+}

+ 170 - 0
app/(main)/_components/domestic-summary.scss

@@ -0,0 +1,170 @@
+// ─────────────────────────────────────────────────────────────
+// 국내 증시 요약 (DomesticSummary) — 코스피/코스닥/KOSPI200 카드 3개
+// 등락색 --price-up/down(상승 적/하락 청). 기존 토큰만 사용(라이트·다크 양립).
+// ─────────────────────────────────────────────────────────────
+
+.dsum {
+	margin-bottom: var(--sp-6);
+
+	&__grid {
+		display: grid;
+		grid-template-columns: repeat(3, minmax(0, 1fr));
+		gap: var(--sp-4);
+	}
+
+	@media (max-width: 900px) {
+		&__grid {
+			grid-template-columns: 1fr;
+		}
+	}
+
+	&__card {
+		padding: var(--sp-5);
+		background: var(--bg-elevated);
+		border: 1px solid var(--border-default);
+		border-radius: var(--radius);
+	}
+
+	&__name {
+		font-size: var(--fs-lg);
+		font-weight: 700;
+		color: var(--text-primary);
+		margin-bottom: var(--sp-3);
+	}
+
+	&__price {
+		display: flex;
+		align-items: baseline;
+		flex-wrap: wrap;
+		gap: var(--sp-3);
+		padding-bottom: var(--sp-4);
+		border-bottom: 1px solid var(--border-light);
+	}
+
+	&__value {
+		font-size: var(--fs-2xl);
+		font-weight: 700;
+		font-variant-numeric: tabular-nums;
+		color: var(--text-primary);
+
+		&--up {
+			color: var(--price-up);
+		}
+
+		&--down {
+			color: var(--price-down);
+		}
+	}
+
+	&__chg {
+		display: inline-flex;
+		align-items: baseline;
+		gap: var(--sp-2);
+		font-size: var(--fs-sm);
+		font-weight: 600;
+		font-variant-numeric: tabular-nums;
+
+		&--up {
+			color: var(--price-up);
+		}
+
+		&--down {
+			color: var(--price-down);
+		}
+
+		&--flat {
+			color: var(--price-flat);
+		}
+	}
+
+	&__arrow {
+		font-style: normal;
+		font-size: var(--fs-xs);
+	}
+
+	&__rate {
+		font-style: normal;
+	}
+
+	// 투자자별 순매수 (2×2)
+	&__investors {
+		display: grid;
+		grid-template-columns: 1fr 1fr;
+		gap: var(--sp-2) var(--sp-5);
+		padding: var(--sp-4) 0;
+		border-bottom: 1px solid var(--border-light);
+	}
+
+	&__inv {
+		display: flex;
+		align-items: baseline;
+		justify-content: space-between;
+		gap: var(--sp-3);
+		font-size: var(--fs-sm);
+	}
+
+	&__inv-k {
+		color: var(--text-muted);
+	}
+
+	&__inv-v {
+		font-variant-numeric: tabular-nums;
+		color: var(--text-secondary);
+
+		&--up {
+			color: var(--price-up);
+		}
+
+		&--down {
+			color: var(--price-down);
+		}
+	}
+
+	// 등락 종목 수
+	&__breadth {
+		display: flex;
+		flex-wrap: wrap;
+		gap: var(--sp-3);
+		padding-top: var(--sp-4);
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+		font-variant-numeric: tabular-nums;
+
+		em {
+			font-style: normal;
+			font-weight: 700;
+			color: var(--text-secondary);
+		}
+
+		.dsum__b--up {
+			color: var(--price-up);
+		}
+
+		.dsum__b--down {
+			color: var(--price-down);
+		}
+	}
+
+	&__b {
+		white-space: nowrap;
+	}
+
+	// 베이시스 (KOSPI200)
+	&__foot {
+		display: flex;
+		align-items: baseline;
+		gap: var(--sp-3);
+		padding-top: var(--sp-4);
+		font-size: var(--fs-sm);
+	}
+
+	&__basis-k {
+		color: var(--text-muted);
+	}
+
+	&__basis-v {
+		font-weight: 700;
+		font-variant-numeric: tabular-nums;
+		color: var(--text-primary);
+	}
+}

+ 18 - 2
app/(main)/page.tsx

@@ -5,8 +5,11 @@ import type { WorldIndexRow } from '@/types/worldIndex';
 import type { MarketQuoteRow } from '@/types/marketQuote';
 import { fetchWorldIndices } from '@/lib/api/worldIndices';
 import { fetchMarketQuotes } from '@/lib/api/marketQuotes';
+import type { DomesticIndexRow } from '@/types/domesticSummary';
+import { fetchDomesticSummary } from '@/lib/api/domesticSummary';
 import HomeBannerCarousel from './_components/HomeBannerCarousel';
 import WorldMarketMap from './_components/WorldMarketMap';
+import DomesticSummary from './_components/DomesticSummary';
 import MajorQuotesGrid from './_components/MajorQuotesGrid';
 import LiveChannelGrid from './_components/LiveChannelGrid';
 import LiveChannelEmpty from './_components/LiveChannelEmpty';
@@ -61,13 +64,24 @@ async function loadMarketQuotes(): Promise<MarketQuoteRow[]> {
 	return res.data.list;
 }
 
+async function loadDomesticSummary(): Promise<DomesticIndexRow[]> {
+	const res = await fetchDomesticSummary();
+
+	if (!res.success || !res.data) {
+		return [];
+	}
+
+	return res.data.list;
+}
+
 export default async function Home() {
 	// 채널 기능 OFF: 라이브 채널 섹션 미노출이므로 조회 생략
-	const [banners, live, worldIndices, marketQuotes] = await Promise.all([
+	const [banners, live, worldIndices, marketQuotes, domesticSummary] = await Promise.all([
 		loadActiveBanners(),
 		FEATURE_CHANNEL ? loadLiveChannels() : Promise.resolve({ channels: [], total: 0 }),
 		loadWorldIndices(),
-		loadMarketQuotes()
+		loadMarketQuotes(),
+		loadDomesticSummary()
 	]);
 
 	return (
@@ -75,6 +89,8 @@ export default async function Home() {
 			<div className="home__inner">
 				{worldIndices.length > 0 && <WorldMarketMap rows={worldIndices} />}
 
+				{domesticSummary.length > 0 && <DomesticSummary rows={domesticSummary} />}
+
 				{marketQuotes.length > 0 && <MajorQuotesGrid rows={marketQuotes} />}
 
 				<HomeBannerCarousel items={banners} />

+ 16 - 0
lib/api/domesticSummary.ts

@@ -0,0 +1,16 @@
+'use server';
+
+import { ResultDto } from '@/types/response/common';
+import { DomesticSummaryResponse } from '@/types/domesticSummary';
+import { fetchJson } from '@/lib/utils/server';
+
+const JSON_HEADERS = { 'Accept': 'application/json' } as const;
+
+// 국내 증시 요약 — 코스피/코스닥/KOSPI200 지수 + 등락종목수 (메인, 익명 열람)
+export async function fetchDomesticSummary(): Promise<ResultDto<DomesticSummaryResponse>>
+{
+	return await fetchJson<DomesticSummaryResponse>('/api/market/domestic-summary', {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}

+ 60 - 0
types/domesticSummary.ts

@@ -0,0 +1,60 @@
+// 국내 증시 요약 도메인 타입 — Backend Application/Features/Api/Stocks/GetDomesticSummary/Response.cs 대응
+// 필드명 camelCase. flucRateBp = %×100. 투자자별·베이시스는 Phase 2(현재 null → "–").
+
+export interface DomesticIndexRow {
+	key: string;          // kospi | kosdaq | kospi200
+	name: string;
+	close: number;
+	changeVal: number;
+	flucRateBp: number;
+	tradeDate: string;
+
+	// 등락 종목 수 (코스피/코스닥만; KOSPI200 은 null)
+	advances?: number|null;
+	declines?: number|null;
+	unchanged?: number|null;
+	limitUp?: number|null;
+	limitDown?: number|null;
+
+	// 투자자별 순매수(억원) — Phase 2
+	individualNet?: number|null;
+	foreignNet?: number|null;
+	institutionNet?: number|null;
+	programNet?: number|null;
+
+	// 베이시스 (KOSPI200) — Phase 2
+	basis?: number|null;
+}
+
+export interface DomesticSummaryResponse {
+	list: DomesticIndexRow[];
+}
+
+// 전일비 절대값 — "45.12" (부호/색은 방향으로 별도)
+export function formatIndexChange(v: number): string {
+	return Math.abs(v).toLocaleString('ko-KR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+}
+
+// 투자자별 순매수 — "-13,277억원" / "1,347억원", null 은 "–"
+export function formatNet(v?: number|null): string {
+	if (v === null || v === undefined) {
+		return '–';
+	}
+	return `${v.toLocaleString('ko-KR')}억원`;
+}
+
+// 종목 수 — null 은 "–"
+export function formatCount(v?: number|null): string {
+	if (v === null || v === undefined) {
+		return '–';
+	}
+	return v.toLocaleString('ko-KR');
+}
+
+// 순매수 방향 (색상용)
+export function netDir(v?: number|null): 'up'|'down'|'flat' {
+	if (v === null || v === undefined || v === 0) {
+		return 'flat';
+	}
+	return v > 0 ? 'up' : 'down';
+}