"use client"; import { useEffect, useState } from "react"; import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { fetchOrderableCashEstimate } from "@/features/trade/apis/kis-stock.api"; import { useOrder } from "@/features/trade/hooks/useOrder"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; import type { DashboardOrderSide, DashboardStockItem, } from "@/features/trade/types/trade.types"; import { parseKisAccountParts } from "@/lib/kis/account"; import { cn } from "@/lib/utils"; interface OrderFormProps { stock?: DashboardStockItem; matchedHolding?: DashboardHoldingItem | null; availableCashBalance?: number | null; } /** * @description 대시보드 주문 패널에서 매수/매도 주문을 입력하고 전송합니다. * @see features/trade/hooks/useOrder.ts - placeOrder 주문 API 호출 * @see features/trade/components/layout/TradeDashboardContent.tsx - orderForm prop으로 DashboardLayout에 전달 */ export function OrderForm({ stock, matchedHolding, availableCashBalance = null, }: OrderFormProps) { const verifiedCredentials = useKisRuntimeStore( (state) => state.verifiedCredentials, ); const { placeOrder, isLoading, error } = useOrder(); // ========== FORM STATE ========== const [price, setPrice] = useState( stock?.currentPrice.toString() || "", ); const [quantity, setQuantity] = useState(""); const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy"); const [orderableCash, setOrderableCash] = useState(null); const [isOrderableCashLoading, setIsOrderableCashLoading] = useState(false); const stockSymbol = stock?.symbol ?? null; const sellableQuantity = matchedHolding?.sellableQuantity ?? 0; const hasSellableQuantity = sellableQuantity > 0; const effectiveOrderableCash = orderableCash ?? availableCashBalance ?? null; // [Effect] 종목/가격 변경 시 매수가능금액(주문가능 예수금)을 다시 조회합니다. useEffect(() => { if (activeTab !== "buy") return; if (!stockSymbol || !verifiedCredentials) { const resetTimerId = window.setTimeout(() => { setOrderableCash(null); setIsOrderableCashLoading(false); }, 0); return () => { window.clearTimeout(resetTimerId); }; } const priceNum = parseInt(price.replace(/,/g, ""), 10); if (Number.isNaN(priceNum) || priceNum <= 0) { const resetTimerId = window.setTimeout(() => { setOrderableCash(null); setIsOrderableCashLoading(false); }, 0); return () => { window.clearTimeout(resetTimerId); }; } let cancelled = false; const timerId = window.setTimeout(() => { setIsOrderableCashLoading(true); void fetchOrderableCashEstimate( { symbol: stockSymbol, price: priceNum, orderType: "limit", }, verifiedCredentials, ) .then((response) => { if (cancelled) return; setOrderableCash(Math.max(0, Math.floor(response.orderableCash))); }) .catch(() => { if (cancelled) return; // 조회 실패 시 대시보드 예수금 스냅샷을 fallback으로 사용합니다. setOrderableCash(null); }) .finally(() => { if (cancelled) return; setIsOrderableCashLoading(false); }); }, 250); return () => { cancelled = true; window.clearTimeout(timerId); }; }, [activeTab, stockSymbol, verifiedCredentials, price]); // ========== ORDER HANDLER ========== /** * UI 흐름: 매수하기/매도하기 버튼 클릭 -> handleOrder -> placeOrder API 호출 -> 주문번호 반환 -> alert */ const handleOrder = async (side: DashboardOrderSide) => { if (!stock || !verifiedCredentials) return; const priceNum = parseInt(price.replace(/,/g, ""), 10); const qtyNum = parseInt(quantity.replace(/,/g, ""), 10); if (Number.isNaN(priceNum) || priceNum <= 0) { alert("가격을 올바르게 입력해 주세요."); return; } if (Number.isNaN(qtyNum) || qtyNum <= 0) { alert("수량을 올바르게 입력해 주세요."); return; } if (side === "buy" && effectiveOrderableCash !== null) { const requestedAmount = priceNum * qtyNum; if (requestedAmount > effectiveOrderableCash) { alert( `주문가능 예수금(${effectiveOrderableCash.toLocaleString("ko-KR")}원)을 초과했습니다.`, ); return; } } if (side === "sell") { if (!matchedHolding) { alert("보유 종목 정보가 없어 매도 주문을 진행할 수 없습니다."); return; } if (sellableQuantity <= 0) { alert("매도가능수량이 0주입니다. 체결/정산 상태를 확인해 주세요."); return; } if (qtyNum > sellableQuantity) { alert(`매도가능수량(${sellableQuantity.toLocaleString("ko-KR")}주)을 초과했습니다.`); return; } } if (!verifiedCredentials.accountNo) { alert("계좌번호가 없습니다. 설정에서 계좌번호를 입력해 주세요."); return; } const accountParts = parseKisAccountParts(verifiedCredentials.accountNo); if (!accountParts) { alert( "계좌번호 형식이 올바르지 않습니다. 설정에서 8-2 형식(예: 12345678-01)으로 다시 확인해 주세요.", ); return; } const response = await placeOrder( { symbol: stock.symbol, side, orderType: "limit", price: priceNum, quantity: qtyNum, accountNo: `${accountParts.accountNo}-${accountParts.accountProductCode}`, accountProductCode: accountParts.accountProductCode, }, verifiedCredentials, ); if (response?.orderNo) { alert(`주문 전송 완료: ${response.orderNo}`); setQuantity(""); } }; const totalPrice = parseInt(price.replace(/,/g, "") || "0", 10) * parseInt(quantity.replace(/,/g, "") || "0", 10); const setPercent = (pct: string) => { const ratio = Number.parseInt(pct.replace("%", ""), 10) / 100; if (!Number.isFinite(ratio) || ratio <= 0) return; // UI 흐름: 비율 버튼 클릭 -> 주문가능 예수금 기준 계산(매수 탭) -> 주문수량 입력값 반영 if (activeTab === "buy") { const priceNum = parseInt(price.replace(/,/g, ""), 10); if (Number.isNaN(priceNum) || priceNum <= 0) { alert("가격을 먼저 입력해 주세요."); return; } if (effectiveOrderableCash === null || effectiveOrderableCash <= 0) { alert("주문가능 예수금을 확인할 수 없어 비율 계산을 할 수 없습니다."); return; } const calculatedQuantity = Math.floor((effectiveOrderableCash * ratio) / priceNum); if (calculatedQuantity <= 0) { alert("선택한 비율로 주문 가능한 수량이 없습니다. 가격 또는 비율을 조정해 주세요."); return; } setQuantity(String(calculatedQuantity)); return; } // UI 흐름: 비율 버튼 클릭 -> 보유수량 기준 계산(매도 탭) -> 주문수량 입력값 반영 if (activeTab === "sell" && hasSellableQuantity) { const calculatedQuantity = Math.max( 1, Math.floor(sellableQuantity * ratio), ); setQuantity(String(calculatedQuantity)); } }; const isMarketDataAvailable = Boolean(stock); const isBuy = activeTab === "buy"; const buyOrderableValue = isOrderableCashLoading ? "조회 중..." : effectiveOrderableCash === null ? "- KRW" : `${effectiveOrderableCash.toLocaleString("ko-KR")}원`; return (
setActiveTab(value as "buy" | "sell")} className="flex h-full w-full flex-col" > {/* ========== ORDER SIDE TABS ========== */} 매수 매도 {/* ========== CURRENT PRICE INFO ========== */} {stock && (
현재가 {stock.currentPrice.toLocaleString()}원
)} {/* ========== BUY TAB ========== */}

