diff --git a/admin/src/apis/lotteryAPI.ts b/admin/src/apis/lotteryAPI.ts index 7a33a7df..3ad2573c 100644 --- a/admin/src/apis/lotteryAPI.ts +++ b/admin/src/apis/lotteryAPI.ts @@ -6,6 +6,8 @@ import { GetLotteryWinnerParams, GetLotteryWinnerResponse, PostLotteryWinnerResponse, + PutLotteryParams, + PutLotteryResponse, } from "@/types/lotteryApi"; import { fetchWithTimeout } from "@/utils/fetchWithTimeout"; @@ -20,13 +22,13 @@ export const LotteryAPI = { return new Promise((resolve) => resolve([ { - lotteryEventId: 1, startDate: "2024-07-26", - startTime: "00:00", + startTime: "00:00:00", endDate: "2024-08-25", - endTime: "23:59", + endTime: "23:59:00", appliedCount: 1000000, winnerCount: 363, + status: "BEFORE", }, ]) ); @@ -40,6 +42,28 @@ export const LotteryAPI = { throw error; } }, + async putLottery(body: PutLotteryParams): Promise<PutLotteryResponse> { + try { + return new Promise((resolve) => + resolve({ + startDate: "2024-08-26", + startTime: "00:00:00", + endDate: "2024-09-25 23:59", + endTime: "00:00:00", + winnerCount: 363, + }) + ); + const response = await fetchWithTimeout(`${baseURL}`, { + method: "PUT", + headers: headers, + body: JSON.stringify(body), + }); + return response.json(); + } catch (error) { + console.error("Error:", error); + throw error; + } + }, async postLotteryWinner(): Promise<PostLotteryWinnerResponse> { try { return new Promise((resolve) => resolve({ message: "요청에 성공하였습니다." })); @@ -54,7 +78,6 @@ export const LotteryAPI = { } }, async getLotteryParticipant({ - id, size, page, phoneNumber, @@ -62,7 +85,7 @@ export const LotteryAPI = { try { return new Promise((resolve) => resolve({ - data: [ + participants: [ { id: 1, phoneNumber: "010-1111-2222", @@ -96,7 +119,7 @@ export const LotteryAPI = { }) ); const response = await fetchWithTimeout( - `${baseURL}/${id}/participants?size=${size}&page=${page}&number=${phoneNumber}`, + `${baseURL}/participants?size=${size}&page=${page}&number=${phoneNumber}`, { method: "GET", headers: headers, @@ -109,7 +132,6 @@ export const LotteryAPI = { } }, async getLotteryWinner({ - id, size, page, phoneNumber, @@ -117,7 +139,7 @@ export const LotteryAPI = { try { return new Promise((resolve) => resolve({ - data: [ + participants: [ { id: 1, phoneNumber: "010-1111-2222", @@ -145,7 +167,7 @@ export const LotteryAPI = { }) ); const response = await fetchWithTimeout( - `${baseURL}/${id}/winner?size=${size}&page=${page}&number=${phoneNumber}`, + `${baseURL}/winner?size=${size}&page=${page}&number=${phoneNumber}`, { method: "GET", headers: headers, @@ -158,7 +180,6 @@ export const LotteryAPI = { } }, async getLotteryExpectations({ - lotteryId, participantId, }: GetLotteryExpectationsParams): Promise<GetLotteryExpectationsResponse> { try { @@ -179,7 +200,7 @@ export const LotteryAPI = { ]) ); const response = await fetchWithTimeout( - `${baseURL}/${lotteryId}/participants/${participantId}/expectations`, + `${baseURL}/participants/${participantId}/expectations`, { method: "GET", headers: headers, diff --git a/admin/src/apis/rushAPI.ts b/admin/src/apis/rushAPI.ts index 34466536..e2dc4d28 100644 --- a/admin/src/apis/rushAPI.ts +++ b/admin/src/apis/rushAPI.ts @@ -1,9 +1,11 @@ import { + GetRushEventResponse, GetRushOptionsParams, GetRushOptionsResponse, GetRushParticipantListParams, GetRushParticipantListResponse, GetRushWinnerListParams, + PutRushEventResponse, } from "@/types/rushApi"; import { fetchWithTimeout } from "@/utils/fetchWithTimeout"; @@ -13,6 +15,119 @@ const headers = { }; export const RushAPI = { + async getRush(): Promise<GetRushEventResponse> { + try { + return new Promise((resolve) => + resolve([ + { + rushEventId: 1, + eventDate: "2024-07-25", + openTime: "20:00:00", + closeTime: "20:10:00", + winnerCount: 315, + prizeImageUrl: "prize1.png", + prizeDescription: "스타벅스 1만원 기프트카드", + status: "BEFORE", + leftOption: { + rushOptionId: 1, + mainText: "첫 차로 저렴한 차 사기", + subText: "첫 차는 가성비가 짱이지!", + resultMainText: "누구보다 가성비 갑인 캐스퍼 일렉트릭", + resultSubText: "전기차 평균보다 훨씬 저렴한 캐스퍼 일렉트릭!", + imageUrl: "https://cdn-icons-png.flaticon.com/512/660/660026.png", + }, + rightOption: { + rushOptionId: 2, + mainText: "첫 차로 성능 좋은 차 사기", + subText: "차는 당연히 성능이지!", + resultMainText: "필요한 건 다 갖춘 캐스퍼 일렉트릭", + resultSubText: "전기차 평균보다 훨씬 저렴한 캐스퍼 일렉트릭!", + imageUrl: "https://cdn-icons-png.flaticon.com/512/846/846551.png", + }, + }, + { + rushEventId: 2, + eventDate: "2024-07-26", + openTime: "20:00:00", + closeTime: "20:10:00", + winnerCount: 315, + prizeImageUrl: "prize2.png", + prizeDescription: "올리브영 1만원 기프트카드", + status: "DURING", + leftOption: { + rushOptionId: 1, + mainText: "첫 차로 저렴한 차 사기", + subText: "첫 차는 가성비가 짱이지!", + resultMainText: "누구보다 가성비 갑인 캐스퍼 일렉트릭", + resultSubText: "전기차 평균보다 훨씬 저렴한 캐스퍼 일렉트릭!", + imageUrl: "https://cdn-icons-png.flaticon.com/512/660/660026.png", + }, + rightOption: { + rushOptionId: 2, + mainText: "첫 차로 성능 좋은 차 사기", + subText: "차는 당연히 성능이지!", + resultMainText: "필요한 건 다 갖춘 캐스퍼 일렉트릭", + resultSubText: "전기차 평균보다 훨씬 저렴한 캐스퍼 일렉트릭!", + imageUrl: "https://cdn-icons-png.flaticon.com/512/846/846551.png", + }, + }, + { + rushEventId: 3, + eventDate: "2024-07-27", + openTime: "20:00:00", + closeTime: "20:10:00", + winnerCount: 315, + prizeImageUrl: "prize3.png", + prizeDescription: "배달의 민족 1만원 기프트카드", + status: "AFTER", + leftOption: { + rushOptionId: 1, + mainText: "첫 차로 저렴한 차 사기", + subText: "첫 차는 가성비가 짱이지!", + resultMainText: "누구보다 가성비 갑인 캐스퍼 일렉트릭", + resultSubText: "전기차 평균보다 훨씬 저렴한 캐스퍼 일렉트릭!", + imageUrl: "https://cdn-icons-png.flaticon.com/512/660/660026.png", + }, + rightOption: { + rushOptionId: 2, + mainText: "첫 차로 성능 좋은 차 사기", + subText: "차는 당연히 성능이지!", + resultMainText: "필요한 건 다 갖춘 캐스퍼 일렉트릭", + resultSubText: "전기차 평균보다 훨씬 저렴한 캐스퍼 일렉트릭!", + imageUrl: "https://cdn-icons-png.flaticon.com/512/846/846551.png", + }, + }, + ]) + ); + const response = await fetchWithTimeout(`${baseURL}`, { + method: "GET", + headers: headers, + }); + return response.json(); + } catch (error) { + console.error("Error:", error); + throw error; + } + }, + async putRush(body: FormData[]): Promise<PutRushEventResponse> { + try { + body.forEach((b) => { + for (let pair of b.entries()) { + console.log(pair[0] + ": " + pair[1]); + } + }); + return new Promise((resolve) => resolve([])); + const response = await fetchWithTimeout(`${baseURL}`, { + method: "PUT", + headers: { "Content-Type": "multipart/form-data" }, + body: JSON.stringify(body), + }); + return response.json(); + } catch (error) { + console.error("Error:", error); + throw error; + } + }, async getRushParticipantList({ id, phoneNumber, @@ -23,7 +138,7 @@ export const RushAPI = { try { return new Promise((resolve) => resolve({ - data: [ + participants: [ { id: 1, phoneNumber: "010-1111-2222", @@ -72,7 +187,7 @@ export const RushAPI = { try { return new Promise((resolve) => resolve({ - data: [ + participants: [ { id: 1, phoneNumber: "010-3843-6999", @@ -122,7 +237,7 @@ export const RushAPI = { subText: " 첫 차는 가성비가 짱이지!", resultMainText: "누구보다 가성비 갑인 캐스퍼 일렉트릭", resultSubText: "전기차 평균보다 훨씬 저렴한 캐스퍼 일렉트릭!", - imageUrl: "left_image.png", + imageUrl: "https://cdn-icons-png.flaticon.com/512/660/660026.png", }, { rushOptionId: 2, @@ -130,7 +245,7 @@ export const RushAPI = { subText: " 차는 당연히 성능이지!", resultMainText: "필요한 건 다 갖춘 캐스퍼 일렉트릭", resultSubText: "전기차 평균보다 훨씨니 저렴한 캐스퍼 일렉트릭!", - imageUrl: "left_image.png", + imageUrl: "https://cdn-icons-png.flaticon.com/512/660/660026.png", }, ]) ); diff --git a/admin/src/components/DatePicker/index.tsx b/admin/src/components/DatePicker/index.tsx index b35fe66f..f02e9cb9 100644 --- a/admin/src/components/DatePicker/index.tsx +++ b/admin/src/components/DatePicker/index.tsx @@ -2,10 +2,11 @@ import { ChangeEvent } from "react"; interface DatePickerProps { date: string; + disabled?: boolean; onChangeDate: (date: string) => void; } -export default function DatePicker({ date, onChangeDate }: DatePickerProps) { +export default function DatePicker({ date, disabled = false, onChangeDate }: DatePickerProps) { const handleChange = (e: ChangeEvent<HTMLInputElement>) => { onChangeDate(e.target.value); }; @@ -17,6 +18,7 @@ export default function DatePicker({ date, onChangeDate }: DatePickerProps) { </div> <input type="date" + disabled={disabled} value={date} onChange={handleChange} className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5" diff --git a/admin/src/components/FileInput/index.tsx b/admin/src/components/FileInput/index.tsx new file mode 100644 index 00000000..a9f652b3 --- /dev/null +++ b/admin/src/components/FileInput/index.tsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; + +interface FileInputProps { + selectedFile: File | string | null; + setSelectedFile: (file: File) => void; +} + +export default function FileInput({ selectedFile, setSelectedFile }: FileInputProps) { + const [previewUrl, setPreviewUrl] = useState<string | null>(null); + + useEffect(() => { + if (!selectedFile) { + setPreviewUrl(null); + return; + } + if (typeof selectedFile === "string") { + setPreviewUrl(selectedFile); + return; + } + + const objectUrl = URL.createObjectURL(selectedFile); + setPreviewUrl(objectUrl); + + return () => URL.revokeObjectURL(objectUrl); + }, [selectedFile]); + + const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0]; + if (file) { + setSelectedFile(file); + } + }; + + return ( + <div> + {previewUrl && ( + <img src={previewUrl} alt="Preview" style={{ width: "auto", maxHeight: "100px" }} /> + )} + <input type="file" accept="image/*" onChange={handleFileChange} /> + </div> + ); +} diff --git a/admin/src/components/TimePicker/index.tsx b/admin/src/components/TimePicker/index.tsx index e7aabe3f..e5f793ae 100644 --- a/admin/src/components/TimePicker/index.tsx +++ b/admin/src/components/TimePicker/index.tsx @@ -2,10 +2,11 @@ import { ChangeEvent } from "react"; interface TimePickerProps { time: string; + disabled?: boolean; onChangeTime: (time: string) => void; } -export default function TimePicker({ time, onChangeTime }: TimePickerProps) { +export default function TimePicker({ time, disabled = false, onChangeTime }: TimePickerProps) { const handleChange = (e: ChangeEvent<HTMLInputElement>) => { /** * 시간-분 까지만 선택 가능 @@ -36,6 +37,7 @@ export default function TimePicker({ time, onChangeTime }: TimePickerProps) { <input type="time" id="time" + disabled={disabled} className="bg-gray-50 border leading-none border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" min="09:00" max="18:00" diff --git a/admin/src/components/Toast/index.tsx b/admin/src/components/Toast/index.tsx new file mode 100644 index 00000000..2921a452 --- /dev/null +++ b/admin/src/components/Toast/index.tsx @@ -0,0 +1,16 @@ +interface ToastProps { + message: string; + isVisible: boolean; +} + +export default function Toast({ message, isVisible }: ToastProps) { + return ( + <div + className={`fixed top-[88px] left-[50%] translate-x-[-50%] h-heading-4-bold bg-neutral-400 text-white px-6 py-4 rounded-lg transition-opacity ${ + isVisible ? "opacity-100" : "opacity-0" + }`} + > + {message} + </div> + ); +} diff --git a/admin/src/constants/common.ts b/admin/src/constants/common.ts new file mode 100644 index 00000000..72066f83 --- /dev/null +++ b/admin/src/constants/common.ts @@ -0,0 +1,11 @@ +export const EVENT_STATUS = { + BEFORE: "BEFORE", + DURING: "DURING", + AFTER: "AFTER", +} as const; + +export const STATUS_MAP = { + [EVENT_STATUS.BEFORE]: "오픈 전", + [EVENT_STATUS.DURING]: "활성화", + [EVENT_STATUS.AFTER]: "종료", +}; diff --git a/admin/src/constants/rush.ts b/admin/src/constants/rush.ts index 51ac9c3b..ef34c41e 100644 --- a/admin/src/constants/rush.ts +++ b/admin/src/constants/rush.ts @@ -9,5 +9,9 @@ export const EVENT_LIST_HEADER = [ "선착순 당첨 인원 수", "진행 상태", "참여자 리스트 보기", - "관리", ]; + +export const QUERY_OPTION = { + OPTION: "option", + PRIZE: "prize", +} as const; diff --git a/admin/src/contexts/rushEventContext.tsx b/admin/src/contexts/rushEventContext.tsx index b223d474..22ca288a 100644 --- a/admin/src/contexts/rushEventContext.tsx +++ b/admin/src/contexts/rushEventContext.tsx @@ -4,7 +4,6 @@ import { RushEventAction, RushEventDispatchType, RushEventStateType, - RushPrizeType, } from "@/types/rush"; export const RushEventStateContext = createContext<RushEventStateType | null>(null); @@ -12,8 +11,6 @@ export const RushEventDispatchContext = createContext<RushEventDispatchType | nu const initialState: RushEventStateType = { rushList: [], - selectOptions: [], - prize: {} as RushPrizeType, }; const casperCustomReducer = ( @@ -23,10 +20,6 @@ const casperCustomReducer = ( switch (action.type) { case RUSH_ACTION.SET_EVENT_LIST: return { ...state, rushList: action.payload }; - case RUSH_ACTION.SET_OPTION: - return { ...state, selectOptions: action.payload }; - case RUSH_ACTION.SET_PRIZE: - return { ...state, prize: action.payload }; default: return state; } diff --git a/admin/src/features/Rush/EventList.tsx b/admin/src/features/Rush/EventList.tsx index c1baf367..aaea04b5 100644 --- a/admin/src/features/Rush/EventList.tsx +++ b/admin/src/features/Rush/EventList.tsx @@ -1,56 +1,53 @@ import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; +import { RushAPI } from "@/apis/rushAPI"; import Button from "@/components/Button"; import DatePicker from "@/components/DatePicker"; import Table from "@/components/Table"; import TimePicker from "@/components/TimePicker"; -import { EVENT_LIST_HEADER } from "@/constants/rush"; +import { EVENT_STATUS, STATUS_MAP } from "@/constants/common"; +import { EVENT_LIST_HEADER, QUERY_OPTION } from "@/constants/rush"; +import useFetch from "@/hooks/useFetch"; import useRushEventDispatchContext from "@/hooks/useRushEventDispatchContext"; import useRushEventStateContext from "@/hooks/useRushEventStateContext"; +import useToast from "@/hooks/useToast"; import { RUSH_ACTION } from "@/types/rush"; +import { PutRushEventResponse } from "@/types/rushApi"; import { getTimeDifference } from "@/utils/getTimeDifference"; export default function EventList() { const navigate = useNavigate(); + const { showToast, ToastComponent } = useToast("수정 사항이 반영되었습니다!"); + const { rushList } = useRushEventStateContext(); const dispatch = useRushEventDispatchContext(); + const { isSuccess: isSuccessPutRush, fetchData: putRush } = useFetch<PutRushEventResponse>(() => + RushAPI.putRush(getFormData()) + ); + useEffect(() => { - // TODO: 데이터 패칭 로직 구현 - dispatch({ - type: RUSH_ACTION.SET_EVENT_LIST, - payload: [ - { - rushEventId: 1, - eventDate: "2024-07-25", - openTime: "20:00:00", - closeTime: "20:10:00", - winnerCount: 315, - prizeImageUrl: "prize1.png", - prizeDescription: "스타벅스 1만원 기프트카드", - }, - { - rushEventId: 2, - eventDate: "2024-07-26", - openTime: "20:00:00", - closeTime: "20:10:00", - winnerCount: 315, - prizeImageUrl: "prize2.png", - prizeDescription: "올리브영 1만원 기프트카드", - }, - { - rushEventId: 2, - eventDate: "2024-07-27", - openTime: "20:00:00", - closeTime: "20:10:00", - winnerCount: 315, - prizeImageUrl: "prize3.png", - prizeDescription: "배달의 민족 1만원 기프트카드", - }, - ], + if (isSuccessPutRush) { + showToast(); + } + }, [isSuccessPutRush]); + + const getFormData = () => { + return rushList.map((rush) => { + const formData = new FormData(); + Object.entries(rush).forEach(([key, value]) => { + if (typeof value === "object") { + Object.entries(value).forEach(([subKey, subValue]) => { + formData.append(`${key}[${subKey}]`, subValue as Blob); + }); + } else { + formData.append(key, value.toString()); + } + }); + return formData; }); - }, []); + }; const handleChangeItem = (key: string, changeIdx: number, text: string | number) => { const updatedTableItemList = rushList.map((item, idx) => { @@ -63,48 +60,55 @@ export default function EventList() { dispatch({ type: RUSH_ACTION.SET_EVENT_LIST, payload: updatedTableItemList }); }; + const handleUpdate = () => { + putRush(); + }; + const getTableData = () => { return rushList.map((item, idx) => { + const canEdit = item.status !== EVENT_STATUS.BEFORE; return [ item.rushEventId, <DatePicker + disabled={canEdit} date={item.eventDate} onChangeDate={(date) => handleChangeItem("eventDate", idx, date)} />, <TimePicker + disabled={canEdit} time={item.openTime} onChangeTime={(time) => handleChangeItem("openTime", idx, time)} />, <TimePicker + disabled={canEdit} time={item.closeTime} onChangeTime={(time) => handleChangeItem("closeTime", idx, time)} />, getTimeDifference(item.openTime, item.closeTime), <Button + disabled={canEdit} buttonSize="sm" - onClick={() => - navigate("/rush/select-form", { state: { id: item.rushEventId } }) - } + onClick={() => navigate(`/rush?q=${QUERY_OPTION.OPTION}`, { state: { idx } })} > 선택지 관리 </Button>, <Button + disabled={canEdit} buttonSize="sm" - onClick={() => - navigate("/rush/prize-form", { state: { id: item.rushEventId } }) - } + onClick={() => navigate(`/rush?q=${QUERY_OPTION.PRIZE}`, { state: { idx } })} > 경품 관리 </Button>, <div className="flex w-full border-b"> <input + disabled={canEdit} value={item.winnerCount} onChange={(e) => handleChangeItem("winnerCount", idx, parseInt(e.target.value) || 0) } /> </div>, - "오픈 전", + STATUS_MAP[item.status], <Button buttonSize="sm" onClick={() => @@ -113,20 +117,21 @@ export default function EventList() { > 참여자 리스트 보기 </Button>, - <Button buttonSize="sm">삭제</Button>, ]; }); }; return ( <div className="w-[1560px] flex flex-col items-center mt-10 gap-4"> - <div className="self-start"> - <Button buttonSize="sm">이벤트 진행 날짜 추가</Button> + <div className="mt-4"> + <Table headers={EVENT_LIST_HEADER} data={getTableData()} /> </div> - <Table headers={EVENT_LIST_HEADER} data={getTableData()} /> + <Button buttonSize="lg" onClick={handleUpdate}> + 수정사항 업데이트 + </Button> - <Button buttonSize="lg">수정사항 업데이트</Button> + {ToastComponent} </div> ); } diff --git a/admin/src/features/Rush/RushPrizeForm.tsx b/admin/src/features/Rush/RushPrizeForm.tsx new file mode 100644 index 00000000..04d678d6 --- /dev/null +++ b/admin/src/features/Rush/RushPrizeForm.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import Button from "@/components/Button"; +import FileInput from "@/components/FileInput"; +import SelectForm from "@/components/SelectForm"; +import TextField from "@/components/TextField"; +import useRushEventDispatchContext from "@/hooks/useRushEventDispatchContext"; +import useRushEventStateContext from "@/hooks/useRushEventStateContext"; +import useToast from "@/hooks/useToast"; +import { RUSH_ACTION, RushPrizeType } from "@/types/rush"; + +export default function RushPrizeForm() { + const location = useLocation(); + const navigate = useNavigate(); + + const rushIdx = location.state.idx; + + const { rushList } = useRushEventStateContext(); + const dispatch = useRushEventDispatchContext(); + + const { showToast, ToastComponent } = useToast("입력한 내용이 임시 저장되었습니다!"); + + const [prizeState, setPrizeState] = useState<RushPrizeType>({} as RushPrizeType); + + useEffect(() => { + if (rushIdx !== undefined) { + setPrizeState({ + prizeImageUrl: rushList[rushIdx].prizeImageUrl, + prizeDescription: rushList[rushIdx].prizeDescription, + }); + } + }, [rushList]); + + const handleUpdate = () => { + const updatedTableItemList = rushList.map((item, idx) => { + if (idx === rushIdx) { + return { ...item, ...prizeState }; + } + return { ...item }; + }); + + dispatch({ + type: RUSH_ACTION.SET_EVENT_LIST, + payload: updatedTableItemList, + }); + + showToast(); + }; + + const option = [ + [ + "이미지", + <FileInput + selectedFile={prizeState.prizeImageUrl} + setSelectedFile={(file) => setPrizeState({ ...prizeState, prizeImageUrl: file })} + />, + ], + [ + "경품 이름 (20자 이내)", + <TextField + value={prizeState.prizeDescription} + onChange={(e) => setPrizeState({ ...prizeState, prizeDescription: e.target.value })} + />, + ], + ]; + + return ( + <div className="w-[1560px] flex flex-col items-center justify-center gap-8 mt-10"> + <div className="flex items-center gap-2 self-start"> + <img + alt="뒤로 가기 버튼" + src="/assets/icons/left-arrow.svg" + className="cursor-pointer" + onClick={() => navigate(-1)} + /> + <p className="h-body-1-medium">밸런스 게임 경품 관리</p> + </div> + + <SelectForm header="경품 관리" data={option} /> + + <Button buttonSize="lg" onClick={handleUpdate}> + 임시 저장 + </Button> + + {ToastComponent} + </div> + ); +} diff --git a/admin/src/features/Rush/RushSelectForm.tsx b/admin/src/features/Rush/RushSelectForm.tsx new file mode 100644 index 00000000..9c1bf57b --- /dev/null +++ b/admin/src/features/Rush/RushSelectForm.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import Button from "@/components/Button"; +import FileInput from "@/components/FileInput"; +import SelectForm from "@/components/SelectForm"; +import TextField from "@/components/TextField"; +import useRushEventDispatchContext from "@/hooks/useRushEventDispatchContext"; +import useRushEventStateContext from "@/hooks/useRushEventStateContext"; +import useToast from "@/hooks/useToast"; +import { RUSH_ACTION, RushOptionType } from "@/types/rush"; + +export default function RushSelectForm() { + const location = useLocation(); + const navigate = useNavigate(); + + const rushIdx = location.state.idx; + + const { rushList } = useRushEventStateContext(); + const dispatch = useRushEventDispatchContext(); + + const { showToast, ToastComponent } = useToast("입력한 내용이 임시 저장되었습니다!"); + + const [selectOptionState, setSelectOptionState] = useState<RushOptionType[]>([]); + + useEffect(() => { + if (rushIdx !== undefined) { + setSelectOptionState([rushList[rushIdx].leftOption, rushList[rushIdx].rightOption]); + } + }, [rushList]); + + const handleUpdate = () => { + const updatedTableItemList = rushList.map((item, idx) => { + if (idx === rushIdx) { + return { + ...item, + leftOption: selectOptionState[0], + rightOption: selectOptionState[1], + }; + } + return { ...item }; + }); + + dispatch({ + type: RUSH_ACTION.SET_EVENT_LIST, + payload: updatedTableItemList, + }); + + showToast(); + }; + + const handleChangeItem = (key: string, changeIdx: number, text: string | File) => { + const updatedItem = selectOptionState.map((item, idx) => { + if (idx === changeIdx) { + return { ...item, [key]: text }; + } + return { ...item }; + }); + + setSelectOptionState(updatedItem); + }; + + const getSelectOption = (idx: number) => { + if (selectOptionState.length >= 2) { + return [ + [ + "메인 문구 (15자 이내)", + <TextField + value={selectOptionState[idx].mainText} + onChange={(e) => handleChangeItem("mainText", idx, e.target.value)} + />, + ], + [ + "서브 문구 (40자 이내)", + <TextField + value={selectOptionState[idx].subText} + onChange={(e) => handleChangeItem("subText", idx, e.target.value)} + />, + ], + ]; + } + return []; + }; + const getSelectOptionResult = (idx: number) => { + if (selectOptionState.length >= 2) { + return [ + [ + "이미지", + <FileInput + selectedFile={selectOptionState[idx].imageUrl} + setSelectedFile={(file) => handleChangeItem("imageUrl", idx, file)} + />, + ], + [ + "메인 문구 (20자 이내)", + <TextField + value={selectOptionState[idx].resultMainText} + onChange={(e) => handleChangeItem("resultMainText", idx, e.target.value)} + />, + ], + [ + "서브 문구 (45자 이내)", + <TextField + value={selectOptionState[idx].resultSubText} + onChange={(e) => handleChangeItem("resultSubText", idx, e.target.value)} + />, + ], + ]; + } + return []; + }; + + return ( + <div className="w-[1560px] flex flex-col items-center justify-center gap-8 mt-10"> + <div className="flex items-center gap-2 self-start"> + <img + alt="뒤로 가기 버튼" + src="/assets/icons/left-arrow.svg" + className="cursor-pointer" + onClick={() => navigate(-1)} + /> + <p className="h-body-1-medium">밸런스 게임 선택지 관리</p> + </div> + + <div className="flex gap-10"> + <div className="flex flex-col gap-4"> + <SelectForm header="옵션 1 선택지" data={getSelectOption(0)} /> + <SelectForm header="옵션 1 선택 결과 정보" data={getSelectOptionResult(0)} /> + </div> + <div className="flex flex-col gap-4"> + <SelectForm header="옵션 2 선택지" data={getSelectOption(1)} /> + <SelectForm header="옵션 2 선택 결과 정보" data={getSelectOptionResult(1)} /> + </div> + </div> + + <Button buttonSize="lg" onClick={handleUpdate}> + 임시 저장 + </Button> + + {ToastComponent} + </div> + ); +} diff --git a/admin/src/hooks/useFetch.ts b/admin/src/hooks/useFetch.ts index 3859a150..9e334f8f 100644 --- a/admin/src/hooks/useFetch.ts +++ b/admin/src/hooks/useFetch.ts @@ -9,6 +9,9 @@ export default function useFetch<T, P = void>(fetch: (params: P) => Promise<T>) const [isError, setIsError] = useState<boolean>(false); const fetchData = async (params?: P) => { + setIsError(false); + setIsSuccess(false); + try { const data = await fetch(params as P); setData(data); diff --git a/admin/src/hooks/useInfiniteFetch.ts b/admin/src/hooks/useInfiniteFetch.ts index fa432e48..11e705a2 100644 --- a/admin/src/hooks/useInfiniteFetch.ts +++ b/admin/src/hooks/useInfiniteFetch.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { useErrorBoundary } from "react-error-boundary"; -import { InfiniteListData } from "@/types/common"; +import { InfiniteParticipantListData } from "@/types/common"; interface UseInfiniteFetchProps<R> { fetch: (pageParam: number) => Promise<R>; @@ -24,7 +24,7 @@ export default function useInfiniteFetch<T>({ initialPageParam, getNextPageParam, startFetching = true, -}: UseInfiniteFetchProps<InfiniteListData<T>>): InfiniteScrollData<T> { +}: UseInfiniteFetchProps<InfiniteParticipantListData<T>>): InfiniteScrollData<T> { const { showBoundary } = useErrorBoundary(); const [data, setData] = useState<T[]>([]); @@ -39,11 +39,13 @@ export default function useInfiniteFetch<T>({ if (!hasNextPage || isLoading || currentPageParam === undefined) return; setIsLoading(true); + setIsError(false); + setIsSuccess(false); try { const lastPage = await fetch(currentPageParam); const nextPageParam = getNextPageParam(currentPageParam, lastPage); - setData([...data, ...lastPage.data]); + setData([...data, ...lastPage.participants]); setCurrentPageParam(nextPageParam); setHasNextPage(nextPageParam !== undefined); setTotalLength(lastPage.totalParticipants); diff --git a/admin/src/hooks/useToast.tsx b/admin/src/hooks/useToast.tsx new file mode 100644 index 00000000..dabd5c10 --- /dev/null +++ b/admin/src/hooks/useToast.tsx @@ -0,0 +1,24 @@ +import { useCallback, useEffect, useState } from "react"; +import Toast from "../components/Toast"; + +export default function useToast(message: string, duration: number = 3000) { + const [visible, setVisible] = useState(false); + + const showToast = useCallback(() => { + setVisible(true); + }, []); + + useEffect(() => { + if (visible) { + const timer = setTimeout(() => { + setVisible(false); + }, duration); + + return () => clearTimeout(timer); + } + }, [visible, duration]); + + const ToastComponent = <Toast message={message} isVisible={visible} />; + + return { showToast, ToastComponent }; +} diff --git a/admin/src/pages/Lottery/index.tsx b/admin/src/pages/Lottery/index.tsx index 90856ae8..d5d23332 100644 --- a/admin/src/pages/Lottery/index.tsx +++ b/admin/src/pages/Lottery/index.tsx @@ -1,31 +1,46 @@ import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useLoaderData, useNavigate } from "react-router-dom"; +import { LotteryAPI } from "@/apis/lotteryAPI"; import Button from "@/components/Button"; import DatePicker from "@/components/DatePicker"; import TabHeader from "@/components/TabHeader"; import Table from "@/components/Table"; import TimePicker from "@/components/TimePicker"; +import { STATUS_MAP } from "@/constants/common"; import { LOTTERY_HEADER } from "@/constants/lottery"; +import useFetch from "@/hooks/useFetch"; +import useToast from "@/hooks/useToast"; import { LotteryEventType } from "@/types/lottery"; +import { PutLotteryResponse } from "@/types/lotteryApi"; +import { getDateDifference } from "@/utils/getDateDifference"; export default function Lottery() { const navigate = useNavigate(); + const { showToast, ToastComponent } = useToast("수정 사항이 반영되었습니다!"); + + const lotteryData = useLoaderData() as LotteryEventType[]; const [lottery, setLottery] = useState<LotteryEventType>({} as LotteryEventType); - useEffect(() => { - const data = { - lotteryEventId: 1, - startDate: "2024-07-26", - startTime: "00:00", - endDate: "2024-08-25", - endTime: "23:59", - appliedCount: 1000000, - winnerCount: 363, - }; + const { isSuccess: isSuccessPostLottery, fetchData: postLottery } = + useFetch<PutLotteryResponse>(() => + LotteryAPI.putLottery({ + startDateTime: `${lottery.startDate} ${lottery.startTime}`, + endDateTime: `${lottery.endDate} ${lottery.endTime}`, + winnerCount: lottery.winnerCount, + }) + ); - setLottery(data); + useEffect(() => { + if (lotteryData.length !== 0) { + setLottery(lotteryData[0]); + } }, []); + useEffect(() => { + if (isSuccessPostLottery) { + showToast(); + } + }, [isSuccessPostLottery]); const handleChangeItem = (key: string, text: string | number) => { setLottery({ ...lottery, [key]: text }); @@ -50,7 +65,7 @@ export default function Lottery() { time={lottery.endTime} onChangeTime={(time) => handleChangeItem("endTime", time)} />, - "61일", + getDateDifference(lottery.startDate, lottery.endDate), <div className="border-b flex w-full"> <input value={lottery.winnerCount} @@ -59,13 +74,13 @@ export default function Lottery() { } /> </div>, - "활성화", + STATUS_MAP[lottery.status], ], ]; }; const handleUpdate = () => { - // TODO: update API 요청 + postLottery(); }; return ( @@ -76,24 +91,13 @@ export default function Lottery() { <Table headers={LOTTERY_HEADER} data={getLotteryData()} height="auto" /> <div className="self-end flex gap-4"> - <Button - buttonSize="sm" - onClick={() => - navigate("/lottery/participant-list", { - state: { id: lottery.lotteryEventId }, - }) - } - > + <Button buttonSize="sm" onClick={() => navigate("/lottery/participant-list")}> 참여자 리스트 보러가기 </Button> - <Button - buttonSize="sm" - onClick={() => - navigate("/lottery/winner-list", { - state: { id: lottery.lotteryEventId }, - }) - } - > + <Button buttonSize="sm" onClick={() => navigate("/lottery/winner")}> + 당첨자 추첨하기 + </Button> + <Button buttonSize="sm" onClick={() => navigate("/lottery/winner-list")}> 당첨자 보러가기 </Button> </div> @@ -102,6 +106,8 @@ export default function Lottery() { 수정사항 업데이트 </Button> </div> + + {ToastComponent} </div> ); } diff --git a/admin/src/pages/LotteryParticipantList/index.tsx b/admin/src/pages/LotteryParticipantList/index.tsx index 8daf293f..36b012bd 100644 --- a/admin/src/pages/LotteryParticipantList/index.tsx +++ b/admin/src/pages/LotteryParticipantList/index.tsx @@ -1,24 +1,22 @@ -import { useMemo, useRef, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useEffect, useMemo, useRef, useState } from "react"; +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 { LOTTERY_EXPECTATIONS_HEADER, LOTTERY_PARTICIPANT_HEADER } from "@/constants/lottery"; +import useFetch from "@/hooks/useFetch"; import useInfiniteFetch from "@/hooks/useInfiniteFetch"; import useIntersectionObserver from "@/hooks/useIntersectionObserver"; import useModal from "@/hooks/useModal"; import { LotteryExpectationsType } from "@/types/lottery"; -import { GetLotteryParticipantResponse } from "@/types/lotteryApi"; +import { GetLotteryExpectationsResponse, GetLotteryParticipantResponse } from "@/types/lotteryApi"; export default function LotteryParticipantList() { - const location = useLocation(); const navigate = useNavigate(); - const lotteryId = location.state.id; - const { handleOpenModal, ModalComponent } = useModal(); - const [selectedParticipant, setSelectedParticipant] = useState<LotteryExpectationsType[]>([]); + const [selectedExpectation, setSelectedExpectation] = useState<LotteryExpectationsType[]>([]); const phoneNumberRef = useRef<string>(""); const phoneNumberInputRef = useRef<HTMLInputElement>(null); @@ -31,7 +29,6 @@ export default function LotteryParticipantList() { } = useInfiniteFetch({ fetch: (pageParam: number) => LotteryAPI.getLotteryParticipant({ - id: lotteryId, size: 10, page: pageParam, phoneNumber: phoneNumberRef.current, @@ -42,32 +39,43 @@ export default function LotteryParticipantList() { }, }); + const { + data: expectation, + isSuccess: isSuccessGetLotteryExpectation, + fetchData: getLotteryExpectation, + } = useFetch<GetLotteryExpectationsResponse, number>((participantId: number) => + LotteryAPI.getLotteryExpectations({ + participantId: participantId, + }) + ); + const tableContainerRef = useRef<HTMLDivElement>(null); const { targetRef } = useIntersectionObserver<HTMLTableRowElement>({ onIntersect: getParticipantInfo, enabled: isSuccessGetParticipant, }); + useEffect(() => { + if (expectation && isSuccessGetLotteryExpectation) { + setSelectedExpectation(expectation); + } + }, [expectation, isSuccessGetLotteryExpectation]); + const handleRefetch = () => { phoneNumberRef.current = phoneNumberInputRef.current?.value || ""; refetchParticipantInfo(); }; const handleLotteryWinner = () => { - navigate("/lottery/winner-list", { state: { id: lotteryId } }); + navigate("/lottery/winner-list"); }; const handleClickExpectation = async (participantId: number) => { handleOpenModal(); - - const data = await LotteryAPI.getLotteryExpectations({ - lotteryId, - participantId: participantId, - }); - setSelectedParticipant(data); + getLotteryExpectation(participantId); }; - const expectations = selectedParticipant.map((participant) => [ + const expectations = selectedExpectation.map((participant) => [ participant.casperId, participant.expectation, ]); diff --git a/admin/src/pages/LotteryWinner/index.tsx b/admin/src/pages/LotteryWinner/index.tsx index 82b9d972..c121eafe 100644 --- a/admin/src/pages/LotteryWinner/index.tsx +++ b/admin/src/pages/LotteryWinner/index.tsx @@ -1,41 +1,33 @@ -import { ChangeEvent, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useLoaderData, useNavigate } from "react-router-dom"; import { LotteryAPI } from "@/apis/lotteryAPI"; import Button from "@/components/Button"; import TabHeader from "@/components/TabHeader"; import useFetch from "@/hooks/useFetch"; +import { LotteryEventType } from "@/types/lottery"; import { GetLotteryResponse, PostLotteryWinnerResponse } from "@/types/lotteryApi"; export default function LotteryWinner() { const lottery = useLoaderData() as GetLotteryResponse; - const lotteryId = lottery.length !== 0 ? lottery[0].lotteryEventId : -1; const navigate = useNavigate(); - const [totalCount, setTotalCount] = useState<number>(0); - const [giftCount, setGiftCount] = useState<number>(0); + const [currentLottery, setCurrentLottery] = useState<LotteryEventType>({} as LotteryEventType); const { isSuccess: isSuccessPostLottery, fetchData: postLottery } = useFetch<PostLotteryWinnerResponse>(() => LotteryAPI.postLotteryWinner()); useEffect(() => { if (lottery.length !== 0) { - const currentLotttery = lottery[0]; - setGiftCount(currentLotttery.winnerCount); - setTotalCount(currentLotttery.appliedCount); + setCurrentLottery(lottery[0]); } }, [lottery]); useEffect(() => { if (isSuccessPostLottery) { - navigate("/lottery/winner-list", { state: { id: lotteryId } }); + navigate("/lottery/winner-list"); } }, [isSuccessPostLottery]); - const handleChangeInput = (e: ChangeEvent<HTMLInputElement>) => { - const count = parseInt(e.target.value); - setGiftCount(count || 0); - }; - const handleLottery = () => { postLottery(); }; @@ -47,11 +39,13 @@ export default function LotteryWinner() { <div className="flex flex-col h-full items-center justify-center gap-8 pb-40"> <div className="flex border"> <p className="px-6 py-4 w-[200px] bg-gray-50 h-body-1-bold">전체 참여자 수</p> - <p className="px-6 py-4 w-[200px] h-body-1-regular">{totalCount}</p> + <p className="px-6 py-4 w-[200px] h-body-1-regular"> + {currentLottery.appliedCount} + </p> <p className="px-6 py-4 w-[200px] bg-gray-50 h-body-1-bold">당첨자 수</p> - <div className="self-center mx-4 border-b"> - <input value={giftCount} onChange={handleChangeInput} /> - </div> + <p className="px-6 py-4 w-[200px] h-body-1-regular"> + {currentLottery.winnerCount} + </p> </div> <Button buttonSize="lg" onClick={handleLottery}> diff --git a/admin/src/pages/LotteryWinnerList/index.tsx b/admin/src/pages/LotteryWinnerList/index.tsx index 89fe747c..7e96d4b3 100644 --- a/admin/src/pages/LotteryWinnerList/index.tsx +++ b/admin/src/pages/LotteryWinnerList/index.tsx @@ -1,22 +1,20 @@ -import { useMemo, useRef, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useEffect, useMemo, useRef, useState } from "react"; +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 { LOTTERY_EXPECTATIONS_HEADER, LOTTERY_WINNER_HEADER } from "@/constants/lottery"; +import useFetch from "@/hooks/useFetch"; import useInfiniteFetch from "@/hooks/useInfiniteFetch"; import useIntersectionObserver from "@/hooks/useIntersectionObserver"; import useModal from "@/hooks/useModal"; import { LotteryExpectationsType } from "@/types/lottery"; -import { GetLotteryWinnerResponse } from "@/types/lotteryApi"; +import { GetLotteryExpectationsResponse, GetLotteryWinnerResponse } from "@/types/lotteryApi"; export default function LotteryWinnerList() { - const location = useLocation(); const navigate = useNavigate(); - const lotteryId = location.state.id; - const { handleOpenModal, ModalComponent } = useModal(); const [selectedWinner, setSelectedWinner] = useState<LotteryExpectationsType[]>([]); const phoneNumberRef = useRef<string>(""); @@ -30,7 +28,6 @@ export default function LotteryWinnerList() { } = useInfiniteFetch({ fetch: (pageParam: number) => LotteryAPI.getLotteryWinner({ - id: lotteryId, size: 10, page: pageParam, phoneNumber: phoneNumberRef.current, @@ -41,12 +38,28 @@ export default function LotteryWinnerList() { }, }); + const { + data: expectation, + isSuccess: isSuccessGetLotteryExpectation, + fetchData: getLotteryExpectation, + } = useFetch<GetLotteryExpectationsResponse, number>((winnerId: number) => + LotteryAPI.getLotteryExpectations({ + participantId: winnerId, + }) + ); + const tableContainerRef = useRef<HTMLDivElement>(null); const { targetRef } = useIntersectionObserver<HTMLTableRowElement>({ onIntersect: getWinnerInfo, enabled: isSuccessGetLotteryWinner, }); + useEffect(() => { + if (expectation && isSuccessGetLotteryExpectation) { + setSelectedWinner(expectation); + } + }, [expectation, isSuccessGetLotteryExpectation]); + const handleRefetch = () => { phoneNumberRef.current = phoneNumberInputRef.current?.value || ""; refetchWinnerInfo(); @@ -58,12 +71,7 @@ export default function LotteryWinnerList() { const handleClickExpectation = async (winnerId: number) => { handleOpenModal(); - - const data = await LotteryAPI.getLotteryExpectations({ - lotteryId, - participantId: winnerId, - }); - setSelectedWinner(data); + getLotteryExpectation(winnerId); }; const expectations = selectedWinner.map((winner) => [winner.casperId, winner.expectation]); @@ -129,7 +137,7 @@ export default function LotteryWinnerList() { </div> <ModalComponent> - <Table headers={LOTTERY_EXPECTATIONS_HEADER} data={expectations} /> + <Table headers={LOTTERY_EXPECTATIONS_HEADER} data={expectations} height="auto" /> </ModalComponent> </div> ); diff --git a/admin/src/pages/Rush/index.tsx b/admin/src/pages/Rush/index.tsx index 5f200e1d..724b3506 100644 --- a/admin/src/pages/Rush/index.tsx +++ b/admin/src/pages/Rush/index.tsx @@ -1,53 +1,44 @@ -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; +import { useLoaderData, useSearchParams } from "react-router-dom"; import TabHeader from "@/components/TabHeader"; +import { QUERY_OPTION } from "@/constants/rush"; import EventList from "@/features/Rush/EventList"; +import RushPrizeForm from "@/features/Rush/RushPrizeForm"; +import RushSelectForm from "@/features/Rush/RushSelectForm"; import useRushEventDispatchContext from "@/hooks/useRushEventDispatchContext"; import { RUSH_ACTION } from "@/types/rush"; +import { GetRushEventResponse } from "@/types/rushApi"; export default function Rush() { + const [searchParams] = useSearchParams(); const dispatch = useRushEventDispatchContext(); + const rushEvent = useLoaderData() as GetRushEventResponse; + + const query = searchParams.get("q"); + useEffect(() => { - // TODO: 데이터 패칭 로직 구현 dispatch({ type: RUSH_ACTION.SET_EVENT_LIST, - payload: [ - { - rushEventId: 1, - eventDate: "2024-07-25", - openTime: "20:00:00", - closeTime: "20:10:00", - winnerCount: 315, - prizeImageUrl: "prize1.png", - prizeDescription: "스타벅스 1만원 기프트카드", - }, - { - rushEventId: 2, - eventDate: "2024-07-26", - openTime: "20:00:00", - closeTime: "20:10:00", - winnerCount: 315, - prizeImageUrl: "prize2.png", - prizeDescription: "올리브영 1만원 기프트카드", - }, - { - rushEventId: 2, - eventDate: "2024-07-27", - openTime: "20:00:00", - closeTime: "20:10:00", - winnerCount: 315, - prizeImageUrl: "prize3.png", - prizeDescription: "배달의 민족 1만원 기프트카드", - }, - ], + payload: rushEvent, }); }, []); + const renderElement = useCallback(() => { + if (query === QUERY_OPTION.OPTION) { + return <RushSelectForm />; + } else if (query === QUERY_OPTION.PRIZE) { + return <RushPrizeForm />; + } + + return <EventList />; + }, [query, rushEvent]); + return ( <div className="flex flex-col items-center"> <TabHeader /> - <EventList /> + {renderElement()} </div> ); } diff --git a/admin/src/pages/RushPrizeForm/index.tsx b/admin/src/pages/RushPrizeForm/index.tsx deleted file mode 100644 index eb3ea086..00000000 --- a/admin/src/pages/RushPrizeForm/index.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import Button from "@/components/Button"; -import SelectForm from "@/components/SelectForm"; -import TabHeader from "@/components/TabHeader"; -import TextField from "@/components/TextField"; -import useRushEventDispatchContext from "@/hooks/useRushEventDispatchContext"; -import useRushEventStateContext from "@/hooks/useRushEventStateContext"; -import { RUSH_ACTION, RushPrizeType } from "@/types/rush"; - -export default function RushPrizeForm() { - const navigate = useNavigate(); - - const { prize } = useRushEventStateContext(); - const dispatch = useRushEventDispatchContext(); - - const [prizeState, setPrizeState] = useState<RushPrizeType>({} as RushPrizeType); - - useEffect(() => { - setPrizeState(prize); - }, [prize]); - useEffect(() => { - dispatch({ - type: RUSH_ACTION.SET_PRIZE, - payload: { - prizeImageUrl: "prize.png", - prizeDescription: "메가박스 영화 예매권", - }, - }); - }, []); - - const handleUpdate = () => { - dispatch({ - type: RUSH_ACTION.SET_PRIZE, - payload: prizeState, - }); - }; - - const option = [ - ["이미지", <input type="file" />], - [ - "경품 이름 (20자 이내)", - <TextField - value={prizeState.prizeDescription} - onChange={(e) => setPrizeState({ ...prizeState, prizeDescription: e.target.value })} - />, - ], - ]; - - return ( - <div className="flex flex-col items-center h-screen"> - <TabHeader /> - - <div className="w-[1560px] flex flex-col items-center justify-center gap-8 mt-10"> - <div className="flex items-center gap-2 self-start"> - <img - alt="뒤로 가기 버튼" - src="/assets/icons/left-arrow.svg" - className="cursor-pointer" - onClick={() => navigate(-1)} - /> - <p className="h-body-1-medium">밸런스 게임 경품 관리</p> - </div> - - <SelectForm header="경품 관리" data={option} /> - - <Button buttonSize="lg" onClick={handleUpdate}> - 임시 저장 - </Button> - </div> - </div> - ); -} diff --git a/admin/src/pages/RushSelectForm/index.tsx b/admin/src/pages/RushSelectForm/index.tsx deleted file mode 100644 index 06839707..00000000 --- a/admin/src/pages/RushSelectForm/index.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import Button from "@/components/Button"; -import SelectForm from "@/components/SelectForm"; -import TabHeader from "@/components/TabHeader"; -import TextField from "@/components/TextField"; -import useRushEventDispatchContext from "@/hooks/useRushEventDispatchContext"; -import useRushEventStateContext from "@/hooks/useRushEventStateContext"; -import { RUSH_ACTION, RushOptionType } from "@/types/rush"; - -export default function RushSelectForm() { - const navigate = useNavigate(); - - const { selectOptions } = useRushEventStateContext(); - const dispatch = useRushEventDispatchContext(); - - const [selectOptionState, setSelectOptionState] = useState<RushOptionType[]>([]); - - useEffect(() => { - setSelectOptionState(selectOptions); - }, [selectOptions]); - useEffect(() => { - dispatch({ - type: RUSH_ACTION.SET_OPTION, - payload: [ - { - rushOptionId: 1, - mainText: "첫 차로 저렴한 차 사기", - subText: "첫 차는 가성비가 짱이지!", - resultMainText: "누구보다 가성비 갑인 캐스퍼 일렉트릭", - resultSubText: "전기차 평균보다 훨씬 저렴한 캐스퍼 일렉트릭!", - imageUrl: "left_image.png", - }, - { - rushOptionId: 2, - mainText: "첫 차로 성능 좋은 차 사기", - subText: "차는 당연히 성능이지!", - resultMainText: "필요한 건 다 갖춘 캐스퍼 일렉트릭", - resultSubText: "전기차 평균보다 훨씨니 저렴한 캐스퍼 일렉트릭!", - imageUrl: "left_image.png", - }, - ], - }); - }, []); - - const handleUpdate = () => { - dispatch({ type: RUSH_ACTION.SET_OPTION, payload: selectOptionState }); - }; - - const handleChangeItem = (key: string, changeIdx: number, text: string) => { - const updatedItem = selectOptionState.map((item, idx) => { - if (idx === changeIdx) { - return { ...item, [key]: text }; - } - return { ...item }; - }); - - setSelectOptionState(updatedItem); - }; - - const getSelectOption = (idx: number) => { - if (selectOptionState.length >= 2) { - return [ - [ - "메인 문구 (15자 이내)", - <TextField - value={selectOptionState[idx].mainText} - onChange={(e) => handleChangeItem("mainText", idx, e.target.value)} - />, - ], - [ - "서브 문구 (40자 이내)", - <TextField - value={selectOptionState[idx].subText} - onChange={(e) => handleChangeItem("subText", idx, e.target.value)} - />, - ], - ]; - } - return []; - }; - const getSelectOptionResult = (idx: number) => { - if (selectOptionState.length >= 2) { - return [ - ["이미지", <input type="file" />], - [ - "메인 문구 (20자 이내)", - <TextField - value={selectOptionState[idx].resultMainText} - onChange={(e) => handleChangeItem("resultMainText", idx, e.target.value)} - />, - ], - [ - "서브 문구 (45자 이내)", - <TextField - value={selectOptionState[idx].resultSubText} - onChange={(e) => handleChangeItem("resultSubText", idx, e.target.value)} - />, - ], - ]; - } - return []; - }; - - return ( - <div className="flex flex-col items-center h-screen"> - <TabHeader /> - - <div className="w-[1560px] flex flex-col items-center justify-center gap-8 mt-10"> - <div className="flex items-center gap-2 self-start"> - <img - alt="뒤로 가기 버튼" - src="/assets/icons/left-arrow.svg" - className="cursor-pointer" - onClick={() => navigate(-1)} - /> - <p className="h-body-1-medium">밸런스 게임 선택지 관리</p> - </div> - - <div className="flex gap-10"> - <div className="flex flex-col gap-4"> - <SelectForm header="옵션 1 선택지" data={getSelectOption(0)} /> - <SelectForm - header="옵션 1 선택 결과 정보" - data={getSelectOptionResult(0)} - /> - </div> - <div className="flex flex-col gap-4"> - <SelectForm header="옵션 2 선택지" data={getSelectOption(1)} /> - <SelectForm - header="옵션 2 선택 결과 정보" - data={getSelectOptionResult(1)} - /> - </div> - </div> - - <Button buttonSize="lg" onClick={handleUpdate}> - 임시 저장 - </Button> - </div> - </div> - ); -} diff --git a/admin/src/router.tsx b/admin/src/router.tsx index 1c6e9046..e6fc1ec6 100644 --- a/admin/src/router.tsx +++ b/admin/src/router.tsx @@ -1,5 +1,6 @@ import { createBrowserRouter } from "react-router-dom"; import { LotteryAPI } from "./apis/lotteryAPI"; +import { RushAPI } from "./apis/rushAPI"; import Layout from "./components/Layout"; import { ProtectedRoute, UnProtectedRoute } from "./components/Route"; import RushLayout from "./features/Rush/Layout"; @@ -10,8 +11,6 @@ import LotteryWinner from "./pages/LotteryWinner"; import LotteryWinnerList from "./pages/LotteryWinnerList"; import NotFound from "./pages/NotFound"; import Rush from "./pages/Rush"; -import RushPrizeForm from "./pages/RushPrizeForm"; -import RushSelectForm from "./pages/RushSelectForm"; import RushWinnerList from "./pages/RushWinnerList"; export const router = createBrowserRouter([ @@ -38,14 +37,7 @@ export const router = createBrowserRouter([ { index: true, element: <Rush />, - }, - { - path: "select-form", - element: <RushSelectForm />, - }, - { - path: "prize-form", - element: <RushPrizeForm />, + loader: RushAPI.getRush, }, { path: "winner-list", @@ -59,6 +51,7 @@ export const router = createBrowserRouter([ { index: true, element: <Lottery />, + loader: LotteryAPI.getLottery, }, { path: "winner", diff --git a/admin/src/types/common.ts b/admin/src/types/common.ts index ef8aee87..f4f1e663 100644 --- a/admin/src/types/common.ts +++ b/admin/src/types/common.ts @@ -1,5 +1,5 @@ -export interface InfiniteListData<T> { - data: T[]; +export interface InfiniteParticipantListData<T> { + participants: T[]; isLastPage: boolean; totalParticipants: number; } diff --git a/admin/src/types/lottery.ts b/admin/src/types/lottery.ts index 6a0561d3..9585f99c 100644 --- a/admin/src/types/lottery.ts +++ b/admin/src/types/lottery.ts @@ -1,11 +1,14 @@ +import { EVENT_STATUS } from "@/constants/common"; + +export type LotteryEventStatusType = (typeof EVENT_STATUS)[keyof typeof EVENT_STATUS]; export interface LotteryEventType { - lotteryEventId: number; startDate: string; startTime: string; endDate: string; endTime: string; appliedCount: number; winnerCount: number; + status: LotteryEventStatusType; } export interface LotteryExpectationsType { diff --git a/admin/src/types/lotteryApi.ts b/admin/src/types/lotteryApi.ts index a069f972..e21b581a 100644 --- a/admin/src/types/lotteryApi.ts +++ b/admin/src/types/lotteryApi.ts @@ -1,4 +1,4 @@ -import { InfiniteListData } from "./common"; +import { InfiniteParticipantListData } from "./common"; import { LotteryEventType, LotteryExpectationsType, @@ -8,24 +8,36 @@ import { export type GetLotteryResponse = LotteryEventType[]; +export interface PutLotteryParams { + startDateTime: string; + endDateTime: string; + winnerCount: number; +} + +export interface PutLotteryResponse { + startDate: string; + startTime: string; + endDate: string; + endTime: string; + winnerCount: number; +} + export interface PostLotteryWinnerResponse { message: string; } export interface GetLotteryWinnerParams { - id: number; size: number; page: number; phoneNumber?: string; } export interface GetLotteryExpectationsParams { - lotteryId: number; participantId: number; } export type GetLotteryExpectationsResponse = LotteryExpectationsType[]; -export type GetLotteryWinnerResponse = InfiniteListData<LotteryWinnerType>; +export type GetLotteryWinnerResponse = InfiniteParticipantListData<LotteryWinnerType>; -export type GetLotteryParticipantResponse = InfiniteListData<LotteryParticipantType>; +export type GetLotteryParticipantResponse = InfiniteParticipantListData<LotteryParticipantType>; diff --git a/admin/src/types/rush.ts b/admin/src/types/rush.ts index f8eaeb53..93493e6d 100644 --- a/admin/src/types/rush.ts +++ b/admin/src/types/rush.ts @@ -1,13 +1,18 @@ import { Dispatch } from "react"; +import { EVENT_STATUS } from "@/constants/common"; +export type RushEventStatusType = (typeof EVENT_STATUS)[keyof typeof EVENT_STATUS]; export interface RushEventType { rushEventId: number; eventDate: string; openTime: string; closeTime: string; winnerCount: number; - prizeImageUrl: string; + prizeImageUrl: File | string; prizeDescription: string; + status: RushEventStatusType; + leftOption: RushOptionType; + rightOption: RushOptionType; } export interface RushOptionType { @@ -16,39 +21,26 @@ export interface RushOptionType { subText: string; resultMainText: string; resultSubText: string; - imageUrl: string; + imageUrl: File | string; } export interface RushPrizeType { - prizeImageUrl: string; + prizeImageUrl: File | string; prizeDescription: string; } export interface RushEventStateType { rushList: RushEventType[]; - selectOptions: RushOptionType[]; - prize: RushPrizeType; } export const RUSH_ACTION = { SET_EVENT_LIST: "SET_EVENT_LIST", - SET_OPTION: "SET_OPTION", - SET_PRIZE: "SET_PRIZE", } as const; -export type RushEventAction = - | { - type: typeof RUSH_ACTION.SET_EVENT_LIST; - payload: RushEventType[]; - } - | { - type: typeof RUSH_ACTION.SET_OPTION; - payload: RushOptionType[]; - } - | { - type: typeof RUSH_ACTION.SET_PRIZE; - payload: RushPrizeType; - }; +export type RushEventAction = { + type: typeof RUSH_ACTION.SET_EVENT_LIST; + payload: RushEventType[]; +}; export type RushEventDispatchType = Dispatch<RushEventAction>; @@ -65,12 +57,3 @@ export interface RushParticipantType { createdAt: string; rank: number; } - -export interface RushOptionType { - rushOptionId: number; - mainText: string; - subText: string; - resultMainText: string; - resultSubText: string; - imageUrl: string; -} diff --git a/admin/src/types/rushApi.ts b/admin/src/types/rushApi.ts index a9a32e25..02b808eb 100644 --- a/admin/src/types/rushApi.ts +++ b/admin/src/types/rushApi.ts @@ -1,5 +1,5 @@ -import { InfiniteListData } from "./common"; -import { RushOptionType, RushParticipantType } from "./rush"; +import { InfiniteParticipantListData } from "./common"; +import { RushEventType, RushOptionType, RushParticipantType } from "./rush"; export interface GetRushParticipantListParams { id: number; @@ -9,9 +9,11 @@ export interface GetRushParticipantListParams { phoneNumber?: string; } +export type GetRushEventResponse = RushEventType[]; + export type GetRushOptionsResponse = RushOptionType[]; -export type GetRushParticipantListResponse = InfiniteListData<RushParticipantType>; +export type GetRushParticipantListResponse = InfiniteParticipantListData<RushParticipantType>; export interface GetRushWinnerListParams { id: number; @@ -19,8 +21,12 @@ export interface GetRushWinnerListParams { page: number; phoneNumber?: string; } -export type GetRushWinnerListResponse = InfiniteListData<RushParticipantType>; +export type GetRushWinnerListResponse = InfiniteParticipantListData<RushParticipantType>; export interface GetRushOptionsParams { id: number; } + +export type PutRushEventParams = RushEventType[]; + +export type PutRushEventResponse = RushEventType[]; diff --git a/admin/src/utils/getDateDifference.ts b/admin/src/utils/getDateDifference.ts new file mode 100644 index 00000000..dc8204c3 --- /dev/null +++ b/admin/src/utils/getDateDifference.ts @@ -0,0 +1,15 @@ +export function getDateDifference(startDate: string, endDate: string) { + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + + if (!startDate || !endDate || !startDate.match(dateRegex) || !endDate.match(dateRegex)) { + return ""; + } + + const start = new Date(startDate); + const end = new Date(endDate); + + const differenceInTime = end.getTime() - start.getTime(); + const differenceInDays = Math.ceil(differenceInTime / (1000 * 3600 * 24)); + + return `${differenceInDays}일`; +}