diff --git a/src/app/search/[id]/_components/transaction-form/trade/buyable-quantity.tsx b/src/app/search/[id]/_components/order-stock/buy-and-sell/buyable-quantity.tsx similarity index 82% rename from src/app/search/[id]/_components/transaction-form/trade/buyable-quantity.tsx rename to src/app/search/[id]/_components/order-stock/buy-and-sell/buyable-quantity.tsx index bdc2559..8e6fdbf 100644 --- a/src/app/search/[id]/_components/transaction-form/trade/buyable-quantity.tsx +++ b/src/app/search/[id]/_components/order-stock/buy-and-sell/buyable-quantity.tsx @@ -6,8 +6,10 @@ import { useAuth } from "@/hooks/use-auth"; import MyStockMap from "@/utils/my-stock-count"; import { calculateBuyableQuantity, getKoreanPrice } from "@/utils/price"; +import { TradeType } from "../../../types"; + interface BuyableQuantityProps { - type: "buy" | "sell" | "edit"; + type: TradeType; bidding: number; } @@ -27,11 +29,11 @@ export default function BuyableQuantity({ return (
- {type === "buy" ? "매수" : "매도"} 가능 주식 + {type === TradeType.Buy ? "매수" : "매도"} 가능 주식 -
- - {type === "buy" +
+ + {type === TradeType.Buy ? getKoreanPrice(calculateBuyableQuantity(deposit, bidding)) : stockMap.findStockCount(stockName)} diff --git a/src/app/search/[id]/_components/transaction-form/trade/current-price.tsx b/src/app/search/[id]/_components/order-stock/buy-and-sell/current-price.tsx similarity index 78% rename from src/app/search/[id]/_components/transaction-form/trade/current-price.tsx rename to src/app/search/[id]/_components/order-stock/buy-and-sell/current-price.tsx index ade8e15..6c6f525 100644 --- a/src/app/search/[id]/_components/transaction-form/trade/current-price.tsx +++ b/src/app/search/[id]/_components/order-stock/buy-and-sell/current-price.tsx @@ -11,12 +11,12 @@ export default function CurrentPrice() { return (
{Array.from({ length: 4 }).map((_, index) => ( -
+
{getKoreanPrice(stockInfo.stockPrice)}
))} {Array.from({ length: 4 }).map((_, index) => ( -
+
{getKoreanPrice(stockInfo.stockPrice)}
))} diff --git a/src/app/search/[id]/_components/order-stock/buy-and-sell/form-buttons.tsx b/src/app/search/[id]/_components/order-stock/buy-and-sell/form-buttons.tsx new file mode 100644 index 0000000..57d939b --- /dev/null +++ b/src/app/search/[id]/_components/order-stock/buy-and-sell/form-buttons.tsx @@ -0,0 +1,42 @@ +import Button from "@/components/common/button"; +import { textMap } from "@/constants/trade"; +import cn from "@/utils/cn"; + +import { TradeType } from "../../../types"; + +interface FormButtonsProps { + orderType: TradeType; + handleReset: () => void; + handleSubmit: () => void; +} + +export default function FormButtons({ + orderType, + handleReset, + handleSubmit, +}: FormButtonsProps) { + return ( +
+ + +
+ ); +} diff --git a/src/app/search/[id]/_components/order-stock/buy-and-sell/index.tsx b/src/app/search/[id]/_components/order-stock/buy-and-sell/index.tsx new file mode 100644 index 0000000..7218a37 --- /dev/null +++ b/src/app/search/[id]/_components/order-stock/buy-and-sell/index.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { UseMutateFunction } from "@tanstack/react-query"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import { colorMap } from "@/constants/trade"; +import { useStockInfoContext } from "@/context/stock-info-context"; +import { useAuth } from "@/hooks/use-auth"; +import { + LimitPriceOrderHistory, + ModifyTradeFormData, +} from "@/types/transaction"; +import { calculateTotalOrderAmount } from "@/utils/price"; +import { + BuyFormData, + BuyFormSchema, +} from "@/validation/schema/transaction-form"; + +import useTradeMutations from "../../../hooks/use-trade-mutations"; +import { PriceType, TradeType } from "../../../types"; +import TradeTable from "../trade-table"; +import BuyableQuantity from "./buyable-quantity"; +import CurrentPrice from "./current-price"; +import FormButtons from "./form-buttons"; +import InputField from "./input-field"; +import PriceTypeDropdown from "./price-type-dropdown"; +import TotalAmount from "./total-amount"; + +interface TradeProps { + type: TradeType; + defaultData?: LimitPriceOrderHistory; + handleMutate?: UseMutateFunction; +} + +export default function BuyAndSell({ + type, + defaultData, + handleMutate, +}: TradeProps) { + const [priceType, setPriceType] = useState(PriceType.Limit); + const [isConfirmationPage, setIsConfirmationPage] = useState(false); + + const { stockName, stockInfo } = useStockInfoContext(); + const { token } = useAuth(); + const trades = useTradeMutations(); + + const { + control, + watch, + reset, + handleSubmit, + setValue, + formState: { errors }, + } = useForm({ + resolver: zodResolver(BuyFormSchema), + defaultValues: + type === TradeType.Edit && defaultData + ? { + count: defaultData.stockCount, + bidding: defaultData.buyPrice, + } + : { + count: undefined, + bidding: undefined, + }, + }); + + const watchedCount = watch("count"); + const watchedBidding = watch("bidding"); + + const handleReset = () => { + reset({ + count: undefined, + bidding: undefined, + }); + }; + + const handlePriceTypeChange = (newPriceType: PriceType) => { + setPriceType(newPriceType); + setValue( + "bidding", + newPriceType === PriceType.Market ? Number(stockInfo.stockPrice) : 0, + ); + }; + + const handleConfirmPurchase = () => { + setIsConfirmationPage(true); + }; + + const transactionHandlers = { + [TradeType.Buy]: () => + priceType === PriceType.Market + ? trades.buyAtMarketPrice.mutate({ + token, + data: { stockName, quantity: watchedCount }, + }) + : trades.buyAtLimitPrice.mutate({ + token, + data: { + stockName, + limitPrice: watchedBidding, + quantity: watchedCount, + }, + }), + + [TradeType.Sell]: () => + priceType === PriceType.Market + ? trades.sellAtMarketPrice.mutate({ + token, + data: { stockName, quantity: watchedCount }, + }) + : trades.sellAtLimitPrice.mutate({ + token, + data: { + stockName, + limitPrice: watchedBidding, + quantity: watchedCount, + }, + }), + + [TradeType.Edit]: () => { + if (type !== TradeType.Edit || !handleMutate) return; + handleMutate({ + token, + orderId: defaultData?.OrderId, + data: { + stockName, + limitPrice: watchedBidding, + quantity: watchedCount, + }, + }); + }, + }; + + const handleConfirm = transactionHandlers[type]; + + if (isConfirmationPage) { + return ( + setIsConfirmationPage(false)} + onClickConfirm={handleConfirm} + /> + ); + } + + return ( +
+ +
+ + + + {type !== "edit" && ( + + )} + + + + handleSubmit(handleConfirmPurchase)()} + /> + +
+ ); +} diff --git a/src/app/search/[id]/_components/transaction-form/trade/order-field.tsx b/src/app/search/[id]/_components/order-stock/buy-and-sell/input-field.tsx similarity index 93% rename from src/app/search/[id]/_components/transaction-form/trade/order-field.tsx rename to src/app/search/[id]/_components/order-stock/buy-and-sell/input-field.tsx index 49c836b..f7d14db 100644 --- a/src/app/search/[id]/_components/transaction-form/trade/order-field.tsx +++ b/src/app/search/[id]/_components/order-stock/buy-and-sell/input-field.tsx @@ -10,9 +10,9 @@ import { useStockInfoContext } from "@/context/stock-info-context"; import { getKoreanPrice } from "@/utils/price"; import { BuyFormData } from "@/validation/schema/transaction-form"; -import CountDropdown from "./count-dropdown"; +import NumberDropdown from "./number-dropdown"; -interface OrderFieldProps { +interface InputFieldProps { title: string; type: "count" | "bidding"; placeholder: string; @@ -24,7 +24,7 @@ interface OrderFieldProps { quantity?: number; } -export default function OrderField({ +export default function InputField({ title, type, placeholder, @@ -34,7 +34,7 @@ export default function OrderField({ control, errors, quantity, -}: OrderFieldProps) { +}: InputFieldProps) { const { stockInfo } = useStockInfoContext(); const numberRegex = /^[0-9,]*$/; @@ -72,7 +72,7 @@ export default function OrderField({ return (
- { +interface NumberDropdownProps { state: T; setState: Dispatch>; title: string; @@ -12,13 +12,13 @@ interface CountDropdownProps { stockPrice?: string | boolean; } -function CountDropdown({ +function NumberDropdown({ state, setState, number, title, stockPrice, -}: CountDropdownProps) { +}: NumberDropdownProps) { return ( ({ ); } -export default memo(CountDropdown); +export default memo(NumberDropdown); diff --git a/src/app/search/[id]/_components/transaction-form/trade/price-type-dropdown.tsx b/src/app/search/[id]/_components/order-stock/buy-and-sell/price-type-dropdown.tsx similarity index 77% rename from src/app/search/[id]/_components/transaction-form/trade/price-type-dropdown.tsx rename to src/app/search/[id]/_components/order-stock/buy-and-sell/price-type-dropdown.tsx index 5df04f0..bc1f5b6 100644 --- a/src/app/search/[id]/_components/transaction-form/trade/price-type-dropdown.tsx +++ b/src/app/search/[id]/_components/order-stock/buy-and-sell/price-type-dropdown.tsx @@ -1,9 +1,11 @@ import Dropdown from "@/components/common/dropdown"; +import { PriceType, TradeType } from "../../../types"; + interface PriceTypeDropdownProps { - orderType: "buy" | "sell" | "edit"; - priceType: string; - setPriceType: (value: string) => void; + orderType: TradeType; + priceType: PriceType; + setPriceType: (newPriceType: PriceType) => void; } export default function PriceTypeDropdown({ orderType, @@ -12,14 +14,14 @@ export default function PriceTypeDropdown({ }: PriceTypeDropdownProps) { return (
- {orderType === "edit" ? ( + {orderType === TradeType.Edit ? ( 지정가 ) : ( setPriceType(value as string)} + onSelect={(value) => setPriceType(value as PriceType)} className="flex-1" > {priceType} diff --git a/src/app/search/[id]/_components/transaction-form/trade/total-amount.tsx b/src/app/search/[id]/_components/order-stock/buy-and-sell/total-amount.tsx similarity index 75% rename from src/app/search/[id]/_components/transaction-form/trade/total-amount.tsx rename to src/app/search/[id]/_components/order-stock/buy-and-sell/total-amount.tsx index ee74223..71f0af0 100644 --- a/src/app/search/[id]/_components/transaction-form/trade/total-amount.tsx +++ b/src/app/search/[id]/_components/order-stock/buy-and-sell/total-amount.tsx @@ -12,11 +12,11 @@ export default function TotalAmount({ count, bidding }: TotalAmountProps) { <>
총 주문 금액
-
+
{totalAmount}원
-
{totalAmount}원
+
{totalAmount}원
); } diff --git a/src/app/search/[id]/_components/transaction-form/edit-cancel/edit-table-body.tsx b/src/app/search/[id]/_components/order-stock/edit-cancel/edit-table-row.tsx similarity index 83% rename from src/app/search/[id]/_components/transaction-form/edit-cancel/edit-table-body.tsx rename to src/app/search/[id]/_components/order-stock/edit-cancel/edit-table-row.tsx index dd45f29..23fcd7b 100644 --- a/src/app/search/[id]/_components/transaction-form/edit-cancel/edit-table-body.tsx +++ b/src/app/search/[id]/_components/order-stock/edit-cancel/edit-table-row.tsx @@ -9,7 +9,7 @@ interface EditTableBodyProps { toggleSelection: (orderId: string) => void; } -export default function EditTableBody({ +export default function EditTableRow({ data, isChecked, toggleSelection, @@ -30,13 +30,13 @@ export default function EditTableBody({ {data.type} 정정 - {data.OrderId} + {data.OrderId}
{data.stockName} diff --git a/src/app/search/[id]/_components/order-stock/edit-cancel/edit-table.tsx b/src/app/search/[id]/_components/order-stock/edit-cancel/edit-table.tsx new file mode 100644 index 0000000..3b104e0 --- /dev/null +++ b/src/app/search/[id]/_components/order-stock/edit-cancel/edit-table.tsx @@ -0,0 +1,79 @@ +import Image from "next/image"; +import { Dispatch, SetStateAction } from "react"; + +import CheckButton from "@/components/common/check-button"; +import { LimitPriceOrderHistory } from "@/types/transaction"; + +import EditTableRow from "./edit-table-row"; + +const titles = [ + , + "주문 정보", + "잔량", + "주문 수량", + "주문 가격", +]; + +interface EditTableProps { + limitPriceHistory: LimitPriceOrderHistory[] | undefined; + selectedOrders: string[]; + setSelectedOrders: Dispatch>; +} + +export default function EditTable({ + limitPriceHistory, + selectedOrders, + setSelectedOrders, +}: EditTableProps) { + const toggleOrderSelection = (orderId: string) => { + setSelectedOrders((prev) => + prev.includes(orderId) ? prev.filter((id) => id !== orderId) : [orderId], + ); + }; + + return ( +
+ + + + {titles.map((title, index) => ( + + ))} + + + + {limitPriceHistory && limitPriceHistory.length > 0 ? ( + [...limitPriceHistory] + .sort((a, b) => b.OrderId - a.OrderId) + .map((data) => ( + + )) + ) : ( + + + + )} + +
+ {title} +
+ 지갑 그림 + 지정가 거래 내역이 없습니다! +
+
+ ); +} diff --git a/src/app/search/[id]/_components/order-stock/edit-cancel/index.tsx b/src/app/search/[id]/_components/order-stock/edit-cancel/index.tsx new file mode 100644 index 0000000..7f33991 --- /dev/null +++ b/src/app/search/[id]/_components/order-stock/edit-cancel/index.tsx @@ -0,0 +1,124 @@ +// "use client"; + +import { useState } from "react"; + +import Button from "@/components/common/button"; +import { useAuth } from "@/hooks/use-auth"; +import { useToast } from "@/store/use-toast-store"; + +import useLimitOrderData from "../../../hooks/use-limit-order-data"; +import useOrderMutations from "../../../hooks/use-limit-order-mutations"; +import { TradeType } from "../../../types"; +import LoadingSpinner from "../../loading-spiner"; +import BuyAndSell from "../buy-and-sell"; +import TradeTable from "../trade-table"; +import EditTable from "./edit-table"; + +export default function EditCancel() { + const [selectedOrders, setSelectedOrders] = useState([]); + const [isEditForm, setIsEditForm] = useState(false); + const [isCancelTable, setIsCancelTable] = useState(false); + + const { showToast } = useToast(); + const { token } = useAuth(); + const { + data: limitOrderData, + isLoading, + isPending, + findOrderById, + } = useLimitOrderData(); + const { cancelTradeMutation, modifyTradeMutation } = useOrderMutations(); + + const handleEdit = () => { + if (selectedOrders.length > 0) { + setIsEditForm(true); + } else { + showToast("정정할 데이터를 선택해주세요.", "error"); + } + }; + + const handleCancel = () => { + if (selectedOrders.length > 0) { + setIsCancelTable(true); + } else { + showToast("취소할 데이터를 선택해주세요.", "error"); + } + }; + + const handleCancelConfirm = (orderId: string) => { + cancelTradeMutation.mutate({ token, orderId }); + setIsCancelTable(false); + }; + + if (isLoading || isPending) { + return ; + } + + if (isEditForm) { + const order = findOrderById(selectedOrders[0]); + return ( + + ); + } + + if (isCancelTable) { + return ( + <> + {selectedOrders.map((orderId) => { + const order = findOrderById(orderId); + return order ? ( + setIsCancelTable(false)} + onClickConfirm={() => { + handleCancelConfirm(orderId); + }} + /> + ) : ( + "취소 정보를 받아오지 못했습니다." + ); + })} + + ); + } + + return ( + <> + + {limitOrderData && limitOrderData.length > 0 && ( +
+ + +
+ )} + + ); +} diff --git a/src/app/search/[id]/_components/order-stock/index.tsx b/src/app/search/[id]/_components/order-stock/index.tsx new file mode 100644 index 0000000..3c45dbc --- /dev/null +++ b/src/app/search/[id]/_components/order-stock/index.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/common/tabs"; +import { StockInfoProvider } from "@/context/stock-info-context"; +import { useAuth } from "@/hooks/use-auth"; + +import { StockInfo, TradeType } from "../../types"; +import LoadingSpinner from "../loading-spiner"; +import BuyAndSell from "./buy-and-sell"; +import EditCancel from "./edit-cancel"; +import OrderStockLayout from "./layout"; +import LoginNotice from "./login-notice"; +import OrderHistory from "./order-history"; + +interface OrderStockProps { + stockName: string; + stockInfo: StockInfo; +} + +export default function OrderStock({ stockName, stockInfo }: OrderStockProps) { + const { isAuthenticated, isInitialized } = useAuth(); + + if (!isInitialized) { + return ( + + + + ); + } + + return ( + + {isAuthenticated ? ( + + + + + 매수 + + + 매도 + + 체결내역 + 정정 / 취소 + + + + + + + + + + + + + + + + ) : ( + // 로그인 안되어있으면 로그인 유도 + + )} + + ); +} diff --git a/src/app/search/[id]/_components/order-stock/layout.tsx b/src/app/search/[id]/_components/order-stock/layout.tsx new file mode 100644 index 0000000..5ef2956 --- /dev/null +++ b/src/app/search/[id]/_components/order-stock/layout.tsx @@ -0,0 +1,14 @@ +import { ReactNode } from "react"; + +export default function OrderStockLayout({ + children, +}: { + children: ReactNode; +}) { + return ( +
+

거래하기

+ {children} +
+ ); +} diff --git a/src/app/search/[id]/_components/order-stock/login-notice.tsx b/src/app/search/[id]/_components/order-stock/login-notice.tsx new file mode 100644 index 0000000..f8021ae --- /dev/null +++ b/src/app/search/[id]/_components/order-stock/login-notice.tsx @@ -0,0 +1,29 @@ +import Image from "next/image"; +import Link from "next/link"; + +export default function LoginNotice() { + return ( +
+ 보안 아이콘 +
+ 로그인이 필요해요! +
+ + 가상 거래를 하기 위해서는 로그인이 필수적이에요! + + + 로그인 하기 + +
+
+ ); +} diff --git a/src/app/search/[id]/_components/transaction-form/history.tsx b/src/app/search/[id]/_components/order-stock/order-history/index.tsx similarity index 91% rename from src/app/search/[id]/_components/transaction-form/history.tsx rename to src/app/search/[id]/_components/order-stock/order-history/index.tsx index c2a0a48..52ac4b2 100644 --- a/src/app/search/[id]/_components/transaction-form/history.tsx +++ b/src/app/search/[id]/_components/order-stock/order-history/index.tsx @@ -5,10 +5,10 @@ import { getTradeHistory } from "@/api/transaction"; import { useStockInfoContext } from "@/context/stock-info-context"; import { useAuth } from "@/hooks/use-auth"; -import LoadingSpinner from "../loading-spiner"; -import TransactionTable from "./transaction-table"; +import LoadingSpinner from "../../loading-spiner"; +import TradeTable from "../trade-table"; -export default function History() { +export default function OrderHistory() { const { token, isAuthenticated } = useAuth(); const { stockName } = useStockInfoContext(); @@ -32,7 +32,7 @@ export default function History() { [...tradeHistoryData] .sort((a, b) => b.id - a.id) .map((history) => ( - + + {label} + + {value} + + ); +} + +interface TradeTableProps { + color?: keyof typeof COLOR_VARIANTS; + isSubmit?: boolean; + submittedData: { + stockName: string; + count: number; + bidding: number; + totalAmount: number; + buyOrder?: string; + }; + onClickGoBack?: () => void; + onClickConfirm?: () => void; +} + +export default function TradeTable({ + color = "red", + isSubmit = true, + submittedData, + onClickGoBack, + onClickConfirm, +}: TradeTableProps) { + return ( +
+ + + + + + + +
+ {isSubmit && ( +
+ + +
+ )} +
+ ); +} diff --git a/src/app/search/[id]/_components/transaction-form/edit-cancel/edit-table-header.tsx b/src/app/search/[id]/_components/transaction-form/edit-cancel/edit-table-header.tsx deleted file mode 100644 index 1119901..0000000 --- a/src/app/search/[id]/_components/transaction-form/edit-cancel/edit-table-header.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import CheckButton from "@/components/common/check-button"; - -const titles = [ - , - "주문 정보", - "잔량", - "주문 수량", - "주문 가격", -]; - -export default function EditTableHeader() { - return ( - - - {titles.map((title, index) => ( - - {title} - - ))} - - - ); -} diff --git a/src/app/search/[id]/_components/transaction-form/edit-cancel/index.tsx b/src/app/search/[id]/_components/transaction-form/edit-cancel/index.tsx deleted file mode 100644 index 17c044c..0000000 --- a/src/app/search/[id]/_components/transaction-form/edit-cancel/index.tsx +++ /dev/null @@ -1,191 +0,0 @@ -"use client"; - -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import Image from "next/image"; -import { useState } from "react"; - -import { cancelTrade, getTrade, modifyTrade } from "@/api/transaction"; -import Button from "@/components/common/button"; -import { useStockInfoContext } from "@/context/stock-info-context"; -import { useAuth } from "@/hooks/use-auth"; -import { useToast } from "@/store/use-toast-store"; - -import LoadingSpinner from "../../loading-spiner"; -import Trade from "../trade"; -import TransactionTable from "../transaction-table"; -import EditTableBody from "./edit-table-body"; -import EditTableHeader from "./edit-table-header"; - -export default function EditCancel() { - const { stockName } = useStockInfoContext(); - const { token, isAuthenticated } = useAuth(); - const { showToast } = useToast(); - const queryClient = useQueryClient(); - - const { - data: limitOrderData, - isLoading, - isPending, - } = useQuery({ - queryKey: ["limitOrder", `${stockName}`], - queryFn: () => getTrade(token, stockName), - enabled: !!isAuthenticated && !!token, - }); - - const [selectedOrders, setSelectedOrders] = useState([]); - const [isEditForm, setIsEditForm] = useState(false); - const [isCancelTable, setIsCancelTable] = useState(false); - - const findOrder = (orderId: string) => - limitOrderData?.find((data) => data.OrderId.toString() === orderId); - - const toggleOrderSelection = (orderId: string) => { - setSelectedOrders((prev) => - prev.includes(orderId) ? prev.filter((id) => id !== orderId) : [orderId], - ); - }; - - const handleEdit = () => { - if (selectedOrders.length > 0) { - setIsEditForm(true); - } else { - showToast("정정할 데이터를 선택해주세요.", "error"); - } - }; - - const handleCancel = () => { - if (selectedOrders.length > 0) { - setIsCancelTable(true); - } else { - showToast("취소할 데이터를 선택해주세요.", "error"); - } - }; - - const { mutate: cancelTradeMutate } = useMutation({ - mutationFn: cancelTrade, - onSuccess: () => { - showToast("주문이 취소되었습니다", "success"); - queryClient.invalidateQueries({ queryKey: ["limitOrder"] }); - }, - onSettled: () => { - setIsCancelTable(false); - }, - onError: (error) => { - showToast(error.message, "error"); - }, - }); - - const { mutate: modifyTradeMutate } = useMutation({ - mutationFn: modifyTrade, - onSuccess: () => { - showToast("주문을 수정했습니다.", "success"); - queryClient.invalidateQueries({ queryKey: ["limitOrder"] }); - }, - onSettled: () => { - setIsEditForm(false); - setSelectedOrders([]); - }, - onError: (error) => { - showToast(error.message, "error"); - }, - }); - - const handleCancelConfirm = (orderId: string) => { - cancelTradeMutate({ token, orderId }); - }; - - if (isEditForm) { - const order = findOrder(selectedOrders[0]); - return ( - - ); - } - - if (isLoading || isPending) { - return ; - } - - if (isCancelTable) { - return ( - <> - {selectedOrders.map((orderId) => { - const order = findOrder(orderId); - return order ? ( - setIsCancelTable(false)} - onClickConfirm={() => { - handleCancelConfirm(orderId); - }} - /> - ) : ( - "취소 정보를 받아오지 못했습니다." - ); - })} - - ); - } - - return ( - <> -
- - - - {limitOrderData && limitOrderData.length > 0 ? ( - [...limitOrderData] - .sort((a, b) => b.OrderId - a.OrderId) - .map((data) => ( - - )) - ) : ( - - - - )} - -
- 지갑 그림 - 지정가 거래 내역이 없습니다! -
-
- {limitOrderData && limitOrderData.length > 0 && ( -
- - -
- )} - - ); -} diff --git a/src/app/search/[id]/_components/transaction-form/index.tsx b/src/app/search/[id]/_components/transaction-form/index.tsx deleted file mode 100644 index 4b7f59c..0000000 --- a/src/app/search/[id]/_components/transaction-form/index.tsx +++ /dev/null @@ -1,102 +0,0 @@ -"use client"; - -import Image from "next/image"; -import Link from "next/link"; - -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@/components/common/tabs"; -import { StockInfoProvider } from "@/context/stock-info-context"; -import { useAuth } from "@/hooks/use-auth"; - -import { StockInfo } from "../../types"; -import LoadingSpinner from "../loading-spiner"; -import EditCancel from "./edit-cancel"; -import History from "./history"; -import Trade from "./trade"; - -interface TransactionFormProps { - stockName: string; - stockInfo: StockInfo; -} - -export default function TransactionForm({ - stockName, - stockInfo, -}: TransactionFormProps) { - const { isAuthenticated, isInitialized } = useAuth(); - - if (!isInitialized) { - return ( -
-

거래하기

- -
- ); - } - - return ( -
-

거래하기

- {isAuthenticated ? ( - - - - - 매수 - - - 매도 - - 체결내역 - 정정 / 취소 - - - - - - - - - - - - - - - - ) : ( -
- 보안 아이콘 -
- 로그인이 필요해요! -
- - 가상 거래를 하기 위해서는 로그인이 필수적이에요! - - - 로그인 하기 - -
-
- )} -
- ); -} diff --git a/src/app/search/[id]/_components/transaction-form/trade/bidding.tsx b/src/app/search/[id]/_components/transaction-form/trade/bidding.tsx deleted file mode 100644 index 69cf129..0000000 --- a/src/app/search/[id]/_components/transaction-form/trade/bidding.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { - Control, - Controller, - FieldErrors, - UseFormSetValue, -} from "react-hook-form"; - -import Input from "@/components/common/input"; -import { useStockInfoContext } from "@/context/stock-info-context"; -import { getKoreanPrice } from "@/utils/price"; -import { BuyFormData } from "@/validation/schema/transaction-form"; - -import CountDropdown from "./count-dropdown"; - -interface BiddingProps { - state: number | undefined; - setState: UseFormSetValue; - control: Control; - errors: FieldErrors["bidding"]; -} - -export default function Bidding({ - state, - setState, - control, - errors, -}: BiddingProps) { - const { stockInfo } = useStockInfoContext(); - - return ( -
- setState("bidding", value ? Number(value) : 0)} - stockPrice={stockInfo.stockPrice} - /> - ( - - field.onChange( - e.target.value === "" ? undefined : Number(e.target.value), - ) - } - error={errors?.message} - /> - )} - /> -
- ); -} diff --git a/src/app/search/[id]/_components/transaction-form/trade/buy-form-buttons.tsx b/src/app/search/[id]/_components/transaction-form/trade/buy-form-buttons.tsx deleted file mode 100644 index eb89430..0000000 --- a/src/app/search/[id]/_components/transaction-form/trade/buy-form-buttons.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import Button from "@/components/common/button"; -import cn from "@/utils/cn"; - -interface BuyFormButtonsProps { - orderType: "buy" | "sell" | "edit"; - handleReset: () => void; - handleSubmit: () => void; -} - -export default function BuyFormButtons({ - orderType, - handleReset, - handleSubmit, -}: BuyFormButtonsProps) { - let buttonText; - if (orderType === "buy") { - buttonText = "매수"; - } else if (orderType === "sell") { - buttonText = "매도"; - } else if (orderType === "edit") { - buttonText = "정정"; - } - - return ( -
- - -
- ); -} diff --git a/src/app/search/[id]/_components/transaction-form/trade/index.tsx b/src/app/search/[id]/_components/transaction-form/trade/index.tsx deleted file mode 100644 index b8e2b30..0000000 --- a/src/app/search/[id]/_components/transaction-form/trade/index.tsx +++ /dev/null @@ -1,261 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; - -import { - buyAtLimitPrice, - buyAtMarketPrice, - sellAtLimitPrice, - sellAtMarketPrice, -} from "@/api/transaction"; -import { useTabsContext } from "@/components/common/tabs"; -import { useStockInfoContext } from "@/context/stock-info-context"; -import { useAuth } from "@/hooks/use-auth"; -import { useToast } from "@/store/use-toast-store"; -import { - LimitPriceOrderHistory, - ModifyTradeFormData, -} from "@/types/transaction"; -import { calculateTotalOrderAmount } from "@/utils/price"; -import { - BuyFormData, - BuyFormSchema, -} from "@/validation/schema/transaction-form"; - -import TransactionTable from "../transaction-table"; -import BuyFormButtons from "./buy-form-buttons"; -import BuyableQuantity from "./buyable-quantity"; -import CurrentPrice from "./current-price"; -import OrderField from "./order-field"; -import PriceTypeDropdown from "./price-type-dropdown"; -import TotalAmount from "./total-amount"; - -interface TradeProps { - type: "buy" | "sell" | "edit"; - defaultData?: LimitPriceOrderHistory; - handleMutate?: (data: ModifyTradeFormData) => void; -} - -export default function Trade({ type, defaultData, handleMutate }: TradeProps) { - const [priceType, setPriceType] = useState("지정가"); - const [isConfirmationPage, setIsConfirmationPage] = useState(false); - const { setActiveTab } = useTabsContext(); - const { stockName, stockInfo } = useStockInfoContext(); - const { token } = useAuth(); - const { showToast } = useToast(); - const queryClient = useQueryClient(); - - const { - control, - watch, - reset, - handleSubmit, - setValue, - formState: { errors }, - } = useForm({ - resolver: zodResolver(BuyFormSchema), - defaultValues: - type === "edit" && defaultData - ? { - count: defaultData.stockCount, - bidding: defaultData.buyPrice, - } - : { - count: undefined, - bidding: undefined, - }, - }); - - const watchedCount = watch("count"); - const watchedBidding = watch("bidding"); - - const handleReset = () => { - reset({ - count: undefined, - bidding: undefined, - }); - }; - - const handlePriceTypeChange = (newPriceType: string) => { - setPriceType(newPriceType); - if (newPriceType === "현재가") { - setValue("bidding", Number(stockInfo.stockPrice)); - } else { - setValue("bidding", 0); - } - }; - - const handleConfirmPurchase = () => { - setIsConfirmationPage(true); - }; - - const { mutate: buyAtMarketPriceMutate } = useMutation({ - mutationFn: buyAtMarketPrice, - onSuccess: () => { - setActiveTab("history"); - showToast("현재가로 매수가 완료되었습니다.", "success"); - queryClient.invalidateQueries({ queryKey: ["tradeHistory"] }); - }, - onError: (error) => { - showToast(error.message, "error"); - }, - }); - - const { mutate: sellAtMarketPriceMutate } = useMutation({ - mutationFn: sellAtMarketPrice, - onSuccess: () => { - setActiveTab("history"); - showToast("현재가로 매도가 완료되었습니다.", "success"); - queryClient.invalidateQueries({ queryKey: ["tradeHistory"] }); - }, - onError: (error) => { - showToast(error.message, "error"); - }, - }); - - const { mutate: buyAtLimitPriceMutate } = useMutation({ - mutationFn: buyAtLimitPrice, - onSuccess: () => { - setActiveTab("edit-cancel"); - queryClient.invalidateQueries({ queryKey: ["limitOrder"] }); - showToast("지정가로 매수가 완료되었습니다.", "success"); - }, - onError: (error) => { - showToast(error.message, "error"); - }, - }); - - const { mutate: sellAtLimitPriceMutate } = useMutation({ - mutationFn: sellAtLimitPrice, - onSuccess: () => { - setActiveTab("edit-cancel"); - queryClient.invalidateQueries({ queryKey: ["limitOrder"] }); - showToast("지정가로 매도가 완료되었습니다.", "success"); - }, - onError: (error) => { - showToast(error.message, "error"); - }, - }); - - const handleBuy = () => { - if (priceType === "현재가") { - buyAtMarketPriceMutate({ - token, - data: { stockName, quantity: watchedCount }, - }); - } else { - buyAtLimitPriceMutate({ - token, - data: { stockName, limitPrice: watchedBidding, quantity: watchedCount }, - }); - } - }; - - const handleSell = () => { - if (priceType === "현재가") { - sellAtMarketPriceMutate({ - token, - data: { stockName, quantity: watchedCount }, - }); - } else { - sellAtLimitPriceMutate({ - token, - data: { stockName, limitPrice: watchedBidding, quantity: watchedCount }, - }); - } - }; - - const handleEdit = () => { - if (type !== "edit" || !handleMutate) return; - - handleMutate({ - token, - orderId: defaultData?.OrderId, - data: { stockName, limitPrice: watchedBidding, quantity: watchedCount }, - }); - }; - - let onClickConfirmHandler; - - if (type === "buy") { - onClickConfirmHandler = handleBuy; - } else if (type === "sell") { - onClickConfirmHandler = handleSell; - } else if (type === "edit") { - onClickConfirmHandler = handleEdit; - } - - let color; - if (type === "buy") { - color = "red" as const; - } else if (type === "sell") { - color = "blue" as const; - } else if (type === "edit") { - color = "green" as const; - } - - if (isConfirmationPage) { - return ( - setIsConfirmationPage(false)} - onClickConfirm={onClickConfirmHandler} - /> - ); - } - - return ( -
- -
- - - - {type !== "edit" && ( - - )} - - - - handleSubmit(handleConfirmPurchase)()} - /> -
-
- ); -} diff --git a/src/app/search/[id]/_components/transaction-form/transaction-table.tsx b/src/app/search/[id]/_components/transaction-form/transaction-table.tsx deleted file mode 100644 index cf0680c..0000000 --- a/src/app/search/[id]/_components/transaction-form/transaction-table.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import Button from "@/components/common/button"; -import cn from "@/utils/cn"; -import { getKoreanPrice } from "@/utils/price"; - -interface TransactionTableProps { - color?: "red" | "green" | "blue"; - isSubmit?: boolean; - submittedData: { - stockName: string; - count: number; - bidding: number; - totalAmount: number; - buyOrder?: string; - }; - onClickGoBack?: () => void; - onClickConfirm?: () => void; -} - -export default function TransactionTable({ - color = "red", - isSubmit = true, - submittedData, - onClickGoBack, - onClickConfirm, -}: TransactionTableProps) { - const getBackgroundColor = () => { - const colorMap: { - [key in Exclude]: string; - } = { - red: "bg-[#FDEBEB]", - green: "bg-[#E9FFF0]", - blue: "bg-[#EDF1FC]", - }; - return colorMap[color]; - }; - - return ( -
- - - - - - - - - - - - - - - - - - - -
- 종목명 - {submittedData?.stockName}
- {submittedData.buyOrder} 수량 - - {getKoreanPrice(submittedData.count)} -
- {submittedData.buyOrder} 가격 - - {getKoreanPrice(submittedData.bidding)} -
- 총 주문 금액 - - {getKoreanPrice(submittedData.totalAmount)} -
- {isSubmit && ( -
- - -
- )} -
- ); -} diff --git a/src/app/search/[id]/hooks/use-limit-order-data.ts b/src/app/search/[id]/hooks/use-limit-order-data.ts new file mode 100644 index 0000000..a99724f --- /dev/null +++ b/src/app/search/[id]/hooks/use-limit-order-data.ts @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; + +import { getTrade } from "@/api/transaction"; +import { useStockInfoContext } from "@/context/stock-info-context"; +import { useAuth } from "@/hooks/use-auth"; + +export default function useLimitOrderData() { + const { stockName } = useStockInfoContext(); + const { token, isAuthenticated } = useAuth(); + + const queryResult = useQuery({ + queryKey: ["limitOrder", stockName], + queryFn: () => getTrade(token, stockName), + enabled: !!isAuthenticated && !!token, + }); + + const findOrderById = (orderId: string) => + queryResult.data?.find((data) => data.OrderId.toString() === orderId); + + return { + ...queryResult, + findOrderById, + }; +} diff --git a/src/app/search/[id]/hooks/use-limit-order-mutations.ts b/src/app/search/[id]/hooks/use-limit-order-mutations.ts new file mode 100644 index 0000000..31c83fd --- /dev/null +++ b/src/app/search/[id]/hooks/use-limit-order-mutations.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { cancelTrade, modifyTrade } from "@/api/transaction"; +import { useToast } from "@/store/use-toast-store"; + +export default function useOrderMutations() { + const { showToast } = useToast(); + const queryClient = useQueryClient(); + + const cancelTradeMutation = useMutation({ + mutationFn: cancelTrade, + onSuccess: () => { + showToast("주문이 취소되었습니다", "success"); + queryClient.invalidateQueries({ queryKey: ["limitOrder"] }); + }, + onError: (error: Error) => { + showToast(error.message, "error"); + }, + }); + + const modifyTradeMutation = useMutation({ + mutationFn: modifyTrade, + onSuccess: () => { + showToast("주문을 수정했습니다.", "success"); + queryClient.invalidateQueries({ queryKey: ["limitOrder"] }); + }, + onError: (error: Error) => { + showToast(error.message, "error"); + }, + }); + + return { cancelTradeMutation, modifyTradeMutation }; +} diff --git a/src/app/search/[id]/hooks/use-trade-mutations.ts b/src/app/search/[id]/hooks/use-trade-mutations.ts new file mode 100644 index 0000000..6401f0a --- /dev/null +++ b/src/app/search/[id]/hooks/use-trade-mutations.ts @@ -0,0 +1,66 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { + buyAtLimitPrice, + buyAtMarketPrice, + sellAtLimitPrice, + sellAtMarketPrice, +} from "@/api/transaction"; +import { useTabsContext } from "@/components/common/tabs"; +import { useToast } from "@/store/use-toast-store"; + +export default function useTradeMutations() { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + const { setActiveTab } = useTabsContext(); + + const createMutationConfig = ( + successMessage: string, + tabName: string, + queryKey: string, + ) => ({ + onSuccess: () => { + setActiveTab(tabName); + showToast(successMessage, "success"); + queryClient.invalidateQueries({ queryKey: [queryKey] }); + }, + onError: (error: Error) => { + showToast(error.message, "error"); + }, + }); + + return { + buyAtMarketPrice: useMutation({ + mutationFn: buyAtMarketPrice, + ...createMutationConfig( + "현재가로 매수가 완료되었습니다.", + "history", + "tradeHistory", + ), + }), + sellAtMarketPrice: useMutation({ + mutationFn: sellAtMarketPrice, + ...createMutationConfig( + "현재가로 매도가 완료되었습니다.", + "history", + "tradeHistory", + ), + }), + buyAtLimitPrice: useMutation({ + mutationFn: buyAtLimitPrice, + ...createMutationConfig( + "지정가로 매수가 완료되었습니다.", + "edit-cancel", + "limitOrder", + ), + }), + sellAtLimitPrice: useMutation({ + mutationFn: sellAtLimitPrice, + ...createMutationConfig( + "지정가로 매도가 완료되었습니다.", + "edit-cancel", + "limitOrder", + ), + }), + }; +} diff --git a/src/app/search/[id]/page.tsx b/src/app/search/[id]/page.tsx index 0c4dcf7..ea14aa3 100644 --- a/src/app/search/[id]/page.tsx +++ b/src/app/search/[id]/page.tsx @@ -1,8 +1,8 @@ import Link from "next/link"; import CandlestickChartContainer from "./_components/candle-chart-container"; +import OrderStock from "./_components/order-stock"; import StockHeader from "./_components/stock-header"; -import TransactionForm from "./_components/transaction-form"; import TutorialContainer from "./_components/tutorial/tutorial-container"; import { ChartResponse, StockInfo, VolumeResponse } from "./types"; @@ -72,10 +72,7 @@ export default async function StockPage({ initialVolumeData={initialData.volumeData} /> - +
); diff --git a/src/app/search/[id]/types/index.ts b/src/app/search/[id]/types/index.ts index da98d03..fa11d05 100644 --- a/src/app/search/[id]/types/index.ts +++ b/src/app/search/[id]/types/index.ts @@ -30,3 +30,14 @@ export interface StockInfo { highStockPrice: string; lowStockPrice: string; } + +export enum TradeType { + Buy = "buy", + Sell = "sell", + Edit = "edit", +} + +export enum PriceType { + Market = "현재가", + Limit = "지정가", +} diff --git a/src/components/common/dropdown/index.tsx b/src/components/common/dropdown/index.tsx index 428c4a4..f401ae5 100644 --- a/src/components/common/dropdown/index.tsx +++ b/src/components/common/dropdown/index.tsx @@ -129,7 +129,7 @@ function Wrapper({ {isOpen && (