page.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import './style.scss';
  2. import '@/app/styles/stock-common.scss';
  3. import { cache } from 'react';
  4. import { Metadata } from 'next';
  5. import Link from 'next/link';
  6. import { notFound } from 'next/navigation';
  7. import { fetchStockDetail, fetchStockHistory } from '@/lib/api/stock';
  8. import { fetchStockDisclosures } from '@/lib/api/market';
  9. import { fetchStockDividends, fetchStockLending, fetchStockCbExercises } from '@/lib/api/seibro';
  10. import { fetchStockBoardPosts } from '@/lib/api/community';
  11. import { changeDirection, changeSymbol, formatChangeRate, formatKrwCompact, formatNumber, tradingStatusLabel } from '@/lib/utils/stock';
  12. import Pager from '../_components/Pager';
  13. import DisclosureList from '../../market/_components/DisclosureList';
  14. import StockDiscussion from './_components/StockDiscussion';
  15. import StockDividends from './_components/StockDividends';
  16. import StockLending from './_components/StockLending';
  17. import StockCbExercises from './_components/StockCbExercises';
  18. import StockPriceChart from './_components/StockPriceChart';
  19. import StockDetailHeader from './_components/StockDetailHeader';
  20. const TABS = [
  21. { value: 'price', label: '시세' },
  22. { value: 'dividends', label: '배당' },
  23. { value: 'lending', label: '대차' },
  24. { value: 'cb', label: '전환권' },
  25. { value: 'news', label: '뉴스' },
  26. { value: 'disclosure', label: '공시' },
  27. { value: 'discussion', label: '토론' }
  28. ];
  29. const HISTORY_PER_PAGE = 30;
  30. const DISCUSSION_PER_PAGE = 20;
  31. const DIVIDENDS_PER_PAGE = 30;
  32. const LENDING_PER_PAGE = 90;
  33. const CB_PER_PAGE = 30;
  34. type Props = {
  35. params: Promise<{
  36. code: string;
  37. }>;
  38. searchParams: Promise<{
  39. tab?: string;
  40. page?: string;
  41. }>;
  42. };
  43. // generateMetadata + 본문 이중 호출 방지 (요청 단위 dedupe)
  44. const getDetail = cache(async (code: string) => {
  45. return await fetchStockDetail(code);
  46. });
  47. export async function generateMetadata({ params }: Props): Promise<Metadata>
  48. {
  49. const { code } = await params;
  50. const res = await getDetail(code);
  51. if (!res.success || !res.data) {
  52. return {
  53. title: '종목 상세 — 개미투자'
  54. };
  55. }
  56. return {
  57. title: `${res.data.name}(${res.data.code}) 주가·시세 — 개미투자`,
  58. description: `${res.data.name}(${res.data.code}) 전일 종가·등락률·거래량·일별 시세 — 전일 종가 기준(T+1)`
  59. };
  60. }
  61. export default async function StockDetailPage({ params, searchParams }: Props)
  62. {
  63. const { code } = await params;
  64. const query = await searchParams;
  65. const tab = TABS.some(c => c.value === query.tab) ? (query.tab as string) : 'price';
  66. const page = Math.max(Number(query.page) || 1, 1);
  67. const res = await getDetail(code);
  68. // 없는 코드(형식 불일치 포함)는 404
  69. if (!res.success && res.status === 404) {
  70. return notFound();
  71. }
  72. // 백엔드 미기동 등 그 외 실패 — 에러 UI 로 우아하게 처리
  73. if (!res.success || !res.data) {
  74. return (
  75. <div id='stock-detail'>
  76. <div className='stock-detail__error' role='alert'>
  77. <strong>종목 정보를 불러오지 못했습니다.</strong>
  78. 잠시 후 다시 시도해 주세요.
  79. </div>
  80. </div>
  81. );
  82. }
  83. const detail = res.data;
  84. const latest = detail.recentPrices.length > 0 ? detail.recentPrices[0] : null;
  85. const direction = changeDirection(detail.changeRate);
  86. const statusLabel = tradingStatusLabel(detail.tradingStatus);
  87. // 시세 탭만 히스토리 조회 (request-time)
  88. const history = tab === 'price' ? await fetchStockHistory(code, { page, perPage: HISTORY_PER_PAGE }) : null;
  89. // 공시 탭 — 종목별 DART 공시 조회 (request-time)
  90. const disclosures = tab === 'disclosure' ? await fetchStockDisclosures(code, { page }) : null;
  91. // 토론 탭 — 종목 가상보드 글 목록 (request-time)
  92. const discussion = tab === 'discussion' ? await fetchStockBoardPosts(code, { page, perPage: DISCUSSION_PER_PAGE }) : null;
  93. // SEIBro 종목 탭 — 배당 이력 / 대차 시계열 / CB·BW 행사 (request-time)
  94. const dividends = tab === 'dividends' ? await fetchStockDividends(code, { page, perPage: DIVIDENDS_PER_PAGE }) : null;
  95. const lending = tab === 'lending' ? await fetchStockLending(code, { page, perPage: LENDING_PER_PAGE }) : null;
  96. const cbExercises = tab === 'cb' ? await fetchStockCbExercises(code, { page, perPage: CB_PER_PAGE }) : null;
  97. return (
  98. <div id='stock-detail'>
  99. <StockDetailHeader key={detail.code} code={detail.code} name={detail.name} />
  100. {/* 상단 요약 — T+1 데이터임을 명시 */}
  101. <header className='stock-detail__summary'>
  102. <div className='stock-detail__title'>
  103. <h1>{detail.name}</h1>
  104. <span className='stock-detail__code'>{detail.code}</span>
  105. <span className={`stock-badge stock-badge--${detail.market.toLowerCase()}`}>{detail.market}</span>
  106. {statusLabel && (
  107. <span className={`stock-badge stock-badge--${detail.tradingStatus.toLowerCase()}`}>{statusLabel}</span>
  108. )}
  109. {detail.sectorName && <span className='stock-detail__sector'>{detail.sectorName}</span>}
  110. </div>
  111. <div className='stock-detail__price'>
  112. <strong className={`stock-detail__close stock-change stock-change--${direction}`}>
  113. {formatNumber(detail.closePrice)}
  114. </strong>
  115. <span className={`stock-detail__diff stock-change stock-change--${direction}`}>
  116. {changeSymbol(direction)} {latest ? formatNumber(Math.abs(latest.changeAmount)) : '—'} ({formatChangeRate(detail.changeRate)})
  117. </span>
  118. </div>
  119. <dl className='stock-detail__meta'>
  120. <div>
  121. <dt>거래량</dt>
  122. <dd>{formatNumber(latest?.volume)}</dd>
  123. </div>
  124. <div>
  125. <dt>거래대금</dt>
  126. <dd>{formatKrwCompact(latest?.tradingValue)}</dd>
  127. </div>
  128. <div>
  129. <dt>시가총액</dt>
  130. <dd>{formatKrwCompact(detail.marketCap)}</dd>
  131. </div>
  132. </dl>
  133. <p className='stock-detail__basis'>
  134. 전일 기준 · {detail.lastTradingDate ?? '데이터 준비 중'} 종가 (T+1 반영)
  135. </p>
  136. </header>
  137. {/* 시세 그래프 — stock-detail__basis 바로 하단, 최근 종가 추이 (recentPrices) */}
  138. <StockPriceChart prices={detail.recentPrices} name={detail.name} />
  139. {/* 탭 */}
  140. <nav className='stock-detail__tabs' aria-label='종목 상세 탭'>
  141. {TABS.map(item => (
  142. <Link
  143. key={item.value}
  144. href={item.value === 'price' ? `/stock/${detail.code}` : `/stock/${detail.code}?tab=${item.value}`}
  145. {...(tab === item.value ? { 'aria-current': 'page' as const } : {})}
  146. >
  147. {item.label}
  148. </Link>
  149. ))}
  150. </nav>
  151. <section className='stock-detail__panel'>
  152. {tab === 'disclosure' ? (
  153. !disclosures || !disclosures.success || !disclosures.data ? (
  154. <div className='stock-detail__error' role='alert'>
  155. <strong>공시를 불러오지 못했습니다.</strong>
  156. 잠시 후 다시 시도해 주세요.
  157. </div>
  158. ) : (
  159. <>
  160. <DisclosureList rows={disclosures.data.list} />
  161. <Pager
  162. total={disclosures.data.total}
  163. page={page}
  164. perPage={20}
  165. basePath={`/stock/${detail.code}`}
  166. query={{ tab: 'disclosure' }}
  167. />
  168. </>
  169. )
  170. ) : tab === 'discussion' ? (
  171. !discussion || !discussion.success || !discussion.data ? (
  172. <div className='stock-detail__error' role='alert'>
  173. <strong>토론을 불러오지 못했습니다.</strong>
  174. 잠시 후 다시 시도해 주세요.
  175. </div>
  176. ) : (
  177. <StockDiscussion
  178. code={detail.code}
  179. stockName={detail.name}
  180. page={page}
  181. perPage={DISCUSSION_PER_PAGE}
  182. data={discussion.data}
  183. />
  184. )
  185. ) : tab === 'dividends' ? (
  186. !dividends || !dividends.success || !dividends.data ? (
  187. <div className='stock-detail__error' role='alert'>
  188. <strong>배당 이력을 불러오지 못했습니다.</strong>
  189. 잠시 후 다시 시도해 주세요.
  190. </div>
  191. ) : (
  192. <>
  193. <StockDividends rows={dividends.data.list} />
  194. <Pager
  195. total={dividends.data.total}
  196. page={page}
  197. perPage={DIVIDENDS_PER_PAGE}
  198. basePath={`/stock/${detail.code}`}
  199. query={{ tab: 'dividends' }}
  200. />
  201. </>
  202. )
  203. ) : tab === 'lending' ? (
  204. !lending || !lending.success || !lending.data ? (
  205. <div className='stock-detail__error' role='alert'>
  206. <strong>대차 시계열을 불러오지 못했습니다.</strong>
  207. 잠시 후 다시 시도해 주세요.
  208. </div>
  209. ) : (
  210. <>
  211. <StockLending rows={lending.data.list} />
  212. <Pager
  213. total={lending.data.total}
  214. page={page}
  215. perPage={LENDING_PER_PAGE}
  216. basePath={`/stock/${detail.code}`}
  217. query={{ tab: 'lending' }}
  218. />
  219. </>
  220. )
  221. ) : tab === 'cb' ? (
  222. !cbExercises || !cbExercises.success || !cbExercises.data ? (
  223. <div className='stock-detail__error' role='alert'>
  224. <strong>전환권 행사 내역을 불러오지 못했습니다.</strong>
  225. 잠시 후 다시 시도해 주세요.
  226. </div>
  227. ) : (
  228. <>
  229. <StockCbExercises rows={cbExercises.data.list} />
  230. <Pager
  231. total={cbExercises.data.total}
  232. page={page}
  233. perPage={CB_PER_PAGE}
  234. basePath={`/stock/${detail.code}`}
  235. query={{ tab: 'cb' }}
  236. />
  237. </>
  238. )
  239. ) : tab !== 'price' ? (
  240. <p className='stock-detail__placeholder'>준비 중입니다.</p>
  241. ) : !history || !history.success || !history.data ? (
  242. <div className='stock-detail__error' role='alert'>
  243. <strong>일별 시세를 불러오지 못했습니다.</strong>
  244. 잠시 후 다시 시도해 주세요.
  245. </div>
  246. ) : history.data.list.length === 0 ? (
  247. <p className='stock-detail__empty'>수집된 일별 시세가 없습니다.</p>
  248. ) : (
  249. <>
  250. <table className='stock-detail__table'>
  251. <caption>{detail.name} 일별 시세 — 날짜, 종가, 등락, 시가, 고가, 저가, 거래량, 거래대금</caption>
  252. <thead>
  253. <tr>
  254. <th scope='col'>날짜</th>
  255. <th scope='col'>종가</th>
  256. <th scope='col'>등락률</th>
  257. <th scope='col' className='stock-detail__col--sub'>시가</th>
  258. <th scope='col' className='stock-detail__col--sub'>고가</th>
  259. <th scope='col' className='stock-detail__col--sub'>저가</th>
  260. <th scope='col'>거래량</th>
  261. <th scope='col' className='stock-detail__col--sub'>거래대금</th>
  262. </tr>
  263. </thead>
  264. <tbody>
  265. {history.data.list.map(row => {
  266. const rowDirection = changeDirection(row.changeRate);
  267. return (
  268. <tr key={row.tradingDate}>
  269. <td>{row.tradingDate}</td>
  270. <td>{formatNumber(row.close)}</td>
  271. <td className={`stock-change stock-change--${rowDirection}`}>
  272. {changeSymbol(rowDirection)} {formatChangeRate(row.changeRate)}
  273. </td>
  274. <td className='stock-detail__col--sub'>{formatNumber(row.open)}</td>
  275. <td className='stock-detail__col--sub'>{formatNumber(row.high)}</td>
  276. <td className='stock-detail__col--sub'>{formatNumber(row.low)}</td>
  277. <td>{formatNumber(row.volume)}</td>
  278. <td className='stock-detail__col--sub'>{formatKrwCompact(row.tradingValue)}</td>
  279. </tr>
  280. );
  281. })}
  282. </tbody>
  283. </table>
  284. <Pager
  285. total={history.data.total}
  286. page={page}
  287. perPage={HISTORY_PER_PAGE}
  288. basePath={`/stock/${detail.code}`}
  289. query={{}}
  290. />
  291. </>
  292. )}
  293. </section>
  294. </div>
  295. );
  296. }