전체적인 리팩토링
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
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 {
|
||||
@@ -18,6 +19,7 @@ import { cn } from "@/lib/utils";
|
||||
interface OrderFormProps {
|
||||
stock?: DashboardStockItem;
|
||||
matchedHolding?: DashboardHoldingItem | null;
|
||||
availableCashBalance?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,7 +27,11 @@ interface OrderFormProps {
|
||||
* @see features/trade/hooks/useOrder.ts - placeOrder 주문 API 호출
|
||||
* @see features/trade/components/layout/TradeDashboardContent.tsx - orderForm prop으로 DashboardLayout에 전달
|
||||
*/
|
||||
export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
export function OrderForm({
|
||||
stock,
|
||||
matchedHolding,
|
||||
availableCashBalance = null,
|
||||
}: OrderFormProps) {
|
||||
const verifiedCredentials = useKisRuntimeStore(
|
||||
(state) => state.verifiedCredentials,
|
||||
);
|
||||
@@ -37,6 +43,69 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
);
|
||||
const [quantity, setQuantity] = useState<string>("");
|
||||
const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy");
|
||||
const [orderableCash, setOrderableCash] = useState<number | null>(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 ==========
|
||||
/**
|
||||
@@ -56,6 +125,31 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
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;
|
||||
@@ -96,11 +190,34 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
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" && matchedHolding?.quantity) {
|
||||
if (activeTab === "sell" && hasSellableQuantity) {
|
||||
const calculatedQuantity = Math.max(
|
||||
1,
|
||||
Math.floor(matchedHolding.quantity * ratio),
|
||||
Math.floor(sellableQuantity * ratio),
|
||||
);
|
||||
setQuantity(String(calculatedQuantity));
|
||||
}
|
||||
@@ -108,6 +225,12 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
|
||||
const isMarketDataAvailable = Boolean(stock);
|
||||
const isBuy = activeTab === "buy";
|
||||
const buyOrderableValue =
|
||||
isOrderableCashLoading
|
||||
? "조회 중..."
|
||||
: effectiveOrderableCash === null
|
||||
? "- KRW"
|
||||
: `${effectiveOrderableCash.toLocaleString("ko-KR")}원`;
|
||||
|
||||
return (
|
||||
<div className="h-full bg-background p-3 dark:bg-brand-950/55 sm:p-4">
|
||||
@@ -179,9 +302,18 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
disabled={!isMarketDataAvailable}
|
||||
hasError={Boolean(error)}
|
||||
errorMessage={error}
|
||||
orderableValue={buyOrderableValue}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground dark:text-brand-100/65">
|
||||
비율 버튼은 주문가능 예수금 기준으로 매수 수량을 계산합니다.
|
||||
</p>
|
||||
<PercentButtons onSelect={setPercent} />
|
||||
<div className="mt-auto space-y-2.5 sm:space-y-3">
|
||||
{!matchedHolding && (
|
||||
<p className="text-xs text-muted-foreground dark:text-brand-100/70">
|
||||
현재 선택한 종목의 보유 수량이 없어 매도 주문을 보낼 수 없습니다.
|
||||
</p>
|
||||
)}
|
||||
<HoldingInfoPanel holding={matchedHolding} />
|
||||
<Button
|
||||
className="h-11 w-full rounded-lg bg-red-600 text-base font-bold text-white shadow-[0_4px_14px_rgba(220,38,38,0.4)] ring-1 ring-red-300/30 transition-all hover:bg-red-700 hover:shadow-[0_4px_20px_rgba(220,38,38,0.5)] dark:bg-red-500 dark:ring-red-300/40 dark:hover:bg-red-400 sm:h-12 sm:text-lg"
|
||||
@@ -212,19 +344,29 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
disabled={!isMarketDataAvailable}
|
||||
hasError={Boolean(error)}
|
||||
errorMessage={error}
|
||||
orderableValue={
|
||||
matchedHolding
|
||||
? `${sellableQuantity.toLocaleString("ko-KR")}주`
|
||||
: "- 주"
|
||||
}
|
||||
/>
|
||||
<PercentButtons onSelect={setPercent} />
|
||||
<div className="mt-auto space-y-2.5 sm:space-y-3">
|
||||
<HoldingInfoPanel holding={matchedHolding} />
|
||||
<Button
|
||||
className="h-11 w-full rounded-lg bg-blue-600 text-base font-bold text-white shadow-[0_4px_14px_rgba(37,99,235,0.4)] ring-1 ring-blue-300/30 transition-all hover:bg-blue-700 hover:shadow-[0_4px_20px_rgba(37,99,235,0.5)] dark:bg-blue-500 dark:ring-blue-300/40 dark:hover:bg-blue-400 sm:h-12 sm:text-lg"
|
||||
disabled={isLoading || !isMarketDataAvailable}
|
||||
disabled={
|
||||
isLoading ||
|
||||
!isMarketDataAvailable ||
|
||||
!matchedHolding ||
|
||||
!hasSellableQuantity
|
||||
}
|
||||
onClick={() => handleOrder("sell")}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 animate-spin" />
|
||||
) : (
|
||||
"매도하기"
|
||||
hasSellableQuantity ? "매도하기" : "매도가능수량 없음"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -248,6 +390,7 @@ function OrderInputs({
|
||||
disabled,
|
||||
hasError,
|
||||
errorMessage,
|
||||
orderableValue,
|
||||
}: {
|
||||
type: "buy" | "sell";
|
||||
price: string;
|
||||
@@ -258,6 +401,7 @@ function OrderInputs({
|
||||
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]";
|
||||
@@ -272,7 +416,7 @@ function OrderInputs({
|
||||
주문가능
|
||||
</span>
|
||||
<span className="font-medium text-foreground dark:text-brand-50">
|
||||
- {type === "buy" ? "KRW" : "주"}
|
||||
{orderableValue}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -366,6 +510,10 @@ function HoldingInfoPanel({
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
|
||||
<HoldingInfoRow label="보유수량" value={`${holding.quantity.toLocaleString("ko-KR")}주`} />
|
||||
<HoldingInfoRow
|
||||
label="매도가능수량"
|
||||
value={`${holding.sellableQuantity.toLocaleString("ko-KR")}주`}
|
||||
/>
|
||||
<HoldingInfoRow
|
||||
label="평균단가"
|
||||
value={`${holding.averagePrice.toLocaleString("ko-KR")}원`}
|
||||
|
||||
Reference in New Issue
Block a user