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:
@@ -128,7 +128,10 @@ def _extract_balance_data(balance_data: dict) -> dict:
|
|||||||
if balance_data and "output2" in balance_data:
|
if balance_data and "output2" in balance_data:
|
||||||
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
|
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
|
||||||
|
|
||||||
|
# _ord_psbl_cash: sync_portfolio()가 매수가능조회로 채워 넣는 주문가능현금
|
||||||
|
# (당일 매도대금 포함). 없으면 예수금총액(D+2 결제 기준) 폴백.
|
||||||
available_cash = _pick_float(output2, [
|
available_cash = _pick_float(output2, [
|
||||||
|
"_ord_psbl_cash",
|
||||||
"dnca_tot_amt",
|
"dnca_tot_amt",
|
||||||
"d2_depo_amt",
|
"d2_depo_amt",
|
||||||
"d2_deposit",
|
"d2_deposit",
|
||||||
@@ -212,6 +215,22 @@ class PortfolioCache:
|
|||||||
self.next_retry_at: datetime | None = None
|
self.next_retry_at: datetime | None = None
|
||||||
self.last_sync_success: bool = False
|
self.last_sync_success: bool = False
|
||||||
self.last_trade_id: int | None = None
|
self.last_trade_id: int | None = None
|
||||||
|
# 최근 수동 매도 {code: (qty, ts)} — KIS 잔고 반영 지연으로 동기화가 판 종목을
|
||||||
|
# 되살리는 것 방지 (5분 TTL)
|
||||||
|
self.recent_sells: dict[str, tuple[int, datetime]] = {}
|
||||||
|
|
||||||
|
def note_sell(self, stock_code: str, quantity: int) -> None:
|
||||||
|
self.recent_sells[stock_code] = (quantity, datetime.now())
|
||||||
|
|
||||||
|
def recently_sold_qty(self, stock_code: str, ttl_sec: int = 300) -> int:
|
||||||
|
rec = self.recent_sells.get(stock_code)
|
||||||
|
if not rec:
|
||||||
|
return 0
|
||||||
|
qty, ts = rec
|
||||||
|
if (datetime.now() - ts).total_seconds() > ttl_sec:
|
||||||
|
self.recent_sells.pop(stock_code, None)
|
||||||
|
return 0
|
||||||
|
return qty
|
||||||
|
|
||||||
def mark_changed(self):
|
def mark_changed(self):
|
||||||
self.change_flag = True
|
self.change_flag = True
|
||||||
@@ -483,6 +502,16 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
|||||||
logger.warning(f"⚠️ 해외 잔고 조회 실패: {overseas_balance_data}")
|
logger.warning(f"⚠️ 해외 잔고 조회 실패: {overseas_balance_data}")
|
||||||
overseas_balance_data = {}
|
overseas_balance_data = {}
|
||||||
|
|
||||||
|
# 주문가능현금(당일 매도대금 포함) → output2에 주입해 available_cash로 사용
|
||||||
|
try:
|
||||||
|
if balance_data and balance_data.get("rt_cd") == "0" and balance_data.get("output2"):
|
||||||
|
_buyable = await korea_investment_api.get_buyable_cash()
|
||||||
|
if _buyable > 0:
|
||||||
|
_o2 = balance_data["output2"]
|
||||||
|
(_o2[0] if isinstance(_o2, list) else _o2)["_ord_psbl_cash"] = _buyable
|
||||||
|
except Exception as _bc_err:
|
||||||
|
logger.debug(f"주문가능현금 조회 실패 (예수금총액 폴백): {_bc_err}")
|
||||||
|
|
||||||
# 디버깅: KIS API 전체 응답 로깅
|
# 디버깅: KIS API 전체 응답 로깅
|
||||||
logger.info(f"🔍 KIS API balance response keys: {balance_data.keys() if balance_data else 'None'}")
|
logger.info(f"🔍 KIS API balance response keys: {balance_data.keys() if balance_data else 'None'}")
|
||||||
|
|
||||||
@@ -577,6 +606,14 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
|||||||
for item in balance_data["output1"]:
|
for item in balance_data["output1"]:
|
||||||
stock_code = item.get("pdno")
|
stock_code = item.get("pdno")
|
||||||
|
|
||||||
|
# 최근 5분 내 수동 매도한 수량 이하로만 남아 있으면 KIS 반영 지연으로 판단
|
||||||
|
_sold_qty = portfolio_cache.recently_sold_qty(stock_code)
|
||||||
|
if _sold_qty and int(item.get("hldg_qty", 0)) <= _sold_qty:
|
||||||
|
logger.info(f"👻 최근 수동 매도 종목 스킵: {stock_code} (KIS 잔고 반영 대기)")
|
||||||
|
if stock_code in existing_all:
|
||||||
|
await db.delete(existing_all[stock_code])
|
||||||
|
continue
|
||||||
|
|
||||||
if stock_code in ghost_candidates:
|
if stock_code in ghost_candidates:
|
||||||
logger.info(f"👻 Skipping Ghost Stock: {stock_code} (Latest trade was SELL)")
|
logger.info(f"👻 Skipping Ghost Stock: {stock_code} (Latest trade was SELL)")
|
||||||
# DB에서도 명시적 삭제
|
# DB에서도 명시적 삭제
|
||||||
@@ -1333,6 +1370,7 @@ async def sell_stock(
|
|||||||
# ✅ delete/commit 후 접근 불가를 대비해 미리 캡처
|
# ✅ delete/commit 후 접근 불가를 대비해 미리 캡처
|
||||||
stock_name = holding.stock_name
|
stock_name = holding.stock_name
|
||||||
original_holding_qty = holding.quantity
|
original_holding_qty = holding.quantity
|
||||||
|
original_avg_price = float(holding.average_price or 0)
|
||||||
|
|
||||||
# 매도 주문 실행
|
# 매도 주문 실행
|
||||||
order_result = await korea_investment_api.place_order(
|
order_result = await korea_investment_api.place_order(
|
||||||
@@ -1395,6 +1433,9 @@ async def sell_stock(
|
|||||||
if h.stock_code == stock_code:
|
if h.stock_code == stock_code:
|
||||||
h.quantity -= quantity
|
h.quantity -= quantity
|
||||||
break
|
break
|
||||||
|
# 예수금/평가액 요약도 즉시 반영 + 동기화가 되살리지 않도록 최근 매도 기록
|
||||||
|
portfolio_cache.note_sell(stock_code, quantity)
|
||||||
|
await portfolio_cache.apply_sell(stock_code, quantity, float(executed_price), original_avg_price)
|
||||||
portfolio_cache.mark_changed()
|
portfolio_cache.mark_changed()
|
||||||
|
|
||||||
logger.info(f"✅ 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원 (손익: {profit_loss:+,.0f}원)")
|
logger.info(f"✅ 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원 (손익: {profit_loss:+,.0f}원)")
|
||||||
|
|||||||
@@ -282,6 +282,10 @@ async def scan_and_enter() -> list[dict]:
|
|||||||
summary = (balance.get("output2") or [{}])[0]
|
summary = (balance.get("output2") or [{}])[0]
|
||||||
total_assets = float(summary.get("tot_evlu_amt", 0) or 0)
|
total_assets = float(summary.get("tot_evlu_amt", 0) or 0)
|
||||||
available_cash = float(summary.get("prvs_rcdl_excc_amt", 0) or 0)
|
available_cash = float(summary.get("prvs_rcdl_excc_amt", 0) or 0)
|
||||||
|
# 당일 매도대금 재사용 가능 — 주문가능현금이 조회되면 우선 사용
|
||||||
|
_buyable = await korea_investment_api.get_buyable_cash()
|
||||||
|
if _buyable > 0:
|
||||||
|
available_cash = _buyable
|
||||||
day_trading_state.last_total_assets = total_assets
|
day_trading_state.last_total_assets = total_assets
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"[DayEntry] 잔고 조회 실패: {e}")
|
logger.debug(f"[DayEntry] 잔고 조회 실패: {e}")
|
||||||
|
|||||||
@@ -1092,6 +1092,44 @@ class KoreaInvestmentAPI:
|
|||||||
logger.error(f"Error fetching balance: {e}")
|
logger.error(f"Error fetching balance: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def get_buyable_cash(self, stock_code: str = "005930") -> float:
|
||||||
|
"""주문가능현금(ord_psbl_cash) 조회 — 매수가능조회 API.
|
||||||
|
|
||||||
|
예수금총액(dnca_tot_amt)은 D+2 결제 기준이라 당일 매도대금이 반영되지 않는다.
|
||||||
|
국내 주식은 당일 매도대금을 즉시 재매수에 쓸 수 있으므로, 화면 '예수금'과
|
||||||
|
데이트레이딩 가용 현금은 이 값을 우선 사용한다. (2026-09-22)
|
||||||
|
실전: TTTC8908R / 모의: VTTC8908R. 실패 시 0.0 반환(호출측에서 폴백).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
account_number_prefix = self.account_number[:8]
|
||||||
|
account_number_suffix = self.account_number[8:]
|
||||||
|
is_virtual = "vts" in self.base_url.lower()
|
||||||
|
tr_id = "VTTC8908R" if is_virtual else "TTTC8908R"
|
||||||
|
result, _ = await self._make_request(
|
||||||
|
"GET",
|
||||||
|
"/uapi/domestic-stock/v1/trading/inquire-psbl-order",
|
||||||
|
tr_id,
|
||||||
|
params={
|
||||||
|
"CANO": account_number_prefix,
|
||||||
|
"ACNT_PRDT_CD": account_number_suffix,
|
||||||
|
"PDNO": stock_code,
|
||||||
|
"ORD_UNPR": "0",
|
||||||
|
"ORD_DVSN": "01", # 시장가 기준
|
||||||
|
"CMA_EVLU_AMT_ICLD_YN": "N",
|
||||||
|
"OVRS_ICLD_YN": "N",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if result.get("rt_cd") != "0":
|
||||||
|
logger.warning(f"[get_buyable_cash] rt_cd={result.get('rt_cd')} msg1={result.get('msg1')}")
|
||||||
|
return 0.0
|
||||||
|
out = result.get("output") or {}
|
||||||
|
if isinstance(out, list):
|
||||||
|
out = out[0] if out else {}
|
||||||
|
return float(out.get("ord_psbl_cash", 0) or 0)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[get_buyable_cash] 조회 실패: {e}")
|
||||||
|
return 0.0
|
||||||
|
|
||||||
async def get_overseas_balance(self) -> Dict[str, Any]:
|
async def get_overseas_balance(self) -> Dict[str, Any]:
|
||||||
"""해외주식 잔고 조회 (미국 기준)
|
"""해외주식 잔고 조회 (미국 기준)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { LayoutDashboard, History, Cpu, Activity, SlidersHorizontal, BarChart3,
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import api from '../lib/api'
|
import api from '../lib/api'
|
||||||
import { useTenantMode } from '../lib/tenant'
|
import { useTenantMode } from '../lib/tenant'
|
||||||
|
import ToastHost from './ToastHost'
|
||||||
|
|
||||||
/** Layout Props 정의 */
|
/** Layout Props 정의 */
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
@@ -81,6 +82,7 @@ const Layout = ({ children }: LayoutProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0f1115] text-gray-100 font-sans flex flex-col">
|
<div className="min-h-screen bg-[#0f1115] text-gray-100 font-sans flex flex-col">
|
||||||
|
<ToastHost />
|
||||||
{/* Top Navigation Bar */}
|
{/* 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">
|
<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">
|
<div className="flex items-center gap-8">
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ const PortfolioItem = React.memo(({
|
|||||||
const err = error as any
|
const err = error as any
|
||||||
// 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략
|
// 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략
|
||||||
if (err?.isAccountSetupRequired) return
|
if (err?.isAccountSetupRequired) return
|
||||||
alert(`매도 실패: ${err.response?.data?.detail || err.message}`)
|
// 실패 알림은 useTradeActions의 toast.error로 표시됨 (alert는 화면 블로킹)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const RecommendationItem = React.memo(({
|
|||||||
const err = error as any
|
const err = error as any
|
||||||
// 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략
|
// 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략
|
||||||
if (err?.isAccountSetupRequired) return
|
if (err?.isAccountSetupRequired) return
|
||||||
alert(`매수 실패: ${err.response?.data?.detail || err.message}`)
|
// 실패 알림은 useTradeActions의 toast.error로 표시됨 (alert는 화면 블로킹)
|
||||||
} finally {
|
} finally {
|
||||||
setBuying(false)
|
setBuying(false)
|
||||||
}
|
}
|
||||||
|
|||||||
51
frontend/src/components/ToastHost.tsx
Normal file
51
frontend/src/components/ToastHost.tsx
Normal 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
|
||||||
@@ -22,6 +22,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
|||||||
import { AxiosError } from 'axios'
|
import { AxiosError } from 'axios'
|
||||||
import api, { buyStockV2, sellStockV2, resetPendingTradesV2 } from '../lib/api'
|
import api, { buyStockV2, sellStockV2, resetPendingTradesV2 } from '../lib/api'
|
||||||
import { useTenantMode } from '../lib/tenant'
|
import { useTenantMode } from '../lib/tenant'
|
||||||
|
import { toast } from '../lib/toast'
|
||||||
|
|
||||||
/** 계좌 미등록/미검증(400/423) 여부를 표시하는 확장 에러 타입 */
|
/** 계좌 미등록/미검증(400/423) 여부를 표시하는 확장 에러 타입 */
|
||||||
export type TradeActionError = AxiosError<{ detail?: string }> & { isAccountSetupRequired?: boolean }
|
export type TradeActionError = AxiosError<{ detail?: string }> & { isAccountSetupRequired?: boolean }
|
||||||
@@ -58,11 +59,14 @@ export const useTradeActions = () => {
|
|||||||
// KIS 모의서버 지연 시 주문 1건에 30~60초 걸릴 수 있음 — 기본 15초로 끊으면
|
// KIS 모의서버 지연 시 주문 1건에 30~60초 걸릴 수 있음 — 기본 15초로 끊으면
|
||||||
// 서버는 체결하는데 화면은 실패로 보여 중복 주문을 유발함
|
// 서버는 체결하는데 화면은 실패로 보여 중복 주문을 유발함
|
||||||
const body = { stock_code: stockCode, quantity, price: null }
|
const body = { stock_code: stockCode, quantity, price: null }
|
||||||
if (mode === 'v2') {
|
const res = mode === 'v2'
|
||||||
await sellStockV2(body)
|
? await sellStockV2(body)
|
||||||
} else {
|
: await api.post('/portfolio/sell', body, { timeout: 90000 })
|
||||||
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
|
// Invalidate queries to refresh portfolio
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -79,6 +83,9 @@ export const useTradeActions = () => {
|
|||||||
`❌ Sell order failed for ${stockName}:`,
|
`❌ Sell order failed for ${stockName}:`,
|
||||||
err.response?.data?.detail || err.message
|
err.response?.data?.detail || err.message
|
||||||
)
|
)
|
||||||
|
if (!err.isAccountSetupRequired) {
|
||||||
|
toast.error(`${stockName} 매도 실패: ${err.response?.data?.detail || err.message}`)
|
||||||
|
}
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -91,11 +98,11 @@ export const useTradeActions = () => {
|
|||||||
if (mode === undefined) throw new TenantModeNotReadyError()
|
if (mode === undefined) throw new TenantModeNotReadyError()
|
||||||
try {
|
try {
|
||||||
const body = { stock_code: stockCode, stock_name: stockName, quantity: null, price: null }
|
const body = { stock_code: stockCode, stock_name: stockName, quantity: null, price: null }
|
||||||
if (mode === 'v2') {
|
const res = mode === 'v2'
|
||||||
await buyStockV2(body)
|
? await buyStockV2(body)
|
||||||
} else {
|
: await api.post('/portfolio/buy', body, { timeout: 90000 })
|
||||||
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: ['portfolio-combined'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['trade-history'] })
|
queryClient.invalidateQueries({ queryKey: ['trade-history'] })
|
||||||
console.log(`✅ ${stockName} buy order completed`)
|
console.log(`✅ ${stockName} buy order completed`)
|
||||||
@@ -105,6 +112,9 @@ export const useTradeActions = () => {
|
|||||||
`❌ Buy order failed for ${stockName}:`,
|
`❌ Buy order failed for ${stockName}:`,
|
||||||
err.response?.data?.detail || err.message
|
err.response?.data?.detail || err.message
|
||||||
)
|
)
|
||||||
|
if (!err.isAccountSetupRequired) {
|
||||||
|
toast.error(`${stockName} 매수 실패: ${err.response?.data?.detail || err.message}`)
|
||||||
|
}
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
23
frontend/src/lib/toast.ts
Normal file
23
frontend/src/lib/toast.ts
Normal 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),
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user