MajorQuotesGrid.tsx 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. 'use client';
  2. import './major-quotes.scss';
  3. import { useEffect, useMemo, useState } from 'react';
  4. import TradingViewChart from './TradingViewChart';
  5. import { moveDir, formatFlucRate, formatIndexClose } from '@/types/worldIndex';
  6. import { type MarketQuoteRow, groupLabel, tvSymbolForQuote, formatVolume, formatChangeVal } from '@/types/marketQuote';
  7. // 주요 종목 현황 — 그룹 탭(미국/아시아/…) + 데이터 그리드(종목·현재가·전일비·등락률·거래량·시간·차트).
  8. // 차트는 TradingView 드릴다운(미국주만 심볼 확정 매핑, 그 외는 미표시). 전일 마감 기준.
  9. type Props = {
  10. rows: MarketQuoteRow[];
  11. };
  12. export default function MajorQuotesGrid({ rows }: Props)
  13. {
  14. const [group, setGroup] = useState<string|null>(null);
  15. const [chart, setChart] = useState<{ symbol: string; name: string }|null>(null);
  16. // 차트 모달 Esc 닫기
  17. useEffect(() => {
  18. if (!chart) {
  19. return;
  20. }
  21. const onKey = (e: KeyboardEvent) => {
  22. if (e.key === 'Escape') {
  23. setChart(null);
  24. }
  25. };
  26. window.addEventListener('keydown', onKey);
  27. return () => {
  28. window.removeEventListener('keydown', onKey);
  29. };
  30. }, [chart]);
  31. // 그룹 목록 — 등장 순서 유지(데이터 있는 그룹만)
  32. const groups = useMemo(() => {
  33. const seen: string[] = [];
  34. for (const r of rows) {
  35. if (!seen.includes(r.groupCode)) {
  36. seen.push(r.groupCode);
  37. }
  38. }
  39. return seen;
  40. }, [rows]);
  41. const activeGroup = group && groups.includes(group) ? group : (groups[0] ?? null);
  42. const gridRows = useMemo(() => {
  43. return rows.filter((r) => r.groupCode === activeGroup).sort((a, b) => b.flucRateBp - a.flucRateBp);
  44. }, [rows, activeGroup]);
  45. const asOf = useMemo(() => {
  46. return rows.reduce<string|null>((max, c) => (max === null || c.tradeDate > max ? c.tradeDate : max), null);
  47. }, [rows]);
  48. if (groups.length === 0) {
  49. return null;
  50. }
  51. return (
  52. <section className='major-quotes' aria-label='주요 종목 현황'>
  53. <div className='major-quotes__head'>
  54. <h2 className='major-quotes__title'>주요 종목 현황</h2>
  55. {asOf && <span className='major-quotes__asof'>전일 마감 기준 {asOf}</span>}
  56. </div>
  57. <div className='major-quotes__tabs' role='tablist' aria-label='그룹'>
  58. {groups.map((g) => (
  59. <button
  60. key={g}
  61. type='button'
  62. role='tab'
  63. aria-selected={g === activeGroup}
  64. className={`major-quotes__tab${g === activeGroup ? ' is-active' : ''}`}
  65. onClick={() => setGroup(g)}
  66. >
  67. {groupLabel(g)}
  68. </button>
  69. ))}
  70. </div>
  71. <div className='major-quotes__grid-wrap'>
  72. <table className='major-quotes__grid'>
  73. <thead>
  74. <tr>
  75. <th scope='col' className='major-quotes__c-name'>종목</th>
  76. <th scope='col' className='major-quotes__c-num'>현재가</th>
  77. <th scope='col' className='major-quotes__c-num'>전일비</th>
  78. <th scope='col' className='major-quotes__c-num'>등락률</th>
  79. <th scope='col' className='major-quotes__c-num'>거래량</th>
  80. <th scope='col' className='major-quotes__c-time'>시간</th>
  81. <th scope='col' className='major-quotes__c-chart'>차트</th>
  82. </tr>
  83. </thead>
  84. <tbody>
  85. {gridRows.map((row) => {
  86. const dir = moveDir(row.flucRateBp);
  87. const tv = tvSymbolForQuote(row.symbol);
  88. return (
  89. <tr key={row.symbol} className='major-quotes__row'>
  90. <td className='major-quotes__c-name'>{row.name}</td>
  91. <td className='major-quotes__c-num major-quotes__c-close'>{formatIndexClose(row.close)}</td>
  92. <td className={`major-quotes__c-num major-quotes__c-${dir}`}>{formatChangeVal(row.changeVal)}</td>
  93. <td className={`major-quotes__c-num major-quotes__c-${dir}`}>{formatFlucRate(row.flucRateBp)}</td>
  94. <td className='major-quotes__c-num major-quotes__c-vol'>{formatVolume(row.volume)}</td>
  95. <td className='major-quotes__c-time'>{row.tradeDate}</td>
  96. <td className='major-quotes__c-chart'>
  97. {tv ? (
  98. <button
  99. type='button'
  100. className='major-quotes__chart-btn'
  101. title={`${row.name} 차트 보기`}
  102. aria-label={`${row.name} 차트 보기`}
  103. onClick={() => setChart({ symbol: tv, name: row.name })}
  104. >
  105. <svg viewBox='0 0 24 24' width='16' height='16' aria-hidden='true'>
  106. <polyline points='3,17 9,11 13,15 21,6' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round' />
  107. </svg>
  108. </button>
  109. ) : (
  110. <span className='major-quotes__chart-na' aria-hidden='true'>–</span>
  111. )}
  112. </td>
  113. </tr>
  114. );
  115. })}
  116. </tbody>
  117. </table>
  118. </div>
  119. {chart && (
  120. <div
  121. className='major-quotes__modal'
  122. role='dialog'
  123. aria-modal='true'
  124. aria-label={`${chart.name} 차트`}
  125. onClick={() => setChart(null)}
  126. >
  127. <div className='major-quotes__modal-panel' onClick={(e) => e.stopPropagation()}>
  128. <div className='major-quotes__modal-head'>
  129. <span className='major-quotes__modal-title'>{chart.name}</span>
  130. <button type='button' className='major-quotes__modal-close' aria-label='닫기' onClick={() => setChart(null)}>×</button>
  131. </div>
  132. <div className='major-quotes__modal-body'>
  133. <TradingViewChart symbol={chart.symbol} />
  134. </div>
  135. <p className='major-quotes__modal-note'>실시간 차트 제공 · TradingView</p>
  136. </div>
  137. </div>
  138. )}
  139. </section>
  140. );
  141. }