fix: 수동 매도 즉시 반영 + 매도/매수 결과 토스트 + 당일 매도대금 가용현금 반영

- 프론트: 토스트 알림 시스템(lib/toast + ToastHost) 추가, 매도/매수 성공·실패를 화면에 표시
  (기존엔 console.log만, 실패는 블로킹 alert). 성공 토스트에 손익 표시.
- 매도 직후 예수금/평가액 요약 즉시 갱신(apply_sell), 최근 5분 내 수동 매도 종목은
  KIS 잔고 반영 지연으로 동기화가 되살리지 않도록 스킵(recent_sells).
- 예수금이 D+2 결제 기준(dnca_tot_amt)이라 당일 매도대금이 화면·데이트레이딩 가용현금에
  반영되지 않던 문제: 매수가능조회(inquire-psbl-order) ord_psbl_cash 를 우선 사용.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 14:40:39 +09:00
parent e0178aaf0d
commit 496529782b
9 changed files with 181 additions and 12 deletions

View File

@@ -18,6 +18,7 @@ import { LayoutDashboard, History, Cpu, Activity, SlidersHorizontal, BarChart3,
import { useQuery } from '@tanstack/react-query'
import api from '../lib/api'
import { useTenantMode } from '../lib/tenant'
import ToastHost from './ToastHost'
/** Layout Props 정의 */
interface LayoutProps {
@@ -81,6 +82,7 @@ const Layout = ({ children }: LayoutProps) => {
return (
<div className="min-h-screen bg-[#0f1115] text-gray-100 font-sans flex flex-col">
<ToastHost />
{/* Top Navigation Bar */}
<header className="border-b border-gray-800 bg-[#161b22] h-16 flex items-center justify-between px-6 sticky top-0 z-50 backdrop-blur-md bg-opacity-90">
<div className="flex items-center gap-8">

View File

@@ -91,7 +91,7 @@ const PortfolioItem = React.memo(({
const err = error as any
// 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략
if (err?.isAccountSetupRequired) return
alert(`매도 실패: ${err.response?.data?.detail || err.message}`)
// 실패 알림은 useTradeActions의 toast.error로 표시됨 (alert는 화면 블로킹)
}
}

View File

@@ -65,7 +65,7 @@ const RecommendationItem = React.memo(({
const err = error as any
// 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략
if (err?.isAccountSetupRequired) return
alert(`매수 실패: ${err.response?.data?.detail || err.message}`)
// 실패 알림은 useTradeActions의 toast.error로 표시됨 (alert는 화면 블로킹)
} finally {
setBuying(false)
}

View File

@@ -0,0 +1,51 @@
/**
* ToastHost.tsx — 우상단 토스트 스택. Layout에 1회 마운트.
*/
import React, { useEffect, useState } from 'react'
import { CheckCircle2, XCircle, Info, X } from 'lucide-react'
import { TOAST_EVENT, ToastMessage } from '../lib/toast'
const STYLE: Record<ToastMessage['kind'], { border: string; icon: React.ReactNode }> = {
success: { border: 'border-[#22c55e]', icon: <CheckCircle2 className="w-4 h-4 text-[#22c55e]" /> },
error: { border: 'border-[#ef4444]', icon: <XCircle className="w-4 h-4 text-[#ef4444]" /> },
info: { border: 'border-[#2b65d9]', icon: <Info className="w-4 h-4 text-[#2b65d9]" /> },
}
const ToastHost: React.FC = () => {
const [items, setItems] = useState<ToastMessage[]>([])
useEffect(() => {
const onToast = (e: Event) => {
const msg = (e as CustomEvent<ToastMessage>).detail
setItems((prev) => [...prev.slice(-4), msg])
window.setTimeout(() => setItems((prev) => prev.filter((m) => m.id !== msg.id)), msg.ttl)
}
window.addEventListener(TOAST_EVENT, onToast)
return () => window.removeEventListener(TOAST_EVENT, onToast)
}, [])
if (items.length === 0) return null
return (
<div className="fixed top-20 right-4 z-[100] flex flex-col gap-2 w-[320px] max-w-[calc(100vw-2rem)]">
{items.map((m) => (
<div
key={m.id}
role="status"
className={`flex items-start gap-2 bg-[#0c1522] border-l-4 ${STYLE[m.kind].border} border border-[#152035] rounded shadow-lg px-3 py-2 text-xs text-[#c9d7e5]`}
>
<span className="mt-px">{STYLE[m.kind].icon}</span>
<span className="flex-1 whitespace-pre-line">{m.text}</span>
<button
onClick={() => setItems((prev) => prev.filter((x) => x.id !== m.id))}
className="text-[#4a6580] hover:text-[#8faabf]"
aria-label="close"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)
}
export default ToastHost

View File

@@ -22,6 +22,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import api, { buyStockV2, sellStockV2, resetPendingTradesV2 } from '../lib/api'
import { useTenantMode } from '../lib/tenant'
import { toast } from '../lib/toast'
/** 계좌 미등록/미검증(400/423) 여부를 표시하는 확장 에러 타입 */
export type TradeActionError = AxiosError<{ detail?: string }> & { isAccountSetupRequired?: boolean }
@@ -58,11 +59,14 @@ export const useTradeActions = () => {
// KIS 모의서버 지연 시 주문 1건에 30~60초 걸릴 수 있음 — 기본 15초로 끊으면
// 서버는 체결하는데 화면은 실패로 보여 중복 주문을 유발함
const body = { stock_code: stockCode, quantity, price: null }
if (mode === 'v2') {
await sellStockV2(body)
} else {
await api.post('/portfolio/sell', body, { timeout: 90000 })
}
const res = mode === 'v2'
? await sellStockV2(body)
: await api.post('/portfolio/sell', body, { timeout: 90000 })
const d = (res as any)?.data ?? {}
const pl = typeof d.profit_loss === 'number'
? ` (손익 ${d.profit_loss >= 0 ? '+' : ''}${Math.round(d.profit_loss).toLocaleString('ko-KR')}원, ${(d.profit_loss_rate ?? 0) >= 0 ? '+' : ''}${Number(d.profit_loss_rate ?? 0).toFixed(2)}%)`
: ''
toast.success(`${d.message || `${stockName} ${quantity}주 매도 주문 완료`}${pl}`)
// Invalidate queries to refresh portfolio
queryClient.invalidateQueries({
@@ -79,6 +83,9 @@ export const useTradeActions = () => {
`❌ Sell order failed for ${stockName}:`,
err.response?.data?.detail || err.message
)
if (!err.isAccountSetupRequired) {
toast.error(`${stockName} 매도 실패: ${err.response?.data?.detail || err.message}`)
}
throw err
}
},
@@ -91,11 +98,11 @@ export const useTradeActions = () => {
if (mode === undefined) throw new TenantModeNotReadyError()
try {
const body = { stock_code: stockCode, stock_name: stockName, quantity: null, price: null }
if (mode === 'v2') {
await buyStockV2(body)
} else {
await api.post('/portfolio/buy', body, { timeout: 90000 })
}
const res = mode === 'v2'
? await buyStockV2(body)
: await api.post('/portfolio/buy', body, { timeout: 90000 })
const d = (res as any)?.data ?? {}
toast.success(d.message || `${stockName} 매수 주문 완료`)
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] })
queryClient.invalidateQueries({ queryKey: ['trade-history'] })
console.log(`✅ ${stockName} buy order completed`)
@@ -105,6 +112,9 @@ export const useTradeActions = () => {
`❌ Buy order failed for ${stockName}:`,
err.response?.data?.detail || err.message
)
if (!err.isAccountSetupRequired) {
toast.error(`${stockName} 매수 실패: ${err.response?.data?.detail || err.message}`)
}
throw err
}
},

23
frontend/src/lib/toast.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* toast.ts — 전역 토스트 알림 (의존성 없음, CustomEvent 기반)
*
* 사용: toast.success('LG 3주 매도 주문 완료') / toast.error('매도 실패: ...')
* 표시: <ToastHost />가 Layout에 마운트되어 window 이벤트를 받아 렌더링
*/
export type ToastKind = 'success' | 'error' | 'info'
export interface ToastMessage { id: number; kind: ToastKind; text: string; ttl: number }
export const TOAST_EVENT = 'app-toast'
let _seq = 0
const emit = (kind: ToastKind, text: string, ttl = 4000) => {
if (typeof window === 'undefined') return
const detail: ToastMessage = { id: ++_seq, kind, text, ttl }
window.dispatchEvent(new CustomEvent<ToastMessage>(TOAST_EVENT, { detail }))
}
export const toast = {
success: (text: string, ttl?: number) => emit('success', text, ttl),
error: (text: string, ttl?: number) => emit('error', text, ttl ?? 6000),
info: (text: string, ttl?: number) => emit('info', text, ttl),
}