|
|
@@ -0,0 +1,347 @@
|
|
|
+'use client';
|
|
|
+
|
|
|
+import { useCallback, useEffect, useState } from 'react';
|
|
|
+import Link from 'next/link';
|
|
|
+import { cn, fetchApi, getDateTime } from '@/lib/utils/client';
|
|
|
+import { bpDirection, formatBpPercent, formatTokenNumber } from '@/lib/utils/paper';
|
|
|
+import Loading from '@/app/component/Loading';
|
|
|
+import Pagination from '@/app/component/Pagination';
|
|
|
+import {
|
|
|
+ PaperOrderStatus,
|
|
|
+ PAPER_SIDE_LABEL,
|
|
|
+ PAPER_FILL_RULE_LABEL,
|
|
|
+ PAPER_ORDER_STATUS_LABEL,
|
|
|
+ PAPER_LEDGER_TYPE_LABEL,
|
|
|
+ PaperOrderSide,
|
|
|
+ PaperLedgerType
|
|
|
+} from '@/types/paper';
|
|
|
+import type {
|
|
|
+ PaperPositionsResponse,
|
|
|
+ PaperPositionRow,
|
|
|
+ PaperOrdersResponse,
|
|
|
+ PaperOrderRow,
|
|
|
+ PaperLedgerResponse,
|
|
|
+ PaperLedgerRow,
|
|
|
+ PaperOrderStatusValue
|
|
|
+} from '@/types/paper';
|
|
|
+
|
|
|
+type TabKey = 'positions'|'orders'|'ledger';
|
|
|
+const PER_PAGE = 20;
|
|
|
+
|
|
|
+const STATUS_BADGE_CLASS: Record<PaperOrderStatusValue, string> = {
|
|
|
+ [PaperOrderStatus.Pending]: 'paper__badge--pending',
|
|
|
+ [PaperOrderStatus.Filled]: 'paper__badge--filled',
|
|
|
+ [PaperOrderStatus.Cancelled]: 'paper__badge--cancelled',
|
|
|
+ [PaperOrderStatus.Rejected]: 'paper__badge--rejected'
|
|
|
+};
|
|
|
+
|
|
|
+export default function PaperPortfolio()
|
|
|
+{
|
|
|
+ const [tab, setTab] = useState<TabKey>('positions');
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div>
|
|
|
+ <div className='paper__tabs'>
|
|
|
+ <button type='button' className={cn('paper__tab', tab === 'positions' && 'paper__tab--active')} onClick={() => setTab('positions')}>보유</button>
|
|
|
+ <button type='button' className={cn('paper__tab', tab === 'orders' && 'paper__tab--active')} onClick={() => setTab('orders')}>주문</button>
|
|
|
+ <button type='button' className={cn('paper__tab', tab === 'ledger' && 'paper__tab--active')} onClick={() => setTab('ledger')}>입출금</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {tab === 'positions' && <PositionsTab />}
|
|
|
+ {tab === 'orders' && <OrdersTab />}
|
|
|
+ {tab === 'ledger' && <LedgerTab />}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// ── 보유 포지션 ──
|
|
|
+function PositionsTab()
|
|
|
+{
|
|
|
+ const [rows, setRows] = useState<PaperPositionRow[]>([]);
|
|
|
+ const [loading, setLoading] = useState(true);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ let cancelled = false;
|
|
|
+
|
|
|
+ (async () => {
|
|
|
+ const res = await fetchApi<PaperPositionsResponse>('/api/paper/positions', { silent: true });
|
|
|
+ if (cancelled) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ setRows(res.success ? (res.data?.list ?? []) : []);
|
|
|
+ setLoading(false);
|
|
|
+ })();
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ cancelled = true;
|
|
|
+ };
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ if (loading) {
|
|
|
+ return <Loading type={2} />;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (rows.length === 0) {
|
|
|
+ return <div className='paper__empty'>보유 중인 종목이 없습니다.</div>;
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className='paper__table-wrap'>
|
|
|
+ <table className='paper__table'>
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th className='paper__th--left'>종목</th>
|
|
|
+ <th>보유수량</th>
|
|
|
+ <th>매도예약</th>
|
|
|
+ <th>평균단가</th>
|
|
|
+ <th>현재가</th>
|
|
|
+ <th>평가금액</th>
|
|
|
+ <th>평가손익</th>
|
|
|
+ <th>수익률</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {rows.map(row => {
|
|
|
+ const dir = bpDirection(row.unrealizedPnLBp);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <tr key={row.stockCode}>
|
|
|
+ <td className='paper__td--left'>
|
|
|
+ <Link href={`/stock/${row.stockCode}`} className='paper__stock-name'>{row.stockName}</Link>
|
|
|
+ <span className='paper__stock-code'>{row.stockCode}</span>
|
|
|
+ </td>
|
|
|
+ <td>{row.quantity.toLocaleString()}</td>
|
|
|
+ <td>{row.reservedQuantity.toLocaleString()}</td>
|
|
|
+ <td>{row.avgPrice.toLocaleString('ko-KR', { maximumFractionDigits: 2 })}</td>
|
|
|
+ <td>{row.latestClose === null ? '—' : row.latestClose.toLocaleString()}</td>
|
|
|
+ <td>{formatTokenNumber(row.marketValue)}</td>
|
|
|
+ <td className={cn(`paper__value--${dir}`)}>{formatTokenNumber(row.unrealizedPnL)}</td>
|
|
|
+ <td className={cn(`paper__value--${dir}`)}>{formatBpPercent(row.unrealizedPnLBp)}</td>
|
|
|
+ </tr>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// ── 주문·체결 내역 ──
|
|
|
+function OrdersTab()
|
|
|
+{
|
|
|
+ const [rows, setRows] = useState<PaperOrderRow[]>([]);
|
|
|
+ const [total, setTotal] = useState(0);
|
|
|
+ const [page, setPage] = useState(1);
|
|
|
+ const [status, setStatus] = useState<PaperOrderStatusValue|''>('');
|
|
|
+ const [loading, setLoading] = useState(true);
|
|
|
+ const [cancelingID, setCancelingID] = useState<number|null>(null);
|
|
|
+
|
|
|
+ const load = useCallback(async () => {
|
|
|
+ setLoading(true);
|
|
|
+
|
|
|
+ const query = new URLSearchParams();
|
|
|
+ query.set('page', String(page));
|
|
|
+ query.set('perPage', String(PER_PAGE));
|
|
|
+ if (status !== '') {
|
|
|
+ query.set('status', String(status));
|
|
|
+ }
|
|
|
+
|
|
|
+ const res = await fetchApi<PaperOrdersResponse>(`/api/paper/orders?${query.toString()}`, { silent: true });
|
|
|
+
|
|
|
+ if (res.success && res.data) {
|
|
|
+ setRows(res.data.list);
|
|
|
+ setTotal(res.data.total);
|
|
|
+ }
|
|
|
+ else {
|
|
|
+ setRows([]);
|
|
|
+ setTotal(0);
|
|
|
+ }
|
|
|
+
|
|
|
+ setLoading(false);
|
|
|
+ }, [page, status]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ load();
|
|
|
+ }, [load]);
|
|
|
+
|
|
|
+ const cancelOrder = useCallback(async (id: number) => {
|
|
|
+ if (!confirm('주문을 취소하시겠어요?')) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ setCancelingID(id);
|
|
|
+ const res = await fetchApi(`/api/paper/orders/${id}`, { method: 'DELETE', silent: true });
|
|
|
+ setCancelingID(null);
|
|
|
+
|
|
|
+ if (res.success) {
|
|
|
+ load();
|
|
|
+ }
|
|
|
+ else {
|
|
|
+ alert(res.message || '주문 취소에 실패했습니다.');
|
|
|
+ }
|
|
|
+ }, [load]);
|
|
|
+
|
|
|
+ const canCancel = (row: PaperOrderRow) => row.status === PaperOrderStatus.Pending && new Date(row.cancelableUntil).getTime() > Date.now();
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div>
|
|
|
+ <div className='paper__tab-group'>
|
|
|
+ <span className='paper__tab-group-label'>상태</span>
|
|
|
+ <button type='button' className={cn('paper__tab', status === '' && 'paper__tab--active')} onClick={() => { setStatus(''); setPage(1); }}>전체</button>
|
|
|
+ <button type='button' className={cn('paper__tab', status === PaperOrderStatus.Pending && 'paper__tab--active')} onClick={() => { setStatus(PaperOrderStatus.Pending); setPage(1); }}>접수</button>
|
|
|
+ <button type='button' className={cn('paper__tab', status === PaperOrderStatus.Filled && 'paper__tab--active')} onClick={() => { setStatus(PaperOrderStatus.Filled); setPage(1); }}>체결</button>
|
|
|
+ <button type='button' className={cn('paper__tab', status === PaperOrderStatus.Cancelled && 'paper__tab--active')} onClick={() => { setStatus(PaperOrderStatus.Cancelled); setPage(1); }}>취소</button>
|
|
|
+ <button type='button' className={cn('paper__tab', status === PaperOrderStatus.Rejected && 'paper__tab--active')} onClick={() => { setStatus(PaperOrderStatus.Rejected); setPage(1); }}>거부</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {loading ? (
|
|
|
+ <Loading type={2} />
|
|
|
+ ) : rows.length === 0 ? (
|
|
|
+ <div className='paper__empty'>주문 내역이 없습니다.</div>
|
|
|
+ ) : (
|
|
|
+ <>
|
|
|
+ <div className='paper__table-wrap'>
|
|
|
+ <table className='paper__table'>
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th className='paper__th--left'>종목</th>
|
|
|
+ <th className='paper__th--center'>구분</th>
|
|
|
+ <th className='paper__th--center'>기준</th>
|
|
|
+ <th>수량</th>
|
|
|
+ <th>예약금액</th>
|
|
|
+ <th>기준일</th>
|
|
|
+ <th className='paper__th--center'>상태</th>
|
|
|
+ <th>체결가</th>
|
|
|
+ <th>실현손익</th>
|
|
|
+ <th className='paper__th--center'>취소</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {rows.map(row => {
|
|
|
+ const pnlDir = bpDirection(row.fill?.realizedPnL ?? null);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <tr key={row.id}>
|
|
|
+ <td className='paper__td--left'>
|
|
|
+ <Link href={`/stock/${row.stockCode}`} className='paper__stock-name'>{row.stockName}</Link>
|
|
|
+ <span className='paper__stock-code'>{row.stockCode}</span>
|
|
|
+ </td>
|
|
|
+ <td className='paper__td--center'>
|
|
|
+ <span className={cn('paper__badge', row.side === PaperOrderSide.Buy ? 'paper__badge--buy' : 'paper__badge--sell')}>
|
|
|
+ {PAPER_SIDE_LABEL[row.side]}
|
|
|
+ </span>
|
|
|
+ </td>
|
|
|
+ <td className='paper__td--center'>{PAPER_FILL_RULE_LABEL[row.fillRule]}</td>
|
|
|
+ <td>{row.quantity.toLocaleString()}</td>
|
|
|
+ <td>{formatTokenNumber(row.reservedAmount)}</td>
|
|
|
+ <td>{row.targetDate}</td>
|
|
|
+ <td className='paper__td--center'>
|
|
|
+ <span className={cn('paper__badge', STATUS_BADGE_CLASS[row.status])} title={row.rejectReason ?? undefined}>
|
|
|
+ {PAPER_ORDER_STATUS_LABEL[row.status]}
|
|
|
+ </span>
|
|
|
+ </td>
|
|
|
+ <td>{row.fill ? row.fill.price.toLocaleString() : '—'}</td>
|
|
|
+ <td className={cn(row.fill?.realizedPnL != null && `paper__value--${pnlDir}`)}>
|
|
|
+ {row.fill?.realizedPnL != null ? formatTokenNumber(row.fill.realizedPnL) : '—'}
|
|
|
+ </td>
|
|
|
+ <td className='paper__td--center'>
|
|
|
+ {canCancel(row) ? (
|
|
|
+ <button
|
|
|
+ type='button'
|
|
|
+ className='paper__btn paper__btn--danger paper__btn--sm'
|
|
|
+ onClick={() => cancelOrder(row.id)}
|
|
|
+ disabled={cancelingID === row.id}
|
|
|
+ >
|
|
|
+ {cancelingID === row.id ? '취소 중' : '취소'}
|
|
|
+ </button>
|
|
|
+ ) : (
|
|
|
+ '—'
|
|
|
+ )}
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Pagination total={total} page={page} perPage={PER_PAGE} onChange={setPage} />
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// ── 입출금 내역 ──
|
|
|
+function LedgerTab()
|
|
|
+{
|
|
|
+ const [rows, setRows] = useState<PaperLedgerRow[]>([]);
|
|
|
+ const [total, setTotal] = useState(0);
|
|
|
+ const [page, setPage] = useState(1);
|
|
|
+ const [loading, setLoading] = useState(true);
|
|
|
+
|
|
|
+ const load = useCallback(async () => {
|
|
|
+ setLoading(true);
|
|
|
+
|
|
|
+ const res = await fetchApi<PaperLedgerResponse>(`/api/paper/ledger?page=${page}&perPage=${PER_PAGE}`, { silent: true });
|
|
|
+
|
|
|
+ if (res.success && res.data) {
|
|
|
+ setRows(res.data.list);
|
|
|
+ setTotal(res.data.total);
|
|
|
+ }
|
|
|
+ else {
|
|
|
+ setRows([]);
|
|
|
+ setTotal(0);
|
|
|
+ }
|
|
|
+
|
|
|
+ setLoading(false);
|
|
|
+ }, [page]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ load();
|
|
|
+ }, [load]);
|
|
|
+
|
|
|
+ if (loading) {
|
|
|
+ return <Loading type={2} />;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (rows.length === 0) {
|
|
|
+ return <div className='paper__empty'>입출금 내역이 없습니다.</div>;
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ <div className='paper__table-wrap'>
|
|
|
+ <table className='paper__table'>
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th className='paper__th--center'>구분</th>
|
|
|
+ <th>금액</th>
|
|
|
+ <th>좌수 변동</th>
|
|
|
+ <th>이후 잔액</th>
|
|
|
+ <th className='paper__th--left'>일시</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {rows.map(row => (
|
|
|
+ <tr key={row.id}>
|
|
|
+ <td className='paper__td--center'>
|
|
|
+ <span className={cn('paper__badge', row.type === PaperLedgerType.Deposit ? 'paper__badge--filled' : 'paper__badge--cancelled')}>
|
|
|
+ {PAPER_LEDGER_TYPE_LABEL[row.type]}
|
|
|
+ </span>
|
|
|
+ </td>
|
|
|
+ <td>{formatTokenNumber(row.tokenAmount)}</td>
|
|
|
+ <td>{row.unitsDelta.toLocaleString('ko-KR', { maximumFractionDigits: 4 })}</td>
|
|
|
+ <td>{formatTokenNumber(row.balanceAfter)}</td>
|
|
|
+ <td className='paper__td--left'>{getDateTime(row.createdAt)}</td>
|
|
|
+ </tr>
|
|
|
+ ))}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Pagination total={total} page={page} perPage={PER_PAGE} onChange={setPage} />
|
|
|
+ </>
|
|
|
+ );
|
|
|
+}
|