/** * JournalPanel.tsx — 매매내역 및 매매일지 탭 패널 컴포넌트 * * 역할: * - 매매내역 탭: 최근 거래 이력 표시 (매수/매도, 손익, 체결 시간, 사유) * - 매매일지 탭: AI 자동 생성 매매일지 표시 (기분, 태그, 내용) * - 미체결 주문 정리 버튼 제공 * * 최적화: * - React.memo로 불필요한 리렌더 방지 * * 데이터 흐름: * - journals: react-query 'journals' 쿼리 결과 * - tradeHistory: react-query 'trade-history' 쿼리 결과 */ import React, { useMemo, useState } from 'react' import { BookOpen, ArrowUpDown } from 'lucide-react' import { Journal, TradeHistory } from '../types/trading' import { parseKst } from '../utils/formatUtils' const kstDateKey = (d: Date | null): string => d ? d.toLocaleDateString('sv-SE', { timeZone: 'Asia/Seoul' }) : '' const kstDateLabel = (d: Date | null, todayKey: string): string => { if (!d) return '날짜 미상' const key = kstDateKey(d) const md = d.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', weekday: 'short', timeZone: 'Asia/Seoul' }) return key === todayKey ? `${md} (오늘)` : md } /** JournalPanel Props 정의 */ interface JournalPanelProps { /** 매매일지 배열 (장 마감 후 자동 생성) */ journals: Journal[] | undefined /** 매매 내역 배열 (최근 거래 이력) */ tradeHistory?: TradeHistory[] | undefined /** 미체결 주문 정리 콜백 */ onResetPending: () => void /** true면 미체결 정리 버튼 비활성화 (예: 사용자 모드 확정 전) */ resetDisabled?: boolean } /** * JournalPanel — 매매내역/매매일지 탭 전환 패널 * * 상태(State): * - tab: 현재 활성 탭 ('trades' 또는 'journal') */ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, resetDisabled }: JournalPanelProps) => { const [tab, setTab] = useState<'trades' | 'journal'>('trades') // 최신순 정렬 (created_at/executed_at 기준, KST 보정). 시각 파싱 불가 건은 id 로 보조 정렬 const sortedTrades = useMemo(() => { const todayKey = kstDateKey(new Date()) return (tradeHistory ?? []) .map((t: TradeHistory) => { const dt = parseKst(t.created_at || t.executed_at || '') return { t, dt, key: kstDateKey(dt) } }) .sort((a, b) => { const d = (b.dt?.getTime() ?? 0) - (a.dt?.getTime() ?? 0) return d !== 0 ? d : Number(b.t.id || 0) - Number(a.t.id || 0) }) .map((x, i, arr) => ({ ...x, // 날짜가 바뀌는 첫 행에 구분선 라벨 header: i === 0 || arr[i - 1].key !== x.key ? kstDateLabel(x.dt, todayKey) : null, })) }, [tradeHistory]) return (
{/* Header with tabs */}
{/* Content */}
{tab === 'trades' ? ( /* ── 매매내역 탭 ── */ sortedTrades.length > 0 ? ( sortedTrades.map(({ t: trade, dt: createdAt, header }, idx: number) => { // 거래 유형 판별 (매수/매도) const isBuy = trade.order_type === 'BUY' const isSell = trade.order_type === 'SELL' // 손익 금액 및 수익률 (순이익 우선, 없으면 손익 사용) const pnl = Number(trade.net_profit_loss || trade.profit_loss || 0) const pnlRate = Number(trade.profit_loss_rate || 0) const isProfit = pnl > 0 const isLoss = pnl < 0 // 체결 상태 판별 (COMPLETED, EXECUTED, FILLED → 정상 체결) const st = String(trade.status || '') const statusOk = st === 'COMPLETED' || st === 'EXECUTED' || st === 'FILLED' const isFailed = st === 'FAILED' // DB가 KST로 저장하므로 타임존 없는 ISO 문자열에 +09:00 보정 (parseKst) const timeStr = createdAt ? createdAt.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Asia/Seoul', }) : '--:--:--' const dateShort = createdAt ? createdAt.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' }) : '' return ( {header && (
{header}
)}
{isBuy ? '매수' : '매도'} {trade.stock_name || trade.stock_code} {isFailed && ( 실패 )}
{dateShort} {timeStr}
{/* 체결가(executed_price) 우선. null 이면 아직 체결 확인 전 — 주문가는 회색으로 구분 */} {Number(trade.executed_price) > 0 ? ( <>{Number(trade.executed_quantity || trade.order_quantity || 0)}주 × {Math.round(Number(trade.executed_price)).toLocaleString()}원 ) : ( <> {Number(trade.order_quantity || trade.executed_quantity || 0)}주 {Number(trade.order_price) > 0 && ( × 주문가 {Math.round(Number(trade.order_price)).toLocaleString()}원 )} {!isFailed && 체결 확인 중} )}
{isSell && statusOk && pnl !== 0 && (
{pnl > 0 ? '+' : ''}{Math.round(pnl).toLocaleString()}원 ({pnlRate > 0 ? '+' : ''}{pnlRate.toFixed(1)}%)
)}
{trade.entry_reason && isBuy && (
{trade.entry_reason}
)} {trade.exit_reason && isSell && (
{trade.exit_reason}
)}
) }) ) : (
매매내역 없음
) ) : ( /* ── 매매일지 탭 ── */ journals && journals.length > 0 ? ( journals.map((journal: Journal) => { // DB가 KST로 저장하므로 타임존 없는 ISO 문자열에 +09:00 보정 // (매매내역 경로 :102-103과 동일 규약 — 오프셋이 이미 있으면 그대로 둠) const rawJournalDt = journal.journal_date || journal.created_at || '' const journalDate = new Date( rawJournalDt.includes('+') || rawJournalDt.includes('Z') ? rawJournalDt : rawJournalDt + '+09:00' ) const dateStr = journalDate.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' }) const timeStr = journalDate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Seoul' }) const isGoodMood = journal.mood === '좋음' || journal.mood === 'good' || journal.mood === 'positive' const isBadMood = journal.mood === '나쁨' || journal.mood === 'bad' || journal.mood === 'negative' const tags: string[] = Array.isArray(journal.tags) ? journal.tags : [] return (
{isGoodMood ? '😊' : isBadMood ? '😔' : '📝'} {journal.stock_name || journal.stock_code}
{dateStr} {timeStr}
{journal.content}
{tags.length > 0 && (
{tags.slice(0, 4).map((tag: string, i: number) => ( #{tag} ))}
)}
) }) ) : (
매매일지 없음 장 마감 후 자동 생성됩니다
) )}
) }) JournalPanel.displayName = 'JournalPanel' export default JournalPanel