Ver Fonte

feat(home): 주요 종목 현황 그리드 (Wave 2) — 그룹 탭 + TradingView 드릴다운

- MajorQuotesGrid: 그룹 탭(미국/아시아/…, 데이터 있는 그룹만) + 데이터 그리드
  (종목·현재가·전일비·등락률·거래량·시간·차트) + 등락률 정렬
- 차트 = TradingView 모달(미국주 .us 심볼 확정 매핑, 그 외 미표시) — TradingViewChart 재사용
- types/marketQuote.ts (MarketQuoteRow·그룹 라벨·심볼/거래량 포맷) + lib/api/marketQuotes.ts
- 홈 페이지 세계지도 아래 렌더(데이터 있을 때만). 전일 마감 기준
- tsc/eslint 그린

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KIM-JINO5 há 2 semanas atrás
pai
commit
a2b6574a7d

+ 154 - 0
app/(main)/_components/MajorQuotesGrid.tsx

@@ -0,0 +1,154 @@
+'use client';
+
+import './major-quotes.scss';
+import { useEffect, useMemo, useState } from 'react';
+import TradingViewChart from './TradingViewChart';
+import { moveDir, formatFlucRate, formatIndexClose } from '@/types/worldIndex';
+import { type MarketQuoteRow, groupLabel, tvSymbolForQuote, formatVolume, formatChangeVal } from '@/types/marketQuote';
+
+// 주요 종목 현황 — 그룹 탭(미국/아시아/…) + 데이터 그리드(종목·현재가·전일비·등락률·거래량·시간·차트).
+// 차트는 TradingView 드릴다운(미국주만 심볼 확정 매핑, 그 외는 미표시). 전일 마감 기준.
+type Props = {
+	rows: MarketQuoteRow[];
+};
+
+export default function MajorQuotesGrid({ rows }: Props)
+{
+	const [group, setGroup] = useState<string|null>(null);
+	const [chart, setChart] = useState<{ symbol: string; name: string }|null>(null);
+
+	// 차트 모달 Esc 닫기
+	useEffect(() => {
+		if (!chart) {
+			return;
+		}
+		const onKey = (e: KeyboardEvent) => {
+			if (e.key === 'Escape') {
+				setChart(null);
+			}
+		};
+		window.addEventListener('keydown', onKey);
+		return () => {
+			window.removeEventListener('keydown', onKey);
+		};
+	}, [chart]);
+
+	// 그룹 목록 — 등장 순서 유지(데이터 있는 그룹만)
+	const groups = useMemo(() => {
+		const seen: string[] = [];
+		for (const r of rows) {
+			if (!seen.includes(r.groupCode)) {
+				seen.push(r.groupCode);
+			}
+		}
+		return seen;
+	}, [rows]);
+
+	const activeGroup = group && groups.includes(group) ? group : (groups[0] ?? null);
+
+	const gridRows = useMemo(() => {
+		return rows.filter((r) => r.groupCode === activeGroup).sort((a, b) => b.flucRateBp - a.flucRateBp);
+	}, [rows, activeGroup]);
+
+	const asOf = useMemo(() => {
+		return rows.reduce<string|null>((max, c) => (max === null || c.tradeDate > max ? c.tradeDate : max), null);
+	}, [rows]);
+
+	if (groups.length === 0) {
+		return null;
+	}
+
+	return (
+		<section className='major-quotes' aria-label='주요 종목 현황'>
+			<div className='major-quotes__head'>
+				<h2 className='major-quotes__title'>주요 종목 현황</h2>
+				{asOf && <span className='major-quotes__asof'>전일 마감 기준 {asOf}</span>}
+			</div>
+
+			<div className='major-quotes__tabs' role='tablist' aria-label='그룹'>
+				{groups.map((g) => (
+					<button
+						key={g}
+						type='button'
+						role='tab'
+						aria-selected={g === activeGroup}
+						className={`major-quotes__tab${g === activeGroup ? ' is-active' : ''}`}
+						onClick={() => setGroup(g)}
+					>
+						{groupLabel(g)}
+					</button>
+				))}
+			</div>
+
+			<div className='major-quotes__grid-wrap'>
+				<table className='major-quotes__grid'>
+					<thead>
+						<tr>
+							<th scope='col' className='major-quotes__c-name'>종목</th>
+							<th scope='col' className='major-quotes__c-num'>현재가</th>
+							<th scope='col' className='major-quotes__c-num'>전일비</th>
+							<th scope='col' className='major-quotes__c-num'>등락률</th>
+							<th scope='col' className='major-quotes__c-num'>거래량</th>
+							<th scope='col' className='major-quotes__c-time'>시간</th>
+							<th scope='col' className='major-quotes__c-chart'>차트</th>
+						</tr>
+					</thead>
+					<tbody>
+						{gridRows.map((row) => {
+							const dir = moveDir(row.flucRateBp);
+							const tv = tvSymbolForQuote(row.symbol);
+							return (
+								<tr key={row.symbol} className='major-quotes__row'>
+									<td className='major-quotes__c-name'>{row.name}</td>
+									<td className='major-quotes__c-num major-quotes__c-close'>{formatIndexClose(row.close)}</td>
+									<td className={`major-quotes__c-num major-quotes__c-${dir}`}>{formatChangeVal(row.changeVal)}</td>
+									<td className={`major-quotes__c-num major-quotes__c-${dir}`}>{formatFlucRate(row.flucRateBp)}</td>
+									<td className='major-quotes__c-num major-quotes__c-vol'>{formatVolume(row.volume)}</td>
+									<td className='major-quotes__c-time'>{row.tradeDate}</td>
+									<td className='major-quotes__c-chart'>
+										{tv ? (
+											<button
+												type='button'
+												className='major-quotes__chart-btn'
+												title={`${row.name} 차트 보기`}
+												aria-label={`${row.name} 차트 보기`}
+												onClick={() => setChart({ symbol: tv, name: row.name })}
+											>
+												<svg viewBox='0 0 24 24' width='16' height='16' aria-hidden='true'>
+													<polyline points='3,17 9,11 13,15 21,6' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round' />
+												</svg>
+											</button>
+										) : (
+											<span className='major-quotes__chart-na' aria-hidden='true'>–</span>
+										)}
+									</td>
+								</tr>
+							);
+						})}
+					</tbody>
+				</table>
+			</div>
+
+			{chart && (
+				<div
+					className='major-quotes__modal'
+					role='dialog'
+					aria-modal='true'
+					aria-label={`${chart.name} 차트`}
+					onClick={() => setChart(null)}
+				>
+					<div className='major-quotes__modal-panel' onClick={(e) => e.stopPropagation()}>
+						<div className='major-quotes__modal-head'>
+							<span className='major-quotes__modal-title'>{chart.name}</span>
+							<button type='button' className='major-quotes__modal-close' aria-label='닫기' onClick={() => setChart(null)}>×</button>
+						</div>
+						<div className='major-quotes__modal-body'>
+							<TradingViewChart symbol={chart.symbol} />
+						</div>
+						<p className='major-quotes__modal-note'>실시간 차트 제공 · TradingView</p>
+					</div>
+				</div>
+			)}
+		</section>
+	);
+}

