Files
auto-trade/features/trade/components/order/OrderForm.tsx

572 lines
20 KiB
TypeScript
Raw Normal View History

"use client";
2026-03-12 09:26:27 +09:00
import { useEffect, useState } from "react";
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
2026-02-11 14:06:06 +09:00
import { Loader2 } from "lucide-react";
2026-02-10 11:16:39 +09:00
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
2026-03-12 09:26:27 +09:00
import { fetchOrderableCashEstimate } from "@/features/trade/apis/kis-stock.api";
2026-02-11 16:31:28 +09:00
import { useOrder } from "@/features/trade/hooks/useOrder";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
2026-02-10 11:16:39 +09:00
import type {
DashboardOrderSide,
2026-02-11 14:06:06 +09:00
DashboardStockItem,
2026-02-11 16:31:28 +09:00
} from "@/features/trade/types/trade.types";
2026-02-26 09:05:17 +09:00
import { parseKisAccountParts } from "@/lib/kis/account";
import { cn } from "@/lib/utils";
2026-02-10 11:16:39 +09:00
interface OrderFormProps {
stock?: DashboardStockItem;
matchedHolding?: DashboardHoldingItem | null;
2026-03-12 09:26:27 +09:00
availableCashBalance?: number | null;
2026-02-10 11:16:39 +09:00
}
2026-02-11 14:06:06 +09:00
/**
* @description / .
* @see features/trade/hooks/useOrder.ts - placeOrder API
* @see features/trade/components/layout/TradeDashboardContent.tsx - orderForm prop으로 DashboardLayout에
2026-02-11 14:06:06 +09:00
*/
2026-03-12 09:26:27 +09:00
export function OrderForm({
stock,
matchedHolding,
availableCashBalance = null,
}: OrderFormProps) {
2026-02-10 11:16:39 +09:00
const verifiedCredentials = useKisRuntimeStore(
(state) => state.verifiedCredentials,
);
const { placeOrder, isLoading, error } = useOrder();
2026-02-11 14:06:06 +09:00
// ========== FORM STATE ==========
const [price, setPrice] = useState<string>(
stock?.currentPrice.toString() || "",
);
2026-02-10 11:16:39 +09:00
const [quantity, setQuantity] = useState<string>("");
2026-02-11 14:06:06 +09:00
const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy");
2026-03-12 09:26:27 +09:00
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]);
2026-02-10 11:16:39 +09:00
2026-02-11 14:06:06 +09:00
// ========== ORDER HANDLER ==========
/**
* UI 흐름: 매수하기/ -> handleOrder -> placeOrder API -> -> alert
*/
2026-02-10 11:16:39 +09:00
const handleOrder = async (side: DashboardOrderSide) => {
if (!stock || !verifiedCredentials) return;
const priceNum = parseInt(price.replace(/,/g, ""), 10);
const qtyNum = parseInt(quantity.replace(/,/g, ""), 10);
2026-02-11 14:06:06 +09:00
if (Number.isNaN(priceNum) || priceNum <= 0) {
alert("가격을 올바르게 입력해 주세요.");
2026-02-10 11:16:39 +09:00
return;
}
2026-02-11 14:06:06 +09:00
if (Number.isNaN(qtyNum) || qtyNum <= 0) {
alert("수량을 올바르게 입력해 주세요.");
2026-02-10 11:16:39 +09:00
return;
}
2026-03-12 09:26:27 +09:00
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;
}
}
2026-02-10 11:16:39 +09:00
if (!verifiedCredentials.accountNo) {
2026-02-11 14:06:06 +09:00
alert("계좌번호가 없습니다. 설정에서 계좌번호를 입력해 주세요.");
2026-02-10 11:16:39 +09:00
return;
}
2026-02-26 09:05:17 +09:00
const accountParts = parseKisAccountParts(verifiedCredentials.accountNo);
if (!accountParts) {
alert(
"계좌번호 형식이 올바르지 않습니다. 설정에서 8-2 형식(예: 12345678-01)으로 다시 확인해 주세요.",
);
return;
}
2026-02-10 11:16:39 +09:00
const response = await placeOrder(
{
symbol: stock.symbol,
2026-02-11 14:06:06 +09:00
side,
orderType: "limit",
2026-02-10 11:16:39 +09:00
price: priceNum,
quantity: qtyNum,
2026-02-26 09:05:17 +09:00
accountNo: `${accountParts.accountNo}-${accountParts.accountProductCode}`,
accountProductCode: accountParts.accountProductCode,
2026-02-10 11:16:39 +09:00
},
verifiedCredentials,
);
2026-02-11 14:06:06 +09:00
if (response?.orderNo) {
alert(`주문 전송 완료: ${response.orderNo}`);
2026-02-10 11:16:39 +09:00
setQuantity("");
}
};
const totalPrice =
parseInt(price.replace(/,/g, "") || "0", 10) *
parseInt(quantity.replace(/,/g, "") || "0", 10);
const setPercent = (pct: string) => {
2026-02-26 09:05:17 +09:00
const ratio = Number.parseInt(pct.replace("%", ""), 10) / 100;
if (!Number.isFinite(ratio) || ratio <= 0) return;
2026-03-12 09:26:27 +09:00
// 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;
}
2026-02-26 09:05:17 +09:00
// UI 흐름: 비율 버튼 클릭 -> 보유수량 기준 계산(매도 탭) -> 주문수량 입력값 반영
2026-03-12 09:26:27 +09:00
if (activeTab === "sell" && hasSellableQuantity) {
2026-02-26 09:05:17 +09:00
const calculatedQuantity = Math.max(
1,
2026-03-12 09:26:27 +09:00
Math.floor(sellableQuantity * ratio),
2026-02-26 09:05:17 +09:00
);
setQuantity(String(calculatedQuantity));
}
2026-02-10 11:16:39 +09:00
};
2026-02-11 14:06:06 +09:00
const isMarketDataAvailable = Boolean(stock);
const isBuy = activeTab === "buy";
2026-03-12 09:26:27 +09:00
const buyOrderableValue =
isOrderableCashLoading
? "조회 중..."
: effectiveOrderableCash === null
? "- KRW"
: `${effectiveOrderableCash.toLocaleString("ko-KR")}`;
2026-02-10 11:16:39 +09:00
return (
<div className="h-full bg-background p-3 dark:bg-brand-950/55 sm:p-4">
2026-02-10 11:16:39 +09:00
<Tabs
value={activeTab}
2026-02-11 14:06:06 +09:00
onValueChange={(value) => setActiveTab(value as "buy" | "sell")}
className="flex h-full w-full flex-col"
2026-02-10 11:16:39 +09:00
>
2026-02-11 14:06:06 +09:00
{/* ========== ORDER SIDE TABS ========== */}
<TabsList className="mb-3 grid h-10 w-full grid-cols-2 gap-0.5 rounded-lg border border-border/60 bg-muted/30 p-0.5 dark:border-brand-700/50 dark:bg-brand-900/25 sm:mb-4">
2026-02-10 11:16:39 +09:00
<TabsTrigger
value="buy"
className={cn(
"!h-full rounded-md border border-transparent text-sm font-semibold text-foreground/70 transition-all dark:text-brand-100/70",
"data-[state=active]:border-red-400/50 data-[state=active]:bg-red-600 data-[state=active]:text-white data-[state=active]:shadow-[0_2px_8px_rgba(220,38,38,0.4)]",
)}
2026-02-10 11:16:39 +09:00
>
</TabsTrigger>
<TabsTrigger
value="sell"
className={cn(
"!h-full rounded-md border border-transparent text-sm font-semibold text-foreground/70 transition-all dark:text-brand-100/70",
"data-[state=active]:border-blue-400/50 data-[state=active]:bg-blue-600 data-[state=active]:text-white data-[state=active]:shadow-[0_2px_8px_rgba(37,99,235,0.4)]",
)}
2026-02-10 11:16:39 +09:00
>
</TabsTrigger>
</TabsList>
{/* ========== CURRENT PRICE INFO ========== */}
{stock && (
<div
className={cn(
"mb-3 flex items-center justify-between rounded-md border px-3 py-2 text-xs",
isBuy
? "border-red-200/60 bg-red-50/50 dark:border-red-800/35 dark:bg-red-950/25"
: "border-blue-200/60 bg-blue-50/50 dark:border-blue-800/35 dark:bg-blue-950/25",
)}
>
<span className="text-muted-foreground dark:text-brand-100/65">
</span>
<span
className={cn(
"font-bold tabular-nums",
isBuy
? "text-red-600 dark:text-red-400"
: "text-blue-600 dark:text-blue-400",
)}
>
{stock.currentPrice.toLocaleString()}
</span>
</div>
)}
2026-02-11 14:06:06 +09:00
{/* ========== BUY TAB ========== */}
2026-02-10 11:16:39 +09:00
<TabsContent
value="buy"
className="flex flex-1 flex-col space-y-2.5 data-[state=inactive]:hidden sm:space-y-3"
2026-02-10 11:16:39 +09:00
>
<OrderInputs
type="buy"
price={price}
setPrice={setPrice}
quantity={quantity}
setQuantity={setQuantity}
totalPrice={totalPrice}
disabled={!isMarketDataAvailable}
2026-02-11 14:06:06 +09:00
hasError={Boolean(error)}
2026-02-10 11:16:39 +09:00
errorMessage={error}
2026-03-12 09:26:27 +09:00
orderableValue={buyOrderableValue}
2026-02-10 11:16:39 +09:00
/>
2026-03-12 09:26:27 +09:00
<p className="text-[11px] text-muted-foreground dark:text-brand-100/65">
.
</p>
2026-02-10 11:16:39 +09:00
<PercentButtons onSelect={setPercent} />
<div className="mt-auto space-y-2.5 sm:space-y-3">
2026-03-12 09:26:27 +09:00
{!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"
disabled={isLoading || !isMarketDataAvailable}
onClick={() => handleOrder("buy")}
>
{isLoading ? (
<Loader2 className="mr-2 animate-spin" />
) : (
"매수하기"
)}
</Button>
</div>
2026-02-10 11:16:39 +09:00
</TabsContent>
2026-02-11 14:06:06 +09:00
{/* ========== SELL TAB ========== */}
2026-02-10 11:16:39 +09:00
<TabsContent
value="sell"
className="flex flex-1 flex-col space-y-2.5 data-[state=inactive]:hidden sm:space-y-3"
2026-02-10 11:16:39 +09:00
>
<OrderInputs
type="sell"
price={price}
setPrice={setPrice}
quantity={quantity}
setQuantity={setQuantity}
totalPrice={totalPrice}
disabled={!isMarketDataAvailable}
2026-02-11 14:06:06 +09:00
hasError={Boolean(error)}
2026-02-10 11:16:39 +09:00
errorMessage={error}
2026-03-12 09:26:27 +09:00
orderableValue={
matchedHolding
? `${sellableQuantity.toLocaleString("ko-KR")}`
: "- 주"
}
2026-02-10 11:16:39 +09:00
/>
<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"
2026-03-12 09:26:27 +09:00
disabled={
isLoading ||
!isMarketDataAvailable ||
!matchedHolding ||
!hasSellableQuantity
}
onClick={() => handleOrder("sell")}
>
{isLoading ? (
<Loader2 className="mr-2 animate-spin" />
) : (
2026-03-12 09:26:27 +09:00
hasSellableQuantity ? "매도하기" : "매도가능수량 없음"
)}
</Button>
</div>
2026-02-10 11:16:39 +09:00
</TabsContent>
</Tabs>
</div>
);
}
2026-02-11 14:06:06 +09:00
/**
* @description (//) .
* @see features/trade/components/order/OrderForm.tsx - OrderForm /
2026-02-11 14:06:06 +09:00
*/
2026-02-10 11:16:39 +09:00
function OrderInputs({
type,
price,
setPrice,
quantity,
setQuantity,
totalPrice,
disabled,
hasError,
errorMessage,
2026-03-12 09:26:27 +09:00
orderableValue,
2026-02-10 11:16:39 +09:00
}: {
type: "buy" | "sell";
price: string;
setPrice: (v: string) => void;
quantity: string;
setQuantity: (v: string) => void;
totalPrice: number;
disabled: boolean;
hasError: boolean;
errorMessage: string | null;
2026-03-12 09:26:27 +09:00
orderableValue: string;
2026-02-10 11:16:39 +09:00
}) {
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";
2026-02-10 11:16:39 +09:00
return (
<div className="space-y-2 sm:space-y-2.5">
{/* 주문 가능 */}
<div className="flex items-center justify-between rounded-md bg-muted/30 px-3 py-1.5 text-xs dark:bg-brand-900/25">
<span className="text-muted-foreground dark:text-brand-100/60">
</span>
<span className="font-medium text-foreground dark:text-brand-50">
2026-03-12 09:26:27 +09:00
{orderableValue}
</span>
2026-02-10 11:16:39 +09:00
</div>
{hasError && (
<div className="rounded-md bg-destructive/10 p-2 text-xs text-destructive break-keep">
2026-02-10 11:16:39 +09:00
{errorMessage}
</div>
)}
{/* 가격 입력 */}
2026-02-10 11:16:39 +09:00
<div className="grid grid-cols-4 items-center gap-2">
<span className={labelClass}>
2026-02-10 11:16:39 +09:00
{type === "buy" ? "매수가격" : "매도가격"}
</span>
<Input
className={inputClass}
2026-02-10 11:16:39 +09:00
placeholder="0"
value={price}
onChange={(e) => setPrice(e.target.value)}
disabled={disabled}
/>
</div>
2026-02-11 14:06:06 +09:00
{/* 수량 입력 */}
2026-02-10 11:16:39 +09:00
<div className="grid grid-cols-4 items-center gap-2">
<span className={labelClass}></span>
2026-02-10 11:16:39 +09:00
<Input
className={inputClass}
2026-02-10 11:16:39 +09:00
placeholder="0"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
disabled={disabled}
/>
</div>
2026-02-11 14:06:06 +09:00
{/* 총액 */}
2026-02-10 11:16:39 +09:00
<div className="grid grid-cols-4 items-center gap-2">
<span className={labelClass}></span>
2026-02-10 11:16:39 +09:00
<Input
className={cn(inputClass, "bg-muted/40 dark:bg-black/20")}
value={totalPrice > 0 ? `${totalPrice.toLocaleString()}` : ""}
2026-02-10 11:16:39 +09:00
readOnly
disabled={disabled}
placeholder="0원"
2026-02-10 11:16:39 +09:00
/>
</div>
</div>
);
}
2026-02-11 14:06:06 +09:00
/**
* @description (10/25/50/100%) .
* @see features/trade/components/order/OrderForm.tsx - OrderForm setPercent
2026-02-11 14:06:06 +09:00
*/
2026-02-10 11:16:39 +09:00
function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
return (
<div className="grid grid-cols-4 gap-1.5">
2026-02-10 11:16:39 +09:00
{["10%", "25%", "50%", "100%"].map((pct) => (
<Button
key={pct}
variant="outline"
size="sm"
className="h-8 text-xs font-medium border-border/60 hover:border-brand-300 hover:bg-brand-50/50 hover:text-brand-700 dark:border-brand-700/50 dark:hover:border-brand-500 dark:hover:bg-brand-900/30 dark:hover:text-brand-200"
2026-02-10 11:16:39 +09:00
onClick={() => onSelect(pct)}
>
{pct}
</Button>
))}
</div>
);
}
/**
* @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 (
<div className="rounded-lg border border-border/65 bg-muted/20 p-3 dark:border-brand-700/45 dark:bg-brand-900/28">
<p className="mb-2 text-xs font-semibold text-foreground dark:text-brand-50">
</p>
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
<HoldingInfoRow label="보유수량" value={`${holding.quantity.toLocaleString("ko-KR")}`} />
2026-03-12 09:26:27 +09:00
<HoldingInfoRow
label="매도가능수량"
value={`${holding.sellableQuantity.toLocaleString("ko-KR")}`}
/>
<HoldingInfoRow
label="평균단가"
value={`${holding.averagePrice.toLocaleString("ko-KR")}`}
/>
<HoldingInfoRow
label="평가금액"
value={`${holding.evaluationAmount.toLocaleString("ko-KR")}`}
/>
<HoldingInfoRow
label="손익"
value={`${holding.profitLoss >= 0 ? "+" : ""}${holding.profitLoss.toLocaleString("ko-KR")}`}
toneClass={profitToneClass}
/>
<HoldingInfoRow
label="수익률"
value={`${holding.profitRate >= 0 ? "+" : ""}${holding.profitRate.toFixed(2)}%`}
toneClass={profitToneClass}
/>
</div>
</div>
);
}
/**
* @description / .
* @see features/trade/components/order/OrderForm.tsx HoldingInfoPanel
*/
function HoldingInfoRow({
label,
value,
toneClass,
}: {
label: string;
value: string;
toneClass?: string;
}) {
return (
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="text-muted-foreground dark:text-brand-100/70">{label}</span>
<span className={cn("truncate font-semibold tabular-nums text-foreground dark:text-brand-50", toneClass)}>
{value}
</span>
</div>
);
}
/**
* @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";
}