'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|null>(null); const requestSeq = useRef(0); const [account, setAccount] = useState(null); const [keyword, setKeyword] = useState(''); const [suggests, setSuggests] = useState([]); const [open, setOpen] = useState(false); const [selected, setSelected] = useState(null); const [side, setSide] = useState(PaperOrderSide.Buy); const [quantity, setQuantity] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [result, setResult] = useState(null); const loadAccount = useCallback(async () => { const res = await fetchApi('/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(`/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('/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 (
가용 토큰 {account ? formatTokenNumber(account.token) : '—'}
{/* 종목 검색 */}
종목 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 && (
    {suggests.map(item => (
  • { e.preventDefault(); pickStock(item); }} > {item.name} {item.code} {item.market}
  • ))}
)}
{/* 매수/매도 */}
주문 구분
{/* 수량 */}
수량 (주) setQuantity(e.target.value)} placeholder='주문 수량' />
{error &&

{error}

} {result && (
주문이 접수되었습니다
  • 주문번호: {result.orderID}
  • 구분: {PAPER_SIDE_LABEL[result.side]} · 체결 기준: {fillRuleLabel(result.fillRule)}
  • 수량: {result.quantity.toLocaleString()}주
  • 예약 금액: {formatTokenNumber(result.reservedAmount)}
  • 체결 기준일(TargetDate): {result.targetDate}
  • 취소 가능 시한: {getDateTime(result.cancelableUntil)}
)}
); }