PaperTradeForm.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. 'use client';
  2. import { useCallback, useEffect, useRef, useState } from 'react';
  3. import '@/app/styles/header-search.scss';
  4. import '@/app/styles/stock-common.scss';
  5. import { fetchApi, getDateTime } from '@/lib/utils/client';
  6. import { formatTokenNumber } from '@/lib/utils/paper';
  7. import {
  8. PaperOrderSide,
  9. PAPER_FILL_RULE_LABEL,
  10. PAPER_SIDE_LABEL
  11. } from '@/types/paper';
  12. import type {
  13. PaperOrderSideValue,
  14. PaperAccountResponse,
  15. PaperPlaceOrderResponse,
  16. PaperFillRuleValue
  17. } from '@/types/paper';
  18. import type { StockSuggestItem, StockSuggestResponse } from '@/types/stock';
  19. const DEBOUNCE_MS = 300;
  20. const LISTBOX_ID = 'paper-trade-suggest-listbox';
  21. // 모의투자 주문 폼 — 종목 검색(suggest) + 매수/매도 + 수량 → POST /api/paper/orders
  22. export default function PaperTradeForm()
  23. {
  24. const debounceRef = useRef<ReturnType<typeof setTimeout>|null>(null);
  25. const requestSeq = useRef(0);
  26. const [account, setAccount] = useState<PaperAccountResponse|null>(null);
  27. const [keyword, setKeyword] = useState('');
  28. const [suggests, setSuggests] = useState<StockSuggestItem[]>([]);
  29. const [open, setOpen] = useState(false);
  30. const [selected, setSelected] = useState<StockSuggestItem|null>(null);
  31. const [side, setSide] = useState<PaperOrderSideValue>(PaperOrderSide.Buy);
  32. const [quantity, setQuantity] = useState('');
  33. const [busy, setBusy] = useState(false);
  34. const [error, setError] = useState<string|null>(null);
  35. const [result, setResult] = useState<PaperPlaceOrderResponse|null>(null);
  36. const loadAccount = useCallback(async () => {
  37. const res = await fetchApi<PaperAccountResponse>('/api/paper/account', { silent: true });
  38. setAccount(res.success ? (res.data ?? null) : null);
  39. }, []);
  40. useEffect(() => {
  41. loadAccount();
  42. }, [loadAccount]);
  43. useEffect(() => {
  44. return () => {
  45. if (debounceRef.current) {
  46. clearTimeout(debounceRef.current);
  47. }
  48. };
  49. }, []);
  50. const requestSuggest = useCallback(async (q: string) => {
  51. const seq = ++requestSeq.current;
  52. const res = await fetchApi<StockSuggestResponse>(`/api/stocks/suggest?q=${encodeURIComponent(q)}`, { silent: true });
  53. if (seq !== requestSeq.current) {
  54. return;
  55. }
  56. setSuggests(res.success ? (res.data?.list ?? []) : []);
  57. setOpen(true);
  58. }, []);
  59. const handleKeywordChange = useCallback((value: string) => {
  60. setKeyword(value);
  61. setSelected(null);
  62. if (debounceRef.current) {
  63. clearTimeout(debounceRef.current);
  64. }
  65. const trimmed = value.trim();
  66. if (!trimmed) {
  67. requestSeq.current++;
  68. setSuggests([]);
  69. setOpen(false);
  70. return;
  71. }
  72. debounceRef.current = setTimeout(() => {
  73. requestSuggest(trimmed);
  74. }, DEBOUNCE_MS);
  75. }, [requestSuggest]);
  76. const pickStock = useCallback((item: StockSuggestItem) => {
  77. setSelected(item);
  78. setKeyword(`${item.name} (${item.code})`);
  79. setSuggests([]);
  80. setOpen(false);
  81. }, []);
  82. const submit = useCallback(async () => {
  83. setError(null);
  84. setResult(null);
  85. if (!selected) {
  86. setError('종목을 선택해 주세요.');
  87. return;
  88. }
  89. const qty = Number(quantity);
  90. if (!Number.isInteger(qty) || qty <= 0) {
  91. setError('1주 이상의 수량을 입력해 주세요.');
  92. return;
  93. }
  94. setBusy(true);
  95. const res = await fetchApi<PaperPlaceOrderResponse>('/api/paper/orders', {
  96. method: 'POST',
  97. body: { stockCode: selected.code, side, quantity: qty },
  98. silent: true
  99. });
  100. setBusy(false);
  101. if (res.success && res.data) {
  102. setResult(res.data);
  103. setQuantity('');
  104. loadAccount();
  105. }
  106. else {
  107. setError(res.message || '주문 접수에 실패했습니다.');
  108. }
  109. }, [selected, quantity, side, loadAccount]);
  110. const fillRuleLabel = (rule: PaperFillRuleValue) => PAPER_FILL_RULE_LABEL[rule] ?? '—';
  111. return (
  112. <div className='paper__account-card'>
  113. <div className='paper__metric'>
  114. <span className='paper__metric-label'>가용 토큰</span>
  115. <span className='paper__metric-value'>{account ? formatTokenNumber(account.token) : '—'}</span>
  116. </div>
  117. <div className='paper-modal__body paper__trade-body'>
  118. {/* 종목 검색 */}
  119. <div className='paper-modal__field paper__field--suggest'>
  120. <span className='paper-modal__label'>종목</span>
  121. <input
  122. className='paper-modal__input'
  123. type='search'
  124. value={keyword}
  125. onChange={e => handleKeywordChange(e.target.value)}
  126. onFocus={() => { if (suggests.length > 0) setOpen(true); }}
  127. placeholder='종목명 또는 코드'
  128. autoComplete='off'
  129. role='combobox'
  130. aria-expanded={open}
  131. aria-controls={LISTBOX_ID}
  132. aria-autocomplete='list'
  133. />
  134. {open && suggests.length > 0 && (
  135. <ul id={LISTBOX_ID} className='header-suggest' role='listbox' aria-label='종목 자동완성'>
  136. {suggests.map(item => (
  137. <li
  138. key={item.code}
  139. role='option'
  140. aria-selected={selected?.code === item.code}
  141. className='header-suggest__item'
  142. onMouseDown={e => { e.preventDefault(); pickStock(item); }}
  143. >
  144. <span className='header-suggest__name'>{item.name}</span>
  145. <span className='header-suggest__code'>{item.code}</span>
  146. <span className={`stock-badge stock-badge--${item.market.toLowerCase()}`}>{item.market}</span>
  147. </li>
  148. ))}
  149. </ul>
  150. )}
  151. </div>
  152. {/* 매수/매도 */}
  153. <div className='paper-modal__field'>
  154. <span className='paper-modal__label'>주문 구분</span>
  155. <div className='paper__tabs'>
  156. <button
  157. type='button'
  158. className={`paper__tab${side === PaperOrderSide.Buy ? ' paper__tab--active' : ''}`}
  159. onClick={() => setSide(PaperOrderSide.Buy)}
  160. >
  161. {PAPER_SIDE_LABEL[PaperOrderSide.Buy]}
  162. </button>
  163. <button
  164. type='button'
  165. className={`paper__tab${side === PaperOrderSide.Sell ? ' paper__tab--active' : ''}`}
  166. onClick={() => setSide(PaperOrderSide.Sell)}
  167. >
  168. {PAPER_SIDE_LABEL[PaperOrderSide.Sell]}
  169. </button>
  170. </div>
  171. </div>
  172. {/* 수량 */}
  173. <div className='paper-modal__field'>
  174. <span className='paper-modal__label'>수량 (주)</span>
  175. <input
  176. className='paper-modal__input'
  177. type='number'
  178. min={1}
  179. step={1}
  180. inputMode='numeric'
  181. value={quantity}
  182. onChange={e => setQuantity(e.target.value)}
  183. placeholder='주문 수량'
  184. />
  185. </div>
  186. {error && <p className='paper-modal__error' role='alert'>{error}</p>}
  187. {result && (
  188. <div className='paper__notice' role='status'>
  189. <div className='paper__notice-title'>주문이 접수되었습니다</div>
  190. <ul className='paper__notice-list'>
  191. <li>주문번호: {result.orderID}</li>
  192. <li>구분: {PAPER_SIDE_LABEL[result.side]} · 체결 기준: {fillRuleLabel(result.fillRule)}</li>
  193. <li>수량: {result.quantity.toLocaleString()}주</li>
  194. <li>예약 금액: {formatTokenNumber(result.reservedAmount)}</li>
  195. <li>체결 기준일(TargetDate): {result.targetDate}</li>
  196. <li>취소 가능 시한: {getDateTime(result.cancelableUntil)}</li>
  197. </ul>
  198. </div>
  199. )}
  200. <div className='paper-modal__footer'>
  201. <button type='button' className='paper__btn paper__btn--primary' onClick={submit} disabled={busy}>
  202. {busy ? '접수 중...' : '주문 접수'}
  203. </button>
  204. </div>
  205. </div>
  206. </div>
  207. );
  208. }