'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 = { [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('positions'); return (
{tab === 'positions' && } {tab === 'orders' && } {tab === 'ledger' && }
); } // ── 보유 포지션 ── function PositionsTab() { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { let cancelled = false; (async () => { const res = await fetchApi('/api/paper/positions', { silent: true }); if (cancelled) { return; } setRows(res.success ? (res.data?.list ?? []) : []); setLoading(false); })(); return () => { cancelled = true; }; }, []); if (loading) { return ; } if (rows.length === 0) { return
보유 중인 종목이 없습니다.
; } return (
{rows.map(row => { const dir = bpDirection(row.unrealizedPnLBp); return ( ); })}
종목 보유수량 매도예약 평균단가 현재가 평가금액 평가손익 수익률
{row.stockName} {row.stockCode} {row.quantity.toLocaleString()} {row.reservedQuantity.toLocaleString()} {row.avgPrice.toLocaleString('ko-KR', { maximumFractionDigits: 2 })} {row.latestClose === null ? '—' : row.latestClose.toLocaleString()} {formatTokenNumber(row.marketValue)} {formatTokenNumber(row.unrealizedPnL)} {formatBpPercent(row.unrealizedPnLBp)}
); } // ── 주문·체결 내역 ── function OrdersTab() { const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [status, setStatus] = useState(''); const [loading, setLoading] = useState(true); const [cancelingID, setCancelingID] = useState(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(`/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 (
상태
{loading ? ( ) : rows.length === 0 ? (
주문 내역이 없습니다.
) : ( <>
{rows.map(row => { const pnlDir = bpDirection(row.fill?.realizedPnL ?? null); return ( ); })}
종목 구분 기준 수량 예약금액 기준일 상태 체결가 실현손익 취소
{row.stockName} {row.stockCode} {PAPER_SIDE_LABEL[row.side]} {PAPER_FILL_RULE_LABEL[row.fillRule]} {row.quantity.toLocaleString()} {formatTokenNumber(row.reservedAmount)} {row.targetDate} {PAPER_ORDER_STATUS_LABEL[row.status]} {row.fill ? row.fill.price.toLocaleString() : '—'} {row.fill?.realizedPnL != null ? formatTokenNumber(row.fill.realizedPnL) : '—'} {canCancel(row) ? ( ) : ( '—' )}
)}
); } // ── 입출금 내역 ── function LedgerTab() { const [rows, setRows] = useState([]); 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(`/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 ; } if (rows.length === 0) { return
입출금 내역이 없습니다.
; } return ( <>
{rows.map(row => ( ))}
구분 금액 좌수 변동 이후 잔액 일시
{PAPER_LEDGER_TYPE_LABEL[row.type]} {formatTokenNumber(row.tokenAmount)} {row.unitsDelta.toLocaleString('ko-KR', { maximumFractionDigits: 4 })} {formatTokenNumber(row.balanceAfter)} {getDateTime(row.createdAt)}
); }