비율 버튼은 주문가능 예수금 기준으로 매수 수량을 계산합니다.

{!matchedHolding && (

현재 선택한 종목의 보유 수량이 없어 매도 주문을 보낼 수 없습니다.

)}
{/* ========== SELL TAB ========== */}
); } /** * @description 주문 입력 영역(가격/수량/총액)을 렌더링합니다. * @see features/trade/components/order/OrderForm.tsx - OrderForm 매수/매도 탭에서 공용 호출 */ function OrderInputs({ type, price, setPrice, quantity, setQuantity, totalPrice, disabled, hasError, errorMessage, orderableValue, }: { type: "buy" | "sell"; price: string; setPrice: (v: string) => void; quantity: string; setQuantity: (v: string) => void; totalPrice: number; disabled: boolean; hasError: boolean; errorMessage: string | null; orderableValue: string; }) { const labelClass = "text-xs font-medium text-foreground/80 dark:text-brand-100/80 min-w-[56px]"; const inputClass = "col-span-3 h-9 text-right font-mono text-sm dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100"; return (
{/* 주문 가능 */}
주문가능 {orderableValue}
{hasError && (
{errorMessage}
)} {/* 가격 입력 */}
{type === "buy" ? "매수가격" : "매도가격"} setPrice(e.target.value)} disabled={disabled} />
{/* 수량 입력 */}
주문수량 setQuantity(e.target.value)} disabled={disabled} />
{/* 총액 */}
주문총액 0 ? `${totalPrice.toLocaleString()}원` : ""} readOnly disabled={disabled} placeholder="0원" />
); } /** * @description 주문 비율(10/25/50/100%) 단축 버튼을 표시합니다. * @see features/trade/components/order/OrderForm.tsx - OrderForm setPercent 이벤트 처리 */ function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) { return (
{["10%", "25%", "50%", "100%"].map((pct) => ( ))}
); } /** * @description 선택 종목이 보유 상태일 때 주문 패널 하단에 보유 요약을 표시합니다. * @summary UI 흐름: TradeContainer(matchedHolding 계산) -> TradeDashboardContent -> OrderForm -> HoldingInfoPanel 렌더링 * @see features/trade/components/TradeContainer.tsx - selectedSymbol 기준으로 보유종목 매칭 값을 전달합니다. */ function HoldingInfoPanel({ holding, }: { holding?: DashboardHoldingItem | null; }) { if (!holding) return null; const profitToneClass = getHoldingProfitToneClass(holding.profitLoss); return (

보유 정보

= 0 ? "+" : ""}${holding.profitLoss.toLocaleString("ko-KR")}원`} toneClass={profitToneClass} /> = 0 ? "+" : ""}${holding.profitRate.toFixed(2)}%`} toneClass={profitToneClass} />
); } /** * @description 보유정보 카드의 단일 라벨/값 행을 렌더링합니다. * @see features/trade/components/order/OrderForm.tsx HoldingInfoPanel */ function HoldingInfoRow({ label, value, toneClass, }: { label: string; value: string; toneClass?: string; }) { return (
{label} {value}
); } /** * @description 보유 손익 부호에 따른 색상 클래스를 반환합니다. * @see features/trade/components/order/OrderForm.tsx HoldingInfoPanel */ function getHoldingProfitToneClass(value: number) { if (value > 0) return "text-red-500 dark:text-red-400"; if (value < 0) return "text-blue-600 dark:text-blue-400"; return "text-foreground dark:text-brand-50"; }