트레이딩창 UI 배치 및 UX 수정 및 기획서 추가
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { 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";
|
||||
@@ -9,28 +12,35 @@ import type {
|
||||
DashboardOrderSide,
|
||||
DashboardStockItem,
|
||||
} from "@/features/trade/types/trade.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OrderFormProps {
|
||||
stock?: DashboardStockItem;
|
||||
matchedHolding?: DashboardHoldingItem | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 대시보드 주문 패널에서 매수/매도 주문을 입력하고 전송합니다.
|
||||
* @see features/trade/hooks/useOrder.ts placeOrder - 주문 API 호출
|
||||
* @see features/trade/components/TradeContainer.tsx OrderForm - 우측 주문 패널 렌더링
|
||||
* @see features/trade/hooks/useOrder.ts - placeOrder 주문 API 호출
|
||||
* @see features/trade/components/layout/TradeDashboardContent.tsx - orderForm prop으로 DashboardLayout에 전달
|
||||
*/
|
||||
export function OrderForm({ stock }: OrderFormProps) {
|
||||
export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
const verifiedCredentials = useKisRuntimeStore(
|
||||
(state) => state.verifiedCredentials,
|
||||
);
|
||||
const { placeOrder, isLoading, error } = useOrder();
|
||||
|
||||
// ========== FORM STATE ==========
|
||||
const [price, setPrice] = useState<string>(stock?.currentPrice.toString() || "");
|
||||
const [price, setPrice] = useState<string>(
|
||||
stock?.currentPrice.toString() || "",
|
||||
);
|
||||
const [quantity, setQuantity] = useState<string>("");
|
||||
const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy");
|
||||
|
||||
// ========== ORDER HANDLER ==========
|
||||
/**
|
||||
* UI 흐름: 매수하기/매도하기 버튼 클릭 -> handleOrder -> placeOrder API 호출 -> 주문번호 반환 -> alert
|
||||
*/
|
||||
const handleOrder = async (side: DashboardOrderSide) => {
|
||||
if (!stock || !verifiedCredentials) return;
|
||||
|
||||
@@ -79,34 +89,67 @@ export function OrderForm({ stock }: OrderFormProps) {
|
||||
};
|
||||
|
||||
const isMarketDataAvailable = Boolean(stock);
|
||||
const isBuy = activeTab === "buy";
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="h-full bg-background p-3 dark:bg-brand-950/55 sm:p-4">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as "buy" | "sell")}
|
||||
className="flex h-full w-full flex-col"
|
||||
>
|
||||
{/* ========== 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">
|
||||
<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">
|
||||
<TabsTrigger
|
||||
value="buy"
|
||||
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)]"
|
||||
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)]",
|
||||
)}
|
||||
>
|
||||
매수
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="sell"
|
||||
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)]"
|
||||
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)]",
|
||||
)}
|
||||
>
|
||||
매도
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* ========== BUY TAB ========== */}
|
||||
<TabsContent
|
||||
value="buy"
|
||||
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
|
||||
className="flex flex-1 flex-col space-y-2.5 data-[state=inactive]:hidden sm:space-y-3"
|
||||
>
|
||||
<OrderInputs
|
||||
type="buy"
|
||||
@@ -120,19 +163,26 @@ export function OrderForm({ stock }: OrderFormProps) {
|
||||
errorMessage={error}
|
||||
/>
|
||||
<PercentButtons onSelect={setPercent} />
|
||||
<Button
|
||||
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"
|
||||
disabled={isLoading || !isMarketDataAvailable}
|
||||
onClick={() => handleOrder("buy")}
|
||||
>
|
||||
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매수하기"}
|
||||
</Button>
|
||||
<div className="mt-auto space-y-2.5 sm:space-y-3">
|
||||
<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>
|
||||
</TabsContent>
|
||||
|
||||
{/* ========== SELL TAB ========== */}
|
||||
<TabsContent
|
||||
value="sell"
|
||||
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
|
||||
className="flex flex-1 flex-col space-y-2.5 data-[state=inactive]:hidden sm:space-y-3"
|
||||
>
|
||||
<OrderInputs
|
||||
type="sell"
|
||||
@@ -146,13 +196,20 @@ export function OrderForm({ stock }: OrderFormProps) {
|
||||
errorMessage={error}
|
||||
/>
|
||||
<PercentButtons onSelect={setPercent} />
|
||||
<Button
|
||||
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"
|
||||
disabled={isLoading || !isMarketDataAvailable}
|
||||
onClick={() => handleOrder("sell")}
|
||||
>
|
||||
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매도하기"}
|
||||
</Button>
|
||||
<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}
|
||||
onClick={() => handleOrder("sell")}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 animate-spin" />
|
||||
) : (
|
||||
"매도하기"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -161,7 +218,7 @@ export function OrderForm({ stock }: OrderFormProps) {
|
||||
|
||||
/**
|
||||
* @description 주문 입력 영역(가격/수량/총액)을 렌더링합니다.
|
||||
* @see features/trade/components/order/OrderForm.tsx OrderForm - 매수/매도 탭에서 공용 호출
|
||||
* @see features/trade/components/order/OrderForm.tsx - OrderForm 매수/매도 탭에서 공용 호출
|
||||
*/
|
||||
function OrderInputs({
|
||||
type,
|
||||
@@ -184,25 +241,36 @@ function OrderInputs({
|
||||
hasError: boolean;
|
||||
errorMessage: string | null;
|
||||
}) {
|
||||
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 (
|
||||
<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 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">
|
||||
- {type === "buy" ? "KRW" : "주"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasError && (
|
||||
<div className="rounded bg-destructive/10 p-2 text-xs text-destructive break-keep">
|
||||
<div className="rounded-md bg-destructive/10 p-2 text-xs text-destructive break-keep">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 가격 입력 */}
|
||||
<div className="grid grid-cols-4 items-center gap-2">
|
||||
<span className="text-xs font-medium sm:text-sm">
|
||||
<span className={labelClass}>
|
||||
{type === "buy" ? "매수가격" : "매도가격"}
|
||||
</span>
|
||||
<Input
|
||||
className="col-span-3 text-right font-mono dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100"
|
||||
className={inputClass}
|
||||
placeholder="0"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
@@ -210,10 +278,11 @@ function OrderInputs({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수량 입력 */}
|
||||
<div className="grid grid-cols-4 items-center gap-2">
|
||||
<span className="text-xs font-medium sm:text-sm">주문수량</span>
|
||||
<span className={labelClass}>주문수량</span>
|
||||
<Input
|
||||
className="col-span-3 text-right font-mono dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100"
|
||||
className={inputClass}
|
||||
placeholder="0"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
@@ -221,13 +290,15 @@ function OrderInputs({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 총액 */}
|
||||
<div className="grid grid-cols-4 items-center gap-2">
|
||||
<span className="text-xs font-medium sm:text-sm">주문총액</span>
|
||||
<span className={labelClass}>주문총액</span>
|
||||
<Input
|
||||
className="col-span-3 bg-muted/50 text-right font-mono dark:border-brand-700/55 dark:bg-black/20 dark:text-brand-100"
|
||||
value={totalPrice.toLocaleString()}
|
||||
className={cn(inputClass, "bg-muted/40 dark:bg-black/20")}
|
||||
value={totalPrice > 0 ? `${totalPrice.toLocaleString()}원` : ""}
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
placeholder="0원"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,17 +307,17 @@ function OrderInputs({
|
||||
|
||||
/**
|
||||
* @description 주문 비율(10/25/50/100%) 단축 버튼을 표시합니다.
|
||||
* @see features/trade/components/order/OrderForm.tsx setPercent - 버튼 클릭 이벤트 처리
|
||||
* @see features/trade/components/order/OrderForm.tsx - OrderForm setPercent 이벤트 처리
|
||||
*/
|
||||
function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
|
||||
return (
|
||||
<div className="mt-2 grid grid-cols-4 gap-2">
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{["10%", "25%", "50%", "100%"].map((pct) => (
|
||||
<Button
|
||||
key={pct}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
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"
|
||||
onClick={() => onSelect(pct)}
|
||||
>
|
||||
{pct}
|
||||
@@ -255,3 +326,80 @@ function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
|
||||
</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")}주`} />
|
||||
<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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user