|
|
@@ -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>
|
|
|
+ );
|
|
|
+}
|