| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- 'use client';
- import { useCallback, useEffect, useRef, useState } from 'react';
- import '@/app/styles/header-search.scss';
- import '@/app/styles/stock-common.scss';
- import { fetchApi, getDateTime } from '@/lib/utils/client';
- import { formatTokenNumber } from '@/lib/utils/paper';
- import {
- PaperOrderSide,
- PAPER_FILL_RULE_LABEL,
- PAPER_SIDE_LABEL
- } from '@/types/paper';
- import type {
- PaperOrderSideValue,
- PaperAccountResponse,
- PaperPlaceOrderResponse,
- PaperFillRuleValue
- } from '@/types/paper';
- import type { StockSuggestItem, StockSuggestResponse } from '@/types/stock';
- const DEBOUNCE_MS = 300;
- const LISTBOX_ID = 'paper-trade-suggest-listbox';
- // 모의투자 주문 폼 — 종목 검색(suggest) + 매수/매도 + 수량 → POST /api/paper/orders
- export default function PaperTradeForm()
- {
- const debounceRef = useRef<ReturnType<typeof setTimeout>|null>(null);
- const requestSeq = useRef(0);
- const [account, setAccount] = useState<PaperAccountResponse|null>(null);
- const [keyword, setKeyword] = useState('');
- const [suggests, setSuggests] = useState<StockSuggestItem[]>([]);
- const [open, setOpen] = useState(false);
- const [selected, setSelected] = useState<StockSuggestItem|null>(null);
- const [side, setSide] = useState<PaperOrderSideValue>(PaperOrderSide.Buy);
- const [quantity, setQuantity] = useState('');
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState<string|null>(null);
- const [result, setResult] = useState<PaperPlaceOrderResponse|null>(null);
- const loadAccount = useCallback(async () => {
- const res = await fetchApi<PaperAccountResponse>('/api/paper/account', { silent: true });
- setAccount(res.success ? (res.data ?? null) : null);
- }, []);
- useEffect(() => {
- loadAccount();
- }, [loadAccount]);
- useEffect(() => {
- return () => {
- if (debounceRef.current) {
- clearTimeout(debounceRef.current);
- }
- };
- }, []);
- const requestSuggest = useCallback(async (q: string) => {
- const seq = ++requestSeq.current;
- const res = await fetchApi<StockSuggestResponse>(`/api/stocks/suggest?q=${encodeURIComponent(q)}`, { silent: true });
- if (seq !== requestSeq.current) {
- return;
- }
- setSuggests(res.success ? (res.data?.list ?? []) : []);
- setOpen(true);
- }, []);
- const handleKeywordChange = useCallback((value: string) => {
- setKeyword(value);
- setSelected(null);
- if (debounceRef.current) {
- clearTimeout(debounceRef.current);
- }
- const trimmed = value.trim();
- if (!trimmed) {
- requestSeq.current++;
- setSuggests([]);
- setOpen(false);
- return;
- }
- debounceRef.current = setTimeout(() => {
- requestSuggest(trimmed);
- }, DEBOUNCE_MS);
- }, [requestSuggest]);
- const pickStock = useCallback((item: StockSuggestItem) => {
- setSelected(item);
- setKeyword(`${item.name} (${item.code})`);
- setSuggests([]);
- setOpen(false);
- }, []);
- const submit = useCallback(async () => {
- setError(null);
- setResult(null);
- if (!selected) {
- setError('종목을 선택해 주세요.');
- return;
- }
- const qty = Number(quantity);
- if (!Number.isInteger(qty) || qty <= 0) {
- setError('1주 이상의 수량을 입력해 주세요.');
- return;
- }
- setBusy(true);
- const res = await fetchApi<PaperPlaceOrderResponse>('/api/paper/orders', {
- method: 'POST',
- body: { stockCode: selected.code, side, quantity: qty },
- silent: true
- });
- setBusy(false);
- if (res.success && res.data) {
- setResult(res.data);
- setQuantity('');
- loadAccount();
- }
- else {
- setError(res.message || '주문 접수에 실패했습니다.');
- }
- }, [selected, quantity, side, loadAccount]);
- const fillRuleLabel = (rule: PaperFillRuleValue) => PAPER_FILL_RULE_LABEL[rule] ?? '—';
- return (
- <div className='paper__account-card'>
- <div className='paper__metric'>
- <span className='paper__metric-label'>가용 토큰</span>
- <span className='paper__metric-value'>{account ? formatTokenNumber(account.token) : '—'}</span>
- </div>
- <div className='paper-modal__body paper__trade-body'>
- {/* 종목 검색 */}
- <div className='paper-modal__field paper__field--suggest'>
- <span className='paper-modal__label'>종목</span>
- <input
- className='paper-modal__input'
- type='search'
- value={keyword}
- onChange={e => handleKeywordChange(e.target.value)}
- onFocus={() => { if (suggests.length > 0) setOpen(true); }}
- placeholder='종목명 또는 코드'
- autoComplete='off'
- role='combobox'
- aria-expanded={open}
- aria-controls={LISTBOX_ID}
- aria-autocomplete='list'
- />
- {open && suggests.length > 0 && (
- <ul id={LISTBOX_ID} className='header-suggest' role='listbox' aria-label='종목 자동완성'>
- {suggests.map(item => (
- <li
- key={item.code}
- role='option'
- aria-selected={selected?.code === item.code}
- className='header-suggest__item'
- onMouseDown={e => { e.preventDefault(); pickStock(item); }}
- >
- <span className='header-suggest__name'>{item.name}</span>
- <span className='header-suggest__code'>{item.code}</span>
- <span className={`stock-badge stock-badge--${item.market.toLowerCase()}`}>{item.market}</span>
- </li>
- ))}
- </ul>
- )}
- </div>
- {/* 매수/매도 */}
- <div className='paper-modal__field'>
- <span className='paper-modal__label'>주문 구분</span>
- <div className='paper__tabs'>
- <button
- type='button'
- className={`paper__tab${side === PaperOrderSide.Buy ? ' paper__tab--active' : ''}`}
- onClick={() => setSide(PaperOrderSide.Buy)}
- >
- {PAPER_SIDE_LABEL[PaperOrderSide.Buy]}
- </button>
- <button
- type='button'
- className={`paper__tab${side === PaperOrderSide.Sell ? ' paper__tab--active' : ''}`}
- onClick={() => setSide(PaperOrderSide.Sell)}
- >
- {PAPER_SIDE_LABEL[PaperOrderSide.Sell]}
- </button>
- </div>
- </div>
- {/* 수량 */}
- <div className='paper-modal__field'>
- <span className='paper-modal__label'>수량 (주)</span>
- <input
- className='paper-modal__input'
- type='number'
- min={1}
- step={1}
- inputMode='numeric'
- value={quantity}
- onChange={e => setQuantity(e.target.value)}
- placeholder='주문 수량'
- />
- </div>
- {error && <p className='paper-modal__error' role='alert'>{error}</p>}
- {result && (
- <div className='paper__notice' role='status'>
- <div className='paper__notice-title'>주문이 접수되었습니다</div>
- <ul className='paper__notice-list'>
- <li>주문번호: {result.orderID}</li>
- <li>구분: {PAPER_SIDE_LABEL[result.side]} · 체결 기준: {fillRuleLabel(result.fillRule)}</li>
- <li>수량: {result.quantity.toLocaleString()}주</li>
- <li>예약 금액: {formatTokenNumber(result.reservedAmount)}</li>
- <li>체결 기준일(TargetDate): {result.targetDate}</li>
- <li>취소 가능 시한: {getDateTime(result.cancelableUntil)}</li>
- </ul>
- </div>
- )}
- <div className='paper-modal__footer'>
- <button type='button' className='paper__btn paper__btn--primary' onClick={submit} disabled={busy}>
- {busy ? '접수 중...' : '주문 접수'}
- </button>
- </div>
- </div>
- </div>
- );
- }
|