Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e5bc742
Merge branch 'develop' into feat/vote-manage
koty08 Nov 23, 2025
b24d9a8
refactor: DateTimePicker 컴포넌트 일반 Date 객체 -> dayjs로 관리하도록 수정
koty08 Nov 24, 2025
338a144
feat: 1인당 투표권 수 컴포넌트 생성
koty08 Nov 24, 2025
987793e
feat: 투표 기간 컴포넌트 새로 생성, 기존 컴포넌트 삭제
koty08 Nov 24, 2025
86e4569
fix: 공통 Input classname twMerge로 처리하도록 수정
koty08 Nov 24, 2025
a5441db
fix: 기존 어드민 페이지에 기존 투표 기간 컴포넌트 호출 부분 삭제
koty08 Nov 24, 2025
ac6bc8f
feat: 투표 설정 폼 컴포넌트 생성 - 1인당 투표권 수, 투표 기간 컴포넌트 연결
koty08 Nov 24, 2025
c4ccdb7
feat: 투표 관리 페이지 생성 및 라우팅 처리
koty08 Nov 24, 2025
bb9aa74
feat: 투표 기간 수정 API 호출 후 핸들링 추가
koty08 Jan 13, 2026
96d215e
feat: 투표 기간 및 투표권 수 저장 부분 별도로 분리 (API 각각 호출), 투표권 수 API 연결
koty08 Jan 13, 2026
ddc131d
refactor: 기존 useVoteTerm에서 useGetVoteTerm으로 사용중이던 부분 queryOptions로 변경
koty08 Jan 27, 2026
7eb0e40
fix: 투표 기간 수정 시 포맷 오류로 별도 포맷 constants 파일에 생성하여 수정
koty08 Jan 27, 2026
0020a4c
refactor: 최대 투표 수 상수 값 votes 파일로 이동
koty08 Jan 27, 2026
d56fede
feat: 프론트단 투표권 수 확인 로직 추가
koty08 Jan 27, 2026
6d71c6f
feat: 투표 기간 설정 시간에서 초 부분 삭제
koty08 Jan 28, 2026
4ba4e58
feat: + 버튼 10개 이상 시 toast 출력 및 기본 값 0으로 변경 (API에서 받아오므로)
koty08 Jan 28, 2026
4af174f
Merge branch 'develop' into feat/vote-manage
koty08 Feb 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 1 addition & 11 deletions src/apis/contests.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ContestRequestDto, ContestResponseDto, CurrentContestResponseDto, VoteTermDto } from 'types/DTO';
import { ContestRequestDto, ContestResponseDto, CurrentContestResponseDto } from 'types/DTO';
import apiClient from './apiClient';
import { TeamListItemResponseDto } from 'types/DTO/teams/teamListDto';

Expand Down Expand Up @@ -51,13 +51,3 @@ export const postBulkAddTeams = async (contestId: number, formData: FormData) =>
});
return res.data;
};

export const getVoteTerm = async (contestId: number): Promise<VoteTermDto> => {
const res = await apiClient.get(`/contests/${contestId}/vote`);
return res.data;
};

export const updateVoteTerm = async (contestId: number, payload: VoteTermDto) => {
const res = await apiClient.put(`/contests/${contestId}/vote`, payload);
return res.data;
};
22 changes: 22 additions & 0 deletions src/apis/votes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { VoteMaxVotesLimitDto, VoteTermDto } from 'types/DTO';
import apiClient from './apiClient';

export const getVoteTerm = async (contestId: number): Promise<VoteTermDto> => {
const res = await apiClient.get(`/contests/${contestId}/vote`);
return res.data;
};

export const putVoteTerm = async (contestId: number, payload: VoteTermDto) => {
const res = await apiClient.put(`/contests/${contestId}/vote`, payload);
return res.data;
};

export const getMaxVoteLimit = async (contestId: number): Promise<VoteMaxVotesLimitDto> => {
const res = await apiClient.get(`/contests/${contestId}/votes`);
return res.data;
};

export const patchMaxVoteLimit = async (contestId: number, payload: VoteMaxVotesLimitDto) => {
const res = await apiClient.patch(`/contests/${contestId}/votes`, payload);
return res.data;
};
49 changes: 23 additions & 26 deletions src/components/DateTimePicker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { ChevronDownIcon } from 'lucide-react';
import { CiCalendar } from 'react-icons/ci';
import dayjs, { Dayjs } from 'dayjs';

import { Button } from '@components/ui/button';
import { Calendar } from '@components/ui/calendar';
Expand All @@ -9,71 +10,68 @@ import { Popover, PopoverContent, PopoverTrigger } from '@components/ui/popover'

interface DateTimePickerProps {
label: string;
prevDate: Date;
onChange?: (date: Date) => void;
prevDate: string | Dayjs;
onChange: (date: Dayjs) => void;
}