+ 232 - 0
app/(main)/_components/major-quotes.scss

@@ -0,0 +1,232 @@
+// ─────────────────────────────────────────────────────────────
+// 주요 종목 현황 (MajorQuotesGrid) — 그룹 탭 + 데이터 그리드 + TradingView 드릴다운 모달
+// 등락색 --price-up/down/flat(상승 적/하락 청). 기존 토큰만 사용(라이트·다크 양립).
+// ─────────────────────────────────────────────────────────────
+
+.major-quotes {
+	margin-bottom: var(--sp-6);
+	padding: var(--sp-4);
+	background: var(--bg-elevated);
+	border: 1px solid var(--border-default);
+	border-radius: var(--radius);
+
+	&__head {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		flex-wrap: wrap;
+		gap: var(--sp-3);
+		margin-bottom: var(--sp-3);
+	}
+
+	&__title {
+		font-size: var(--fs-lg);
+		font-weight: 700;
+		color: var(--text-primary);
+	}
+
+	&__asof {
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+		font-variant-numeric: tabular-nums;
+	}
+
+	&__tabs {
+		display: flex;
+		flex-wrap: wrap;
+		gap: var(--sp-2);
+		border-bottom: 1px solid var(--border-default);
+		margin-bottom: var(--sp-2);
+	}
+
+	&__tab {
+		padding: var(--sp-3) var(--sp-4);
+		background: transparent;
+		border: none;
+		border-bottom: 2px solid transparent;
+		margin-bottom: -1px;
+		font-size: var(--fs-sm);
+		font-weight: 600;
+		color: var(--text-muted);
+		cursor: pointer;
+		transition: color 0.12s ease, border-color 0.12s ease;
+
+		&:hover {
+			color: var(--text-secondary);
+		}
+
+		&.is-active {
+			color: var(--text-primary);
+			border-bottom-color: var(--text-link);
+		}
+	}
+
+	&__grid-wrap {
+		overflow-x: auto;
+	}
+
+	&__grid {
+		width: 100%;
+		border-collapse: collapse;
+		font-size: var(--fs-sm);
+		font-variant-numeric: tabular-nums;
+
+		th {
+			padding: var(--sp-3);
+			text-align: right;
+			font-size: var(--fs-xs);
+			font-weight: 600;
+			color: var(--text-muted);
+			border-bottom: 1px solid var(--border-default);
+			white-space: nowrap;
+		}
+
+		td {
+			padding: var(--sp-3);
+			border-bottom: 1px solid var(--border-light);
+			white-space: nowrap;
+		}
+	}
+
+	&__row {
+		transition: background 0.1s ease;
+
+		&:hover {
+			background: var(--bg-subtle-hover);
+		}
+	}
+
+	&__c-name {
+		text-align: left;
+		font-weight: 600;
+		color: var(--text-primary);
+	}
+
+	&__c-num {
+		text-align: right;
+	}
+
+	&__c-time {
+		text-align: right;
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+	}
+
+	&__c-chart {
+		text-align: center;
+		width: 1%;
+	}
+
+	&__c-close {
+		color: var(--text-primary);
+		font-weight: 600;
+	}
+
+	&__c-vol {
+		color: var(--text-secondary);
+	}
+
+	&__c-up {
+		color: var(--price-up);
+	}
+
+	&__c-down {
+		color: var(--price-down);
+	}
+
+	&__c-flat {
+		color: var(--price-flat);
+	}
+
+	&__chart-btn {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		padding: var(--sp-2);
+		background: transparent;
+		border: 1px solid var(--border-default);
+		border-radius: var(--radius);
+		color: var(--text-muted);
+		cursor: pointer;
+		transition: color 0.12s ease, border-color 0.12s ease;
+
+		&:hover {
+			color: var(--text-link);
+			border-color: var(--text-link);
+		}
+	}
+
+	&__chart-na {
+		color: var(--text-muted);
+	}
+
+	// 차트 모달 (TradingView)
+	&__modal {
+		position: fixed;
+		inset: 0;
+		z-index: 1000;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		padding: var(--sp-5);
+		background: rgba(0, 0, 0, 0.55);
+	}
+
+	&__modal-panel {
+		display: flex;
+		flex-direction: column;
+		width: min(960px, 96vw);
+		height: min(620px, 88vh);
+		background: var(--bg-page);
+		border: 1px solid var(--border-strong);
+		border-radius: var(--radius);
+		overflow: hidden;
+	}
+
+	&__modal-head {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		gap: var(--sp-3);
+		padding: var(--sp-3) var(--sp-4);
+		border-bottom: 1px solid var(--border-default);
+	}
+
+	&__modal-title {
+		font-size: var(--fs-base);
+		font-weight: 700;
+		color: var(--text-primary);
+	}
+
+	&__modal-close {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		width: 28px;
+		height: 28px;
+		background: transparent;
+		border: none;
+		border-radius: var(--radius);
+		font-size: var(--fs-2xl);
+		line-height: 1;
+		color: var(--text-muted);
+		cursor: pointer;
+
+		&:hover {
+			color: var(--text-primary);
+			background: var(--bg-subtle-hover);
+		}
+	}
+
+	&__modal-body {
+		flex: 1;
+		min-height: 0;
+	}
+
+	&__modal-note {
+		padding: var(--sp-2) var(--sp-4);
+		font-size: var(--fs-2xs);
+		color: var(--text-muted);
+		text-align: right;
+	}
+}

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

