| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154 |
- '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>
- );
- }
|