export const DateTimePicker = ({ label, prevDate, onChange }: DateTimePickerProps) => {
const [open, setOpen] = useState(false);
const [dateTime, setDateTime] = useState<Date>(new Date(prevDate));
const [timeInputValue, setTimeInputValue] = useState<string>(new Date(prevDate).toTimeString().slice(0, 8));
const [dateTime, setDateTime] = useState<Dayjs>(dayjs(prevDate));
const [timeInputValue, setTimeInputValue] = useState<string>(dayjs(prevDate).format('HH:mm'));

useEffect(() => {
const newDate = new Date(prevDate);
setDateTime(newDate);
setTimeInputValue(newDate.toTimeString().slice(0, 8));
const newDateTime = dayjs(prevDate);
setDateTime(newDateTime);
setTimeInputValue(newDateTime.format('HH:mm'));
}, [prevDate]);

const handleDateChange = (date: Date | undefined) => {
if (!date) return;

const newDateTime = new Date(dateTime);
newDateTime.setFullYear(date.getFullYear(), date.getMonth(), date.getDate());
const newDateTime = dayjs(date).hour(dateTime.hour()).minute(dateTime.minute());

setDateTime(newDateTime);
setTimeInputValue(newDateTime.toTimeString().slice(0, 8));
setOpen(false);

onChange?.(newDateTime);
onChange(newDateTime);
};

const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setTimeInputValue(e.target.value);
const { value } = e.target;
setTimeInputValue(value);
};

const handleTimeBlur = () => {
const timeValue = timeInputValue;
const timeParts = timeValue.split(':').map(Number);
const [hours, minutes, seconds = 0] = timeParts;
const timeParts = timeInputValue.split(':').map(Number);
const [hours, minutes] = timeParts;

if (isNaN(hours) || isNaN(minutes)) {
setTimeInputValue(dateTime.toTimeString().slice(0, 8));
setTimeInputValue(dateTime.format('HH:mm'));
return;
}

const newDateTime = new Date(dateTime);
newDateTime.setHours(hours, minutes, seconds);
const newDateTime = dateTime.hour(hours).minute(minutes);
setDateTime(newDateTime);
onChange?.(newDateTime);
onChange(newDateTime);
};

return (
<div className="flex items-center gap-3">
<Label htmlFor="time-picker" className="px-1 whitespace-nowrap">
<div className="flex flex-wrap items-center gap-3">
<Label htmlFor="time-picker" className="font-normal whitespace-nowrap">
{label}
</Label>
<div className="flex items-center gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" id="date-picker" className="w-[150px] justify-between font-normal">
{dateTime ? dateTime.toLocaleDateString() : '날짜 선택'}
<ChevronDownIcon className="ml-2 h-4 w-4" />
{dateTime ? dateTime.format('YYYY-MM-DD') : '날짜 선택'}
<CiCalendar size={18} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dateTime}
selected={dateTime.toDate()}
captionLayout="dropdown"
fromYear={2020}
toYear={2030}
Expand All @@ -83,7 +81,6 @@ export const DateTimePicker = ({ label, prevDate, onChange }: DateTimePickerProp
</Popover>
<Input
type="time"
step="1"
id="time-picker"
value={timeInputValue}
onChange={handleTimeChange}
Expand Down
3 changes: 3 additions & 0 deletions src/constants/votes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const VOTETERM_TIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss';

export const MAX_VOTE_PER_PERSON = 9;
37 changes: 3 additions & 34 deletions src/hooks/useVoteTerm.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,9 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useToast } from 'hooks/useToast';
import { getVoteTerm, updateVoteTerm } from 'apis/contests';
import { formatDateTime } from 'utils/time';
import { VoteTermDto } from 'types/DTO';

export const useGetVoteTerm = (contestId: number | undefined) => {
return useQuery({
queryKey: ['voteTerm', contestId],
queryFn: () => getVoteTerm(contestId as number),
enabled: !!contestId,
});
};
import { voteTermOption } from 'queries/votes';

