2026-02-10 11:16:39 +09:00
|
|
|
import { useState } from "react";
|
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-02-11 14:06:06 +09:00
|
|
|
import { useOrder } from "@/features/dashboard/hooks/useOrder";
|
|
|
|
|
import { useKisRuntimeStore } from "@/features/dashboard/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-10 11:16:39 +09:00
|
|
|
} from "@/features/dashboard/types/dashboard.types";
|
|
|
|
|
|
|
|
|
|
interface OrderFormProps {
|
|
|
|
|
stock?: DashboardStockItem;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 14:06:06 +09:00
|
|
|
/**
|
|
|
|
|
* @description 대시보드 주문 패널에서 매수/매도 주문을 입력하고 전송합니다.
|
|
|
|
|
* @see features/dashboard/hooks/useOrder.ts placeOrder - 주문 API 호출
|
|
|
|
|
* @see features/dashboard/components/DashboardContainer.tsx OrderForm - 우측 주문 패널 렌더링
|
|
|
|
|
*/
|
2026-02-10 11:16:39 +09:00
|
|
|
export function OrderForm({ stock }: OrderFormProps) {
|
|
|
|
|
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-02-10 11:16:39 +09:00
|
|
|
|
2026-02-11 14:06:06 +09:00
|
|
|
// ========== ORDER HANDLER ==========
|
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;
|
|
|
|
|
}
|
|
|
|
|
if (!verifiedCredentials.accountNo) {
|
2026-02-11 14:06:06 +09:00
|
|
|
alert("계좌번호가 없습니다. 설정에서 계좌번호를 입력해 주세요.");
|
2026-02-10 11:16:39 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
accountNo: verifiedCredentials.accountNo,
|
2026-02-11 14:06:06 +09:00
|
|
|
accountProductCode: "01",
|
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-11 14:06:06 +09:00
|
|
|
// TODO: 계좌 잔고 연동 시 퍼센트 자동 계산으로 교체
|
2026-02-10 11:16:39 +09:00
|
|
|
console.log("Percent clicked:", pct);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-11 14:06:06 +09:00
|
|
|
const isMarketDataAvailable = Boolean(stock);
|
2026-02-10 11:16:39 +09:00
|
|
|
|
|
|
|
|
return (
|
2026-02-11 14:06:06 +09:00
|
|
|
<div className="h-full border-l border-border bg-background p-3 dark:border-brand-800/45 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-1 border border-brand-200/70 bg-muted/35 p-1 dark:border-brand-700/50 dark:bg-brand-900/28 sm:mb-4">
|
2026-02-10 11:16:39 +09:00
|
|
|
<TabsTrigger
|
|
|
|
|
value="buy"
|
2026-02-11 14:06:06 +09:00
|
|
|
className="!h-full rounded-md border border-transparent text-foreground/75 transition-colors dark:text-brand-100/75 data-[state=active]:border-red-400/60 data-[state=active]:bg-red-600 data-[state=active]:text-white data-[state=active]:shadow-[0_0_0_1px_rgba(248,113,113,0.45)]"
|
2026-02-10 11:16:39 +09:00
|
|
|
>
|
|
|
|
|
매수
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
<TabsTrigger
|
|
|
|
|
value="sell"
|
2026-02-11 14:06:06 +09:00
|
|
|
className="!h-full rounded-md border border-transparent text-foreground/75 transition-colors dark:text-brand-100/75 data-[state=active]:border-blue-400/65 data-[state=active]:bg-blue-600 data-[state=active]:text-white data-[state=active]:shadow-[0_0_0_1px_rgba(96,165,250,0.45)]"
|
2026-02-10 11:16:39 +09:00
|
|
|
>
|
|
|
|
|
매도
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
</TabsList>
|
|
|
|
|
|
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-3 data-[state=inactive]:hidden sm:space-y-4"
|
|
|
|
|
>
|
|
|
|
|
<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}
|
|
|
|
|
/>
|
|
|
|
|
<PercentButtons onSelect={setPercent} />
|
|
|
|
|
<Button
|
2026-02-11 14:06:06 +09:00
|
|
|
className="mt-auto h-11 w-full bg-red-600 text-base text-white shadow-sm ring-1 ring-red-300/35 hover:bg-red-700 dark:bg-red-500 dark:ring-red-300/45 dark:hover:bg-red-400 sm:h-12 sm:text-lg"
|
2026-02-10 11:16:39 +09:00
|
|
|
disabled={isLoading || !isMarketDataAvailable}
|
|
|
|
|
onClick={() => handleOrder("buy")}
|
|
|
|
|
>
|
2026-02-11 14:06:06 +09:00
|
|
|
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매수하기"}
|
2026-02-10 11:16:39 +09:00
|
|
|
</Button>
|
|
|
|
|
</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-3 data-[state=inactive]:hidden sm:space-y-4"
|
|
|
|
|
>
|
|
|
|
|
<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}
|
|
|
|
|
/>
|
|
|
|
|
<PercentButtons onSelect={setPercent} />
|
|
|
|
|
<Button
|
2026-02-11 14:06:06 +09:00
|
|
|
className="mt-auto h-11 w-full bg-blue-600 text-base text-white shadow-sm ring-1 ring-blue-300/35 hover:bg-blue-700 dark:bg-blue-500 dark:ring-blue-300/45 dark:hover:bg-blue-400 sm:h-12 sm:text-lg"
|
2026-02-10 11:16:39 +09:00
|
|
|
disabled={isLoading || !isMarketDataAvailable}
|
|
|
|
|
onClick={() => handleOrder("sell")}
|
|
|
|
|
>
|
2026-02-11 14:06:06 +09:00
|
|
|
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매도하기"}
|
2026-02-10 11:16:39 +09:00
|
|
|
</Button>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
</Tabs>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 14:06:06 +09:00
|
|
|
/**
|
|
|
|
|
* @description 주문 입력 영역(가격/수량/총액)을 렌더링합니다.
|
|
|
|
|
* @see features/dashboard/components/order/OrderForm.tsx OrderForm - 매수/매도 탭에서 공용 호출
|
|
|
|
|
*/
|
2026-02-10 11:16:39 +09:00
|
|
|
function OrderInputs({
|
|
|
|
|
type,
|
|
|
|
|
price,
|
|
|
|
|
setPrice,
|
|
|
|
|
quantity,
|
|
|
|
|
setQuantity,
|
|
|
|
|
totalPrice,
|
|
|
|
|
disabled,
|
|
|
|
|
hasError,
|
|
|
|
|
errorMessage,
|
|
|
|
|
}: {
|
|
|
|
|
type: "buy" | "sell";
|
|
|
|
|
price: string;
|
|
|
|
|
setPrice: (v: string) => void;
|
|
|
|
|
quantity: string;
|
|
|
|
|
setQuantity: (v: string) => void;
|
|
|
|
|
totalPrice: number;
|
|
|
|
|
disabled: boolean;
|
|
|
|
|
hasError: boolean;
|
|
|
|
|
errorMessage: string | null;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-3 sm:space-y-4">
|
|
|
|
|
<div className="flex justify-between text-xs text-muted-foreground">
|
|
|
|
|
<span>주문가능</span>
|
|
|
|
|
<span>- {type === "buy" ? "KRW" : "주"}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{hasError && (
|
2026-02-11 14:06:06 +09:00
|
|
|
<div className="rounded bg-destructive/10 p-2 text-xs text-destructive break-keep">
|
2026-02-10 11:16:39 +09:00
|
|
|
{errorMessage}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-4 items-center gap-2">
|
|
|
|
|
<span className="text-xs font-medium sm:text-sm">
|
|
|
|
|
{type === "buy" ? "매수가격" : "매도가격"}
|
|
|
|
|
</span>
|
|
|
|
|
<Input
|
2026-02-11 14:06:06 +09:00
|
|
|
className="col-span-3 text-right font-mono dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100"
|
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="text-xs font-medium sm:text-sm">주문수량</span>
|
|
|
|
|
<Input
|
2026-02-11 14:06:06 +09:00
|
|
|
className="col-span-3 text-right font-mono dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100"
|
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="text-xs font-medium sm:text-sm">주문총액</span>
|
|
|
|
|
<Input
|
2026-02-11 14:06:06 +09:00
|
|
|
className="col-span-3 bg-muted/50 text-right font-mono dark:border-brand-700/55 dark:bg-black/20 dark:text-brand-100"
|
2026-02-10 11:16:39 +09:00
|
|
|
value={totalPrice.toLocaleString()}
|
|
|
|
|
readOnly
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 14:06:06 +09:00
|
|
|
/**
|
|
|
|
|
* @description 주문 비율(10/25/50/100%) 단축 버튼을 표시합니다.
|
|
|
|
|
* @see features/dashboard/components/order/OrderForm.tsx setPercent - 버튼 클릭 이벤트 처리
|
|
|
|
|
*/
|
2026-02-10 11:16:39 +09:00
|
|
|
function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
|
|
|
|
|
return (
|
2026-02-11 14:06:06 +09:00
|
|
|
<div className="mt-2 grid grid-cols-4 gap-2">
|
2026-02-10 11:16:39 +09:00
|
|
|
{["10%", "25%", "50%", "100%"].map((pct) => (
|
|
|
|
|
<Button
|
|
|
|
|
key={pct}
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="text-xs"
|
|
|
|
|
onClick={() => onSelect(pct)}
|
|
|
|
|
>
|
|
|
|
|
{pct}
|
|
|
|
|
</Button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|