@@ -2,9 +2,12 @@ import { fetchJson } from '@/lib/utils/server';
 import type { BannerActiveListResponse, BannerItem } from '@/types/response/banner/active-list';
 import type { LiveChannelListResponse } from '@/types/response/channel/live-list';
 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 HomeBannerCarousel from './_components/HomeBannerCarousel';
 import WorldMarketMap from './_components/WorldMarketMap';
+import MajorQuotesGrid from './_components/MajorQuotesGrid';
 import LiveChannelGrid from './_components/LiveChannelGrid';
 import LiveChannelEmpty from './_components/LiveChannelEmpty';
 import { FEATURE_CHANNEL } from '@/constants/features';
@@ -47,12 +50,24 @@ async function loadWorldIndices(): Promise<WorldIndexRow[]> {
 	return res.data.list;
 }
 
+async function loadMarketQuotes(): Promise<MarketQuoteRow[]> {
+	// 백엔드 미기동/데이터 없음이어도 홈은 렌더 — 그리드는 데이터 있을 때만 표시
+	const res = await fetchMarketQuotes();
+
+	if (!res.success || !res.data) {
+		return [];
+	}
+
+	return res.data.list;
+}
+
 export default async function Home() {
 	// 채널 기능 OFF: 라이브 채널 섹션 미노출이므로 조회 생략
-	const [banners, live, worldIndices] = await Promise.all([
+	const [banners, live, worldIndices, marketQuotes] = await Promise.all([
 		loadActiveBanners(),
 		FEATURE_CHANNEL ? loadLiveChannels() : Promise.resolve({ channels: [], total: 0 }),
-		loadWorldIndices()
+		loadWorldIndices(),
+		loadMarketQuotes()
 	]);
 
 	return (
@@ -60,6 +75,8 @@ export default async function Home() {
 			<div className="home__inner">
 				{worldIndices.length > 0 && <WorldMarketMap rows={worldIndices} />}
 
+				{marketQuotes.length > 0 && <MajorQuotesGrid rows={marketQuotes} />}
+
 				<HomeBannerCarousel items={banners} />
 
 				{FEATURE_CHANNEL && (

+ 18 - 0
lib/api/marketQuotes.ts

@@ -0,0 +1,18 @@
+'use server';
+
+import { ResultDto } from '@/types/response/common';
+import { MarketQuotesResponse } from '@/types/marketQuote';
+import { fetchJson } from '@/lib/utils/server';
+
+const JSON_HEADERS = { 'Accept': 'application/json' } as const;
+
+// 주요 종목·상품 시세 — 그룹 필터(선택). 미지정 시 전체. 주요종목현황 그리드 (익명 열람)
+export async function fetchMarketQuotes(group?: string): Promise<ResultDto<MarketQuotesResponse>>
+{
+	const qs = group ? `?group=${encodeURIComponent(group)}` : '';
+
+	return await fetchJson<MarketQuotesResponse>(`/api/market/quotes${qs}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}

+ 61 - 0
types/marketQuote.ts

@@ -0,0 +1,61 @@
+// 주요 종목·상품 시세 도메인 타입 — Backend Application/Features/Api/Stocks/GetMarketQuotes/Response.cs 대응
+// 직렬화 주의: 필드명 camelCase. category 는 숫자값(enum), flucRateBp = %×100.
+
+// ── GET /api/market/quotes ──
+export interface MarketQuoteRow {
+	symbol: string;
+	name: string;
+	category: number;   // 0 Stock,1 Commodity,2 Futures,3 Fx,4 BondYield
+	groupCode: string;  // us-major | asia-major | commodity ...
+	countryCode: string;
+	close: number;
+	changeVal: number;
+	flucRateBp: number; // %×100
+	volume: number;
+	tradeDate: string;  // yyyy-MM-dd
+}
+
+export interface MarketQuotesResponse {
+	list: MarketQuoteRow[];
+}
+
+// 그룹 코드 → 표시 라벨 (탭)
+export const GROUP_LABELS: Record<string, string> = {
+	'us-major': '미국 주요',
+	'asia-major': '아시아 주요',
+	'europe-major': '유럽 주요',
+	'commodity': '상품·선물'
+};
+
+export function groupLabel(code: string): string {
+	return GROUP_LABELS[code] ?? code;
+}
+
+// Stooq 심볼 → TradingView 심볼(드릴다운). 미국주(.us)만 확정 매핑 — 그 외는 null(차트 버튼 미표시).
+export function tvSymbolForQuote(symbol: string): string|null {
+	const s = symbol.trim().toLowerCase();
+	if (s.endsWith('.us')) {
+		return s.slice(0, -3).toUpperCase();
+	}
+	return null;
+}
+
+// 거래량 축약 — 0 이하는 "-", 백만/천 단위 축약
+export function formatVolume(volume: number): string {
+	if (volume <= 0) {
+		return '-';
+	}
+	if (volume >= 1_000_000) {
+		return `${(volume / 1_000_000).toFixed(1)}M`;
+	}
+	if (volume >= 1_000) {
+		return `${Math.round(volume / 1_000)}K`;
+	}
+	return volume.toLocaleString('ko-KR');
+}
+
+// 전일비 — "+1.23" / "-0.45" (부호 + 천단위 콤마 + 소수 2자리)
+export function formatChangeVal(changeVal: number): string {
+	const sign = changeVal > 0 ? '+' : '';
+	return `${sign}${changeVal.toLocaleString('ko-KR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
+}