export const useIsVoteTerm = (contestId: number | undefined) => {
const { data: voteTermData, isLoading } = useGetVoteTerm(contestId);
const { data: voteTermData } = useQuery(voteTermOption(contestId ?? 0));

const isVoteTerm = useMemo(() => {
if (!voteTermData) return false;
Expand All @@ -28,23 +17,3 @@ export const useIsVoteTerm = (contestId: number | undefined) => {

return { isVoteTerm };
};

export const useUpdateVoteTerm = (contestId: number) => {
const queryClient = useQueryClient();
const toast = useToast();

return useMutation({
mutationFn: (payload: { voteStartAt: string; voteEndAt: string }) =>
updateVoteTerm(contestId, {
voteStartAt: formatDateTime(new Date(payload.voteStartAt)),
voteEndAt: formatDateTime(new Date(payload.voteEndAt)),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['voteTerm', contestId] });
toast('투표 기간이 업데이트 되었어요', 'success');
},
onError: (err) => {
toast('투표 기간 업데이트에 실패했어요', 'error');
},
});
};
2 changes: 0 additions & 2 deletions src/pages/admin/OngoingContestsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import ProjectSubmissionTable from '@pages/admin/ProjectSubmissionTable';
import VoteRate from '@pages/admin/VoteRate';
import ProjectSortToggle from './ProjectSortToggle';
import VoteTermSelector from './VoteTermSelector';
import { useQuery } from '@tanstack/react-query';
import { getDashboard } from 'apis/dashboard';
import { getRanking } from 'apis/ranking';
Expand Down Expand Up @@ -36,7 +35,6 @@ const OngoingContestsTab = () => {
<>
<div className="flex flex-col gap-12">
<div className="border-lightGray rounded-xl border p-8">
<VoteTermSelector />
<div className="my-6 border-t border-gray-200"></div>
<ProjectSortToggle />
</div>
Expand Down
72 changes: 0 additions & 72 deletions src/pages/admin/VoteTermSelector.tsx

This file was deleted.

103 changes: 103 additions & 0 deletions src/pages/admin/votes/MaxVoteLimitSetting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { ChangeEvent, useEffect, useState } from 'react';
import { FiPlus, FiMinus } from 'react-icons/fi';
import Input from '@components/Input';
import { Label } from '@components/ui/label';
import { MAX_VOTE_PER_PERSON } from 'constants/votes';
import Button from '@components/Button';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { getMaxVoteLimit, patchMaxVoteLimit } from 'apis/votes';
import { VoteMaxVotesLimitDto } from 'types/DTO';
import { useToast } from 'hooks/useToast';

const MaxVoteLimitSetting = () => {
const { contestId: contestIdParam } = useParams();
const [maxVotesLimit, setMaxVotesLimit] = useState<number>(0);
const queryClient = useQueryClient();
const toast = useToast();

const { data: maxVotesLimitData } = useQuery({
queryKey: ['maxVotesLimit', contestIdParam],
queryFn: () => getMaxVoteLimit(Number(contestIdParam ?? 0)),
enabled: !!contestIdParam,
});
const updateMaxVoteLimit = useMutation({
mutationKey: ['updateMaxVotesLimit'],
mutationFn: (payload: VoteMaxVotesLimitDto) => patchMaxVoteLimit(Number(contestIdParam ?? 0), payload),
});

useEffect(() => {
if (maxVotesLimitData) {
setMaxVotesLimit(maxVotesLimitData.maxVotesLimit);
}
}, [maxVotesLimitData]);

const onButtonClick = (type: 'plus' | 'minus') => {
if (type === 'plus') {
if (maxVotesLimit < MAX_VOTE_PER_PERSON) setMaxVotesLimit((prev) => prev + 1);
else toast('10개 이상으로 설정할 수 없습니다.', 'error');
} else if (type === 'minus' && maxVotesLimit > 0) setMaxVotesLimit((prev) => prev - 1);
};

const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const val = Number(e.target.value);
if (!isNaN(val) && val >= 0 && val <= MAX_VOTE_PER_PERSON) {
setMaxVotesLimit(Number(e.target.value));
}
};

const handleDataSave = () => {
if (maxVotesLimit === 0) {
return toast('투표권 수를 0으로 설정할 수 없습니다.', 'error');
}

updateMaxVoteLimit.mutate(
{ maxVotesLimit },
{
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['maxVotesLimit'] });
toast('투표권 수를 수정했습니다.', 'success');
},
onError: (error: any) => {
toast(error.response?.data?.message || '투표권 수 수정에 실패했습니다.', 'error');
},
},
);
};

return (
<div className="flex flex-col gap-8">
<div className="flex flex-wrap items-end gap-2">
<h2 className="text-2xl font-bold">투표권 수</h2>
<p className="text-midGray text-xs">{`최대 ${MAX_VOTE_PER_PERSON}개까지 설정 가능합니다.`}</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 pl-2">
<div className="flex flex-wrap items-center gap-10">
<Label className="font-normal">{'1인당 투표권 수'}</Label>
<div className="flex items-center gap-5">
<FiMinus
size={18}
onClick={() => onButtonClick('minus')}
className="stroke-mainGreen hover:cursor-pointer"
/>
<Input
value={maxVotesLimit}
onChange={onInputChange}
className="h-10 max-w-10 p-0 text-center text-[16px]"
/>
<FiPlus size={18} onClick={() => onButtonClick('plus')} className="stroke-mainGreen hover:cursor-pointer" />
</div>
</div>
<Button
onClick={handleDataSave}
disabled={updateMaxVoteLimit.isPending}
className="bg-mainBlue hover:bg-mainBlue/90 flex h-9 items-center justify-center rounded-md px-6 py-2 text-sm text-white transition-colors disabled:cursor-not-allowed disabled:bg-gray-400"
>
{'저장하기'}
</Button>
</div>
</div>
);
};

export default MaxVoteLimitSetting;
Loading