diff --git a/admin/src/apis/lotteryAPI.ts b/admin/src/apis/lotteryAPI.ts index e54eef5e..bc1e335b 100644 --- a/admin/src/apis/lotteryAPI.ts +++ b/admin/src/apis/lotteryAPI.ts @@ -1,4 +1,5 @@ import { + DeleteLotteryWinnerResponse, GetLotteryExpectationsParams, GetLotteryExpectationsResponse, GetLotteryParticipantResponse, @@ -55,6 +56,18 @@ export const LotteryAPI = { throw error; } }, + async deleteLotteryWinner(token: string): Promise { + try { + const response = await fetchWithTimeout(`${baseURL}/winner`, { + method: "DELETE", + headers: { ...headers, Authorization: `Bearer ${token}` }, + }); + return response.json(); + } catch (error) { + console.error("Error:", error); + throw error; + } + }, async getLotteryParticipant( { size, page, phoneNumber }: GetLotteryWinnerParams, token: string diff --git a/admin/src/components/Suspense/index.tsx b/admin/src/components/Suspense/index.tsx new file mode 100644 index 00000000..d5047d97 --- /dev/null +++ b/admin/src/components/Suspense/index.tsx @@ -0,0 +1,21 @@ +import { PropsWithChildren } from "react"; + +interface SuspenseProps extends PropsWithChildren { + isLoading?: boolean; +} + +export default function Suspense({ children, isLoading = false }: SuspenseProps) { + return ( + <> + {isLoading ? ( +
+

+ 데이터를 불러오는 중입니다... +

+
+ ) : ( + children + )} + + ); +} diff --git a/admin/src/components/TimePicker/index.tsx b/admin/src/components/TimePicker/index.tsx index e5f793ae..564e5cd1 100644 --- a/admin/src/components/TimePicker/index.tsx +++ b/admin/src/components/TimePicker/index.tsx @@ -12,7 +12,9 @@ export default function TimePicker({ time, disabled = false, onChangeTime }: Tim * 시간-분 까지만 선택 가능 * 초는 0초를 디폴트로 넣는다 */ - const time = `${e.target.value}:00`; + const value = e.target.value; + const isMinuteEnd = value.split(":").length === 2; + const time = `${e.target.value}${isMinuteEnd ? `:00` : ""}`; onChangeTime(time); }; diff --git a/admin/src/constants/common.ts b/admin/src/constants/common.ts index 72066f83..7a6d140b 100644 --- a/admin/src/constants/common.ts +++ b/admin/src/constants/common.ts @@ -9,3 +9,8 @@ export const STATUS_MAP = { [EVENT_STATUS.DURING]: "활성화", [EVENT_STATUS.AFTER]: "종료", }; + +export const ERROR_MAP = { + CONFLICT: "409", + NOT_FOUND: "404", +} as const; diff --git a/admin/src/hooks/useFetch.ts b/admin/src/hooks/useFetch.ts index fb9b92c4..f529013a 100644 --- a/admin/src/hooks/useFetch.ts +++ b/admin/src/hooks/useFetch.ts @@ -10,14 +10,18 @@ export default function useFetch( const { showBoundary } = useErrorBoundary(); const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); const [isSuccess, setIsSuccess] = useState(false); const [isError, setIsError] = useState(false); + const [errorStatus, setErrorStatus] = useState(null); const [cookies] = useCookies([COOKIE_KEY.ACCESS_TOKEN]); const fetchData = async (params?: P) => { setIsError(false); setIsSuccess(false); + setIsLoading(true); + setErrorStatus(null); try { const data = await fetch(params as P, cookies[COOKIE_KEY.ACCESS_TOKEN]); @@ -25,12 +29,16 @@ export default function useFetch( setIsSuccess(!!data); } catch (error) { setIsError(true); - console.error(error); + if (error instanceof Error) { + setErrorStatus(error.message); + } if (showError) { showBoundary(error); } + } finally { + setIsLoading(false); } }; - return { data, isSuccess, isError, fetchData }; + return { data, isSuccess, isLoading, isError, errorStatus, fetchData }; } diff --git a/admin/src/hooks/useInfiniteFetch.ts b/admin/src/hooks/useInfiniteFetch.ts index 692ff063..2538618e 100644 --- a/admin/src/hooks/useInfiniteFetch.ts +++ b/admin/src/hooks/useInfiniteFetch.ts @@ -7,6 +7,7 @@ interface UseInfiniteFetchProps { initialPageParam?: number; getNextPageParam: (currentPageParam: number, lastPage: R) => number | undefined; startFetching?: boolean; + showError?: boolean; } interface InfiniteScrollData { @@ -17,6 +18,7 @@ interface InfiniteScrollData { hasNextPage: boolean; isSuccess: boolean; isError: boolean; + errorStatus: string | null; } export default function useInfiniteFetch({ @@ -24,6 +26,7 @@ export default function useInfiniteFetch({ initialPageParam = 0, getNextPageParam, startFetching = true, + showError = true, }: UseInfiniteFetchProps): InfiniteScrollData { const { showBoundary } = useErrorBoundary(); @@ -32,6 +35,8 @@ export default function useInfiniteFetch({ const [isLoading, setIsLoading] = useState(false); const [isSuccess, setIsSuccess] = useState(false); const [isError, setIsError] = useState(false); + const [errorStatus, setErrorStatus] = useState(null); + const [hasNextPage, setHasNextPage] = useState(true); const [totalLength, setTotalLength] = useState(0); @@ -53,6 +58,7 @@ export default function useInfiniteFetch({ setIsLoading(true); setIsError(false); setIsSuccess(false); + setErrorStatus(null); try { const lastPage = await fetch(currentPageParam); const nextPageParam = getNextPageParam(currentPageParam, lastPage); @@ -62,9 +68,14 @@ export default function useInfiniteFetch({ setHasNextPage(nextPageParam !== undefined); setIsSuccess(true); } catch (error) { - showBoundary(error); setIsError(true); setIsSuccess(false); + if (error instanceof Error) { + setErrorStatus(error.message); + } + if (showError) { + showBoundary(error); + } } finally { setIsLoading(false); } @@ -91,5 +102,6 @@ export default function useInfiniteFetch({ hasNextPage, isSuccess, isError, + errorStatus, }; } diff --git a/admin/src/pages/LotteryParticipantList/index.tsx b/admin/src/pages/LotteryParticipantList/index.tsx index 64273ccb..70c4a208 100644 --- a/admin/src/pages/LotteryParticipantList/index.tsx +++ b/admin/src/pages/LotteryParticipantList/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { useCookies } from "react-cookie"; import { useNavigate } from "react-router-dom"; import { LotteryAPI } from "@/apis/lotteryAPI"; @@ -123,6 +123,11 @@ export default function LotteryParticipantList() { patchLotteryExpectation(id); }; + const handleSubmitSearch = (e: FormEvent) => { + e.preventDefault(); + handleRefetch(); + }; + const expectations = useMemo( () => expectation.map((participant) => [ @@ -176,15 +181,15 @@ export default function LotteryParticipantList() {

-
+
- -
+ - diff --git a/admin/src/pages/LotteryWinner/index.tsx b/admin/src/pages/LotteryWinner/index.tsx index 8b769ad2..9f9442b1 100644 --- a/admin/src/pages/LotteryWinner/index.tsx +++ b/admin/src/pages/LotteryWinner/index.tsx @@ -2,10 +2,16 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { LotteryAPI } from "@/apis/lotteryAPI"; import Button from "@/components/Button"; +import Suspense from "@/components/Suspense"; import TabHeader from "@/components/TabHeader"; +import { ERROR_MAP } from "@/constants/common"; import useFetch from "@/hooks/useFetch"; import { LotteryEventType } from "@/types/lottery"; -import { GetLotteryResponse, PostLotteryWinnerResponse } from "@/types/lotteryApi"; +import { + DeleteLotteryWinnerResponse, + GetLotteryResponse, + PostLotteryWinnerResponse, +} from "@/types/lotteryApi"; export default function LotteryWinner() { const navigate = useNavigate(); @@ -18,8 +24,22 @@ export default function LotteryWinner() { fetchData: getLotteryEvent, } = useFetch((_, token) => LotteryAPI.getLottery(token)); - const { isSuccess: isSuccessPostLottery, fetchData: postLottery } = - useFetch((_, token) => LotteryAPI.postLotteryWinner(token)); + const { + isSuccess: isSuccessPostLottery, + isLoading: isLoadingPostLottery, + isError: isErrorPostLottery, + errorStatus: lotteryPostErrorStatus, + fetchData: postLottery, + } = useFetch( + (_, token) => LotteryAPI.postLotteryWinner(token), + false + ); + + const { isSuccess: isSuccessDeleteLottery, fetchData: deleteLottery } = + useFetch( + (_, token) => LotteryAPI.deleteLotteryWinner(token), + false + ); useEffect(() => { getLotteryEvent(); @@ -34,31 +54,48 @@ export default function LotteryWinner() { navigate("/lottery/winner-list"); } }, [isSuccessPostLottery]); + useEffect(() => { + if (isErrorPostLottery && lotteryPostErrorStatus === ERROR_MAP.CONFLICT) { + const isDelete = confirm("이미 추첨한 이벤트입니다. 삭제 후 다시 추첨하시겠습니까?"); + if (isDelete) { + deleteLottery(); + } + } + }, [isErrorPostLottery, lotteryPostErrorStatus]); + useEffect(() => { + if (isSuccessDeleteLottery) { + postLottery(); + } + }, [isSuccessDeleteLottery]); const handleLottery = () => { postLottery(); }; return ( -
- + +
+ -
-
-

전체 참여자 수

-

- {currentLottery.appliedCount} -

-

당첨자 수

-

- {currentLottery.winnerCount} -

-
+
+
+

+ 전체 참여자 수 +

+

+ {currentLottery.appliedCount} +

+

당첨자 수

+

+ {currentLottery.winnerCount} +

+
- + +
-
+
); } diff --git a/admin/src/pages/LotteryWinnerList/index.tsx b/admin/src/pages/LotteryWinnerList/index.tsx index b0469491..112f665d 100644 --- a/admin/src/pages/LotteryWinnerList/index.tsx +++ b/admin/src/pages/LotteryWinnerList/index.tsx @@ -1,10 +1,11 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { useCookies } from "react-cookie"; import { useNavigate } from "react-router-dom"; import { LotteryAPI } from "@/apis/lotteryAPI"; import Button from "@/components/Button"; import TabHeader from "@/components/TabHeader"; import Table from "@/components/Table"; +import { ERROR_MAP } from "@/constants/common"; import { COOKIE_KEY } from "@/constants/cookie"; import { LOTTERY_EXPECTATIONS_HEADER, LOTTERY_WINNER_HEADER } from "@/constants/lottery"; import useFetch from "@/hooks/useFetch"; @@ -30,6 +31,8 @@ export default function LotteryWinnerList() { const { data: winnerInfo, isSuccess: isSuccessGetLotteryWinner, + isError: isErrorGetLotteryWinner, + errorStatus: lotteryWinnerGetErrorStatus, fetchNextPage: getWinnerInfo, refetch: refetchWinnerInfo, } = useInfiniteFetch({ @@ -46,6 +49,7 @@ export default function LotteryWinnerList() { getNextPageParam: (currentPageParam: number, lastPage: GetLotteryWinnerResponse) => { return lastPage.isLastPage ? undefined : currentPageParam + 1; }, + showError: false, }); const { @@ -103,6 +107,12 @@ export default function LotteryWinnerList() { refetchLotteryExpectation(); } }, [isSuccessPatchLotteryExpectation]); + useEffect(() => { + if (isErrorGetLotteryWinner && lotteryWinnerGetErrorStatus === ERROR_MAP.NOT_FOUND) { + alert("아직 추첨되지 않은 이벤트입니다."); + navigate("/"); + } + }, [isErrorGetLotteryWinner, lotteryWinnerGetErrorStatus]); const handleRefetch = () => { phoneNumberRef.current = phoneNumberInputRef.current?.value || ""; @@ -122,6 +132,11 @@ export default function LotteryWinnerList() { patchLotteryExpectation(id); }; + const handleSubmitSearch = (e: FormEvent) => { + e.preventDefault(); + handleRefetch(); + }; + const expectations = useMemo( () => expectation.map((winner) => [ @@ -173,15 +188,15 @@ export default function LotteryWinnerList() {

당첨자 리스트

-
+
- -
+
- diff --git a/admin/src/pages/RushWinnerList/index.tsx b/admin/src/pages/RushWinnerList/index.tsx index a77bb9d4..aa3827f1 100644 --- a/admin/src/pages/RushWinnerList/index.tsx +++ b/admin/src/pages/RushWinnerList/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { useCookies } from "react-cookie"; import { useLocation, useNavigate } from "react-router-dom"; import { RushAPI } from "@/apis/rushAPI"; @@ -137,6 +137,11 @@ export default function RushWinnerList() { setSelectedOptionIdx(() => idx); }; + const handleSubmitSearch = (e: FormEvent) => { + e.preventDefault(); + handleSearchPhoneNumber(); + }; + const optionTitleList = useMemo( () => options @@ -198,21 +203,22 @@ export default function RushWinnerList() {

-
+
- -
+