diff --git a/package-lock.json b/package-lock.json index f828c578..4416c92d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", @@ -1207,6 +1208,99 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", diff --git a/package.json b/package.json index a612f0de..52236ccd 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", diff --git "a/public/\355\214\200\353\223\261\353\241\235_\355\205\234\355\224\214\353\246\277\355\214\214\354\235\274.xlsx" "b/public/\355\214\200\353\223\261\353\241\235_\355\205\234\355\224\214\353\246\277\355\214\214\354\235\274.xlsx" new file mode 100644 index 00000000..704dd53c Binary files /dev/null and "b/public/\355\214\200\353\223\261\353\241\235_\355\205\234\355\224\214\353\246\277\355\214\214\354\235\274.xlsx" differ diff --git a/src/apis/category.ts b/src/apis/category.ts new file mode 100644 index 00000000..10b9df07 --- /dev/null +++ b/src/apis/category.ts @@ -0,0 +1,22 @@ +import { CategoryDto } from 'types/DTO'; +import apiClient from './apiClient'; + +export const getAllCategory = async () => { + const res = await apiClient.get('/categories'); + return res.data; +}; + +export const postCategory = async (categoryName: string) => { + const res = await apiClient.post('/categories', { categoryName }); + return res.data; +}; + +export const patchCategory = async (categoryId: number, categoryName: string) => { + const res = await apiClient.patch(`/categories/${categoryId}`, { categoryName }); + return res.data; +}; + +export const deleteCategory = async (categoryId: number) => { + const res = await apiClient.delete(`/categories/${categoryId}`); + return res.data; +}; diff --git a/src/apis/contests.ts b/src/apis/contests.ts index e69f10c4..b612d714 100644 --- a/src/apis/contests.ts +++ b/src/apis/contests.ts @@ -1,8 +1,12 @@ -import { ContestResponseDto, VoteTermDto } from 'types/DTO'; +import { ContestRequestDto, ContestResponseDto, VoteTermDto } from 'types/DTO'; import apiClient from './apiClient'; -import { mockContestsResponse } from 'mocks/data/contests'; import { TeamListItemResponseDto } from 'types/DTO/teams/teamListDto'; +export const postContest = async (payload: ContestRequestDto): Promise => { + const res = await apiClient.post('/contests', payload); + return res.data; +}; + export const getAllContests = async (): Promise => { const res = await apiClient.get('/contests'); return res.data.map((contest: ContestResponseDto) => ({ @@ -18,12 +22,11 @@ export const postAllContests = async (contestName: string) => { export const deleteContest = async (contestId: number) => { const res = await apiClient.delete(`/contests/${contestId}`); - console.log(res); return res.data; }; -export const patchContest = async (contestId: number, contestName: string) => { - const res = await apiClient.patch(`/contests/${contestId}`, { contestName }); +export const patchContest = async (contestId: number, payload: ContestRequestDto) => { + const res = await apiClient.patch(`/contests/${contestId}`, payload); return res.data; }; @@ -32,11 +35,23 @@ export const getCurrentContestTeams = async (): Promise { + const res = await apiClient.patch(`/contests/${contestId}/current`, { isCurrent }); + return res.data; +}; + export const getContestTeams = async (contestId: number): Promise => { const res = await apiClient.get(`/contests/${contestId}/teams`); return res.data; }; +export const postBulkAddTeams = async (contestId: number, formData: FormData) => { + const res = await apiClient.post(`/contests/${contestId}/teams/bulk`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return res.data; +}; + export const getVoteTerm = async (contestId: number): Promise => { const res = await apiClient.get(`/contests/${contestId}/vote`); return res.data; diff --git a/src/apis/notices.ts b/src/apis/notices.ts index 998d8a4e..9a714716 100644 --- a/src/apis/notices.ts +++ b/src/apis/notices.ts @@ -1,19 +1,14 @@ +import { NoticeDetailDto, NoticeListDto, NoticeRequestDto } from 'types/DTO/noticeDto'; import apiClient from './apiClient'; -import { NoticeResponseDto } from 'types/DTO/notices/NoticeResponseDto'; -import { NoticeDetailDto } from '../types/DTO/notices/NoticeDetailDto'; -import { NoticeRequestDto } from 'types/DTO/notices/NoticeRequestDto'; -export const getNotices = async (): Promise => { +export const getNotices = async (): Promise => { const { data } = await apiClient.get('/notices'); - return data.map((notice: NoticeResponseDto) => ({ - ...notice, - createdAt: new Date(notice.createdAt), - })); + return data; }; export const getNoticeDetail = async (noticeId: number): Promise => { const { data } = await apiClient.get(`/notices/${noticeId}`); - return { ...data, updatedAt: new Date(data.updatedAt), createdAt: new Date(data.createdAt) }; + return data; }; export const postCreateNotice = async (request: NoticeRequestDto) => { diff --git a/src/components/Input.tsx b/src/components/Input.tsx index 8bfd95ac..b5a38949 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -1,11 +1,15 @@ import { InputHTMLAttributes } from 'react'; +import { twMerge } from 'tailwind-merge'; interface Props extends InputHTMLAttributes {} const Input = ({ className = '', ...props }: Props) => { return ( ); diff --git a/src/components/ui/admin.tsx b/src/components/ui/admin.tsx new file mode 100644 index 00000000..78d46b35 --- /dev/null +++ b/src/components/ui/admin.tsx @@ -0,0 +1,83 @@ +import { VscKebabVertical } from 'react-icons/vsc'; +import { LuPencil } from 'react-icons/lu'; +import { FaRegTrashAlt } from 'react-icons/fa'; +import { twMerge } from 'tailwind-merge'; +import { Popover, PopoverContent, PopoverTrigger } from './popover'; +import { DialogClose, DialogContent, DialogTitle } from './dialog'; +import Button from '@components/Button'; + +export const AdminCard = ({ children }: React.ComponentProps<'div'>) => { + return
{children}
; +}; + +export const AdminCardTop = ({ children }: React.ComponentProps<'div'>) => { + return
{children}
; +}; + +export const AdminCardCreateButton = ({ children, ...props }: React.ComponentProps<'button'>) => { + return ( + + ); +}; + +export const AdminCardRow = ({ children, className }: React.ComponentProps<'div'>) => { + return
{children}
; +}; + +export const AdminPopoverMenu = ({ children, ...props }: React.ComponentProps<'div'>) => { + return ( + + + + + +
+ {children} +
+
+
+ ); +}; + +export const AdminPopoverEditButton = ({ onEdit }: { onEdit: () => void }) => { + return ( + + ); +}; + +export const AdminPopoverDeleteButton = ({ onDelete }: { onDelete: () => void }) => { + return ( + + ); +}; + +export const AdminDeleteConfirmModal = ({ title, onDelete }: { title: string; onDelete: () => void }) => { + return ( + + {title} +
+ + + + +
+
+ ); +}; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 00000000..f871c5b5 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { RxCross2 } from 'react-icons/rx'; + +import { cn } from '@components/lib/utils'; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DialogContent({ className, children, ...props }: React.ComponentProps) { + return ( + + + + {children} + + + + + + ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + +

+ + ); +} + +function DialogClose({ ...props }: React.ComponentProps) { + return ; +} + +export { Dialog, DialogTrigger, DialogContent, DialogTitle, DialogClose }; diff --git a/src/hooks/useContestName.ts b/src/hooks/useContestName.ts new file mode 100644 index 00000000..373702b4 --- /dev/null +++ b/src/hooks/useContestName.ts @@ -0,0 +1,12 @@ +import { useParams } from 'react-router-dom'; +import useContests from './useContests'; + +const useContestName = () => { + const { contestId: contestIdParam } = useParams(); + const { data: contests } = useContests(); + const contestName = contests?.find((contest) => contest.contestId === Number(contestIdParam))?.contestName; + + return contestName; +}; + +export default useContestName; diff --git a/src/layout/admin/contest/AdminContestLayout.tsx b/src/layout/admin/contest/AdminContestLayout.tsx index 0963e1e0..d234a640 100644 --- a/src/layout/admin/contest/AdminContestLayout.tsx +++ b/src/layout/admin/contest/AdminContestLayout.tsx @@ -9,7 +9,7 @@ const AdminContestLayout = () => {
-
+
diff --git a/src/mocks/data/notices.ts b/src/mocks/data/notices.ts index dc5b2573..b6e06722 100644 --- a/src/mocks/data/notices.ts +++ b/src/mocks/data/notices.ts @@ -1,27 +1,26 @@ -import { NoticeDetailDto } from './../../types/DTO/notices/NoticeDetailDto'; -import { NoticeResponseDto } from 'types/DTO/notices/NoticeResponseDto'; +import { NoticeDetailDto, NoticeListDto } from 'types/DTO/noticeDto'; -export const mockNotices: NoticeResponseDto[] = [ +export const mockNotices: NoticeListDto[] = [ { noticeId: 1, title: '서비스 점검 안내: 6월 30일(월) 23:00-24:00까지 시스템 점검으로 서비스 이용이 일시 중단됩니다.', - createdAt: new Date('2025-06-30T14:00:00'), + createdAt: '2025-06-30T14:00:00', }, { noticeId: 2, title: '약관 변경 안내: 2025년 7월 1일부터 서비스 이용약관이 변경됩니다. 변경된 내용을 꼭 확인해 주세요.', - createdAt: new Date('2025-06-30T13:59:00'), + createdAt: '2025-06-30T13:59:00', }, { noticeId: 3, title: '이벤트 종료 안내: 6월 한정 진행된 출석체크 이벤트가 종료되었습니다. 참여해주신 모든 분께 감사드립니다.', - createdAt: new Date('2025-06-30T13:58:00'), + createdAt: '2025-06-30T13:58:00', }, { noticeId: 4, title: '버그 수정 및 안정화 업데이트 안내: 일부 이미지 업로드 오류 및 성능 이슈가 개선되었습니다. 쾌적한 사용 환경을 제공합니다.', - createdAt: new Date('2025-06-30T13:07:00'), + createdAt: '2025-06-30T13:07:00', }, ]; @@ -29,6 +28,6 @@ export const mockNoticeDetail: NoticeDetailDto = { title: '버그 수정 및 안정화 업데이트 안내: 일부 이미지 업로드 오류 및 성능 이슈가 개선되었습니다. 쾌적한 사용 환경을 제공합니다.', description: '일부 이미지 업로드 오류와 성능 이슈를 개선하여 보다 쾌적하고 안정적인 사용 환경을 제공합니다.', - createdAt: new Date('2025-07-01T09:15:23+09:00'), - updatedAt: new Date('2025-07-01T09:15:23+09:00'), + createdAt: '2025-07-01T09:15:23+09:00', + updatedAt: '2025-07-01T09:15:23+09:00', }; diff --git a/src/pages/admin/AdminDashBoardPage.tsx b/src/pages/admin/AdminDashBoardPage.tsx new file mode 100644 index 00000000..b90bfbbc --- /dev/null +++ b/src/pages/admin/AdminDashBoardPage.tsx @@ -0,0 +1,19 @@ +import AllNoticeListSection from './AllNoticeListSection'; +import ContestCategorySection from './ContestCategorySection'; +import ContentListSection from './ContestListSection'; +import OngoingContestSetting from './OngoingContestSetting'; +import ServiceInfoSection from './ServiceInfoSection'; + +const DashBoardPage = () => { + return ( +
+ + + + + +
+ ); +}; + +export default DashBoardPage; diff --git a/src/pages/admin/AllNoticeListSection.tsx b/src/pages/admin/AllNoticeListSection.tsx new file mode 100644 index 00000000..01906b0f --- /dev/null +++ b/src/pages/admin/AllNoticeListSection.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import dayjs from 'dayjs'; +import { + AdminCard, + AdminCardTop, + AdminCardCreateButton, + AdminCardRow, + AdminPopoverMenu, + AdminPopoverEditButton, + AdminPopoverDeleteButton, +} from '@components/ui/admin'; +import { Dialog, DialogTrigger } from '@components/ui/dialog'; +import { noticeOption } from 'queries/notices'; +import { NoticeDeleteConfirmModal, NoticeModal } from './NoticeModal'; + +const AllNoticeListSection = () => { + const [createOpen, setCreateOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + const { data: notices } = useQuery(noticeOption()); + + return ( + + +

전체 공지사항 목록

+ + + + 새 공지 + + setCreateOpen(false)} /> + +
+
+ {notices?.map((notice) => ( + +
+
{notice.noticeId}
+
{dayjs(notice.createdAt).format('YYYY년 MM월 DD일 HH:mm')}
+
{notice.title}
+
+ + + setEditOpen(true)} /> + setEditOpen(false)} /> + + + setDeleteOpen(true)} /> + setDeleteOpen(false)} /> + + +
+ ))} +
+
+ ); +}; + +export default AllNoticeListSection; diff --git a/src/pages/admin/CategoryModal.tsx b/src/pages/admin/CategoryModal.tsx new file mode 100644 index 00000000..d78d458a --- /dev/null +++ b/src/pages/admin/CategoryModal.tsx @@ -0,0 +1,123 @@ +import { useState } from 'react'; +import { FaRegEdit } from 'react-icons/fa'; +import Button from '@components/Button'; +import Input from '@components/Input'; +import { useToast } from 'hooks/useToast'; +import { DialogClose, DialogContent, DialogTitle } from '@components/ui/dialog'; +import { AdminDeleteConfirmModal } from '@components/ui/admin'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { deleteCategory, patchCategory, postCategory } from 'apis/category'; +import { CategoryDto } from 'types/DTO'; + +interface CategoryModalProps { + type: 'create' | 'edit'; + prevData?: CategoryDto; + closeModal: () => void; +} + +export const CategoryModal = ({ type, prevData, closeModal }: CategoryModalProps) => { + const [categoryName, setCategoryName] = useState(prevData?.categoryName ?? ''); + const toast = useToast(); + const queryClient = useQueryClient(); + + const categoryCreate = useMutation({ + mutationKey: ['categoryCreate'], + mutationFn: (categoryName: string) => postCategory(categoryName), + }); + const categoryEdit = useMutation({ + mutationKey: ['categoryEdit'], + mutationFn: ({ categoryId, categoryName }: Omit) => + patchCategory(categoryId, categoryName), + }); + + const handleSubmit = async () => { + if (!categoryName) { + toast('카테고리 이름을 입력해주세요.', 'error'); + return; + } + + if (type === 'create') { + await categoryCreate.mutateAsync(categoryName, { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['category'] }); + toast('카테고리가 추가되었습니다.', 'success'); + }, + onError: () => { + toast('카테고리 추가에 실패했습니다.', 'error'); + }, + }); + } else if (type === 'edit' && prevData) { + await categoryEdit.mutateAsync( + { categoryId: prevData.categoryId, categoryName }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['category'] }); + toast('카테고리가 수정되었습니다.', 'success'); + }, + onError: () => { + toast('카테고리 수정에 실패했습니다.', 'error'); + }, + }, + ); + } + closeModal(); + }; + + return ( + +
+ +
+ {`${type === 'create' ? '추가' : '수정'}할 카테고리 이름을 입력하세요.`} + setCategoryName(e.target.value)} + placeholder="카테고리를 입력하세요." + className="bg-whiteGray h-12 rounded-lg" + /> +
+ + + + +
+
+ ); +}; + +interface CategoryDeleteConfirmModalProps { + category: CategoryDto; + closeModal: () => void; +} + +export const CategoryDeleteConfirmModal = ({ category, closeModal }: CategoryDeleteConfirmModalProps) => { + const toast = useToast(); + const queryClient = useQueryClient(); + + const categoryDelete = useMutation({ + mutationKey: ['categoryDelete'], + mutationFn: (categoryId: number) => deleteCategory(categoryId), + }); + + const onDelete = async () => { + await categoryDelete.mutateAsync(category.categoryId, { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['category'] }); + toast('카테고리가 삭제되었습니다.', 'success'); + }, + onError: () => { + toast('카테고리 삭제에 실패했습니다.', 'error'); + }, + }); + closeModal(); + }; + + return ( + + ); +}; diff --git a/src/pages/admin/CategorySelect.tsx b/src/pages/admin/CategorySelect.tsx new file mode 100644 index 00000000..2e8b2544 --- /dev/null +++ b/src/pages/admin/CategorySelect.tsx @@ -0,0 +1,45 @@ +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@components/ui/select'; +import { useQuery } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { categoryOption } from 'queries/category'; +import { contestOption } from 'queries/contests'; + +interface CategorySelectProps { + categoryId: string; + onChange: (value: string) => void; + className?: string; +} + +const CategorySelect = ({ categoryId, onChange, className = '' }: CategorySelectProps) => { + const { contestId: contestIdParam } = useParams(); + const { data: contests } = useQuery(contestOption()); + const { data: categorys } = useQuery(categoryOption()); + + useEffect(() => { + if (categorys && contests) { + if (!contestIdParam) onChange(categorys[0].categoryId.toString()); + else { + const currentId = contests.find((contest) => contest.contestId === Number(contestIdParam))?.categoryId; + if (currentId) onChange(String(currentId)); + } + } + }, [contests, categorys, contestIdParam]); + + return ( + + ); +}; + +export default CategorySelect; diff --git a/src/pages/admin/ContestCategorySection.tsx b/src/pages/admin/ContestCategorySection.tsx new file mode 100644 index 00000000..eb33cf5b --- /dev/null +++ b/src/pages/admin/ContestCategorySection.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; +import { + AdminCard, + AdminCardCreateButton, + AdminCardTop, + AdminPopoverMenu, + AdminCardRow, + AdminPopoverEditButton, + AdminPopoverDeleteButton, +} from '@components/ui/admin'; +import { CategoryModal, CategoryDeleteConfirmModal } from './CategoryModal'; +import { Dialog, DialogTrigger } from '@components/ui/dialog'; +import { useQuery } from '@tanstack/react-query'; +import { categoryOption } from 'queries/category'; + +const ContestCategorySection = () => { + const [createOpen, setCreateOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + const { data: categories } = useQuery(categoryOption()); + + return ( + + +

대회 카테고리

+ + + + 새 카테고리 + + setCreateOpen(false)} /> + +
+
+ {categories?.map((category) => ( + +
{category.categoryName}
+ + + setEditOpen(true)} /> + setEditOpen(false)} /> + + + setDeleteOpen(true)} /> + setDeleteOpen(false)} /> + + +
+ ))} +
+
+ ); +}; + +export default ContestCategorySection; diff --git a/src/pages/admin/ContestListSection.tsx b/src/pages/admin/ContestListSection.tsx new file mode 100644 index 00000000..8d288b3f --- /dev/null +++ b/src/pages/admin/ContestListSection.tsx @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import { IoIosArrowForward } from 'react-icons/io'; +import { AdminCard, AdminCardTop, AdminCardCreateButton, AdminCardRow } from '@components/ui/admin'; +import { getAllContests } from 'apis/contests'; + +const ContentListSection = () => { + const { data: contests } = useQuery({ queryKey: ['contests'], queryFn: getAllContests }); + + return ( + + +

대회 목록

+ + {'+ 새 대회'} + +
+
+ {contests?.map((contest) => ( + +
+
{contest.contestName}
+
+ {contest.categoryName} +
+
+ +
대회 설정 페이지로 이동
+ + +
+ ))} +
+
+ ); +}; + +export default ContentListSection; diff --git a/src/pages/admin/ContestSlots.tsx b/src/pages/admin/ContestSlots.tsx new file mode 100644 index 00000000..14f6cbd1 --- /dev/null +++ b/src/pages/admin/ContestSlots.tsx @@ -0,0 +1,103 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { ContestResponseDto } from 'types/DTO'; +import { twMerge } from 'tailwind-merge'; +import { TiDeleteOutline } from 'react-icons/ti'; +import { patchChangeOngoingContest } from 'apis/contests'; +import { useToast } from 'hooks/useToast'; +import { contestOption } from 'queries/contests'; + +interface ContestSlotsProps { + contests?: ContestResponseDto[]; + selectedContest?: ContestResponseDto; +} + +export const ContestSlots = ({ contests, selectedContest }: ContestSlotsProps) => { + const toast = useToast(); + const queryClient = useQueryClient(); + + const changeOngoingContest = useMutation({ + mutationKey: ['changeOngoingContest'], + mutationFn: (payload: { contestId: number; isCurrent: boolean }) => + patchChangeOngoingContest(payload.contestId, payload.isCurrent), + }); + + const toggleOngoingContest = (contest: ContestResponseDto, isCurrent: boolean) => { + changeOngoingContest.mutate( + { contestId: contest.contestId, isCurrent }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: contestOption().queryKey }); + if (isCurrent) toast(`${contest.contestName}를 진행 대회로 설정했습니다.`, 'success'); + else toast(`${contest.contestName}를 진행 대회에서 제외했습니다.`, 'success'); + }, + onError: () => toast(`진행 대회 변경 중 오류가 발생했습니다.`, 'error'), + }, + ); + }; + + if (!contests || !selectedContest) return null; + + const ongoingContests = contests.filter((e) => e.isCurrent); + + if (ongoingContests.length === 0) { + return ( + toggleOngoingContest(selectedContest, true)} + /> + ); + } + + if (ongoingContests.length === 1) { + const alreadyOccupied = ongoingContests[0].categoryName === selectedContest.categoryName; + return ( + <> + toggleOngoingContest(ongoingContests[0], false)} + /> + toggleOngoingContest(selectedContest, true)} + /> + + ); + } + + return ongoingContests.map((contest) => ( + toggleOngoingContest(contest, false)} + /> + )); +}; + +interface OngoingContestSlotProps { + text: string; + type: 'available' | 'occupied' | 'disabled'; + onClick?: () => void; + onDelete?: () => void; +} + +const slotStyle = { + available: 'bg-mainBlue text-white hover:cursor-pointer', + occupied: 'bg-mainGreen text-white', + disabled: 'bg-lightGray text-midGray hover:cursor-not-allowed', +}; + +const OngoingContestSlot = ({ type, text, onClick, onDelete }: OngoingContestSlotProps) => ( +
+ {text} + {type === 'occupied' && ( + + )} +
+); diff --git a/src/pages/admin/NoticeManageTab/ManageNoticeListTab.tsx b/src/pages/admin/NoticeManageTab/ManageNoticeListTab.tsx index 134f98a4..85143ed9 100644 --- a/src/pages/admin/NoticeManageTab/ManageNoticeListTab.tsx +++ b/src/pages/admin/NoticeManageTab/ManageNoticeListTab.tsx @@ -4,9 +4,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { deleteNotice, getNotices } from 'apis/notices'; import { useToast } from 'hooks/useToast'; import { useNavigate } from 'react-router-dom'; -import { NoticeResponseDto } from 'types/DTO/notices/NoticeResponseDto'; import { Link } from 'react-router-dom'; import dayjs from 'dayjs'; +import { NoticeListDto } from 'types/DTO/noticeDto'; const ManageNoticeListTab = () => { const navigate = useNavigate(); @@ -33,7 +33,7 @@ const ManageNoticeListTab = () => {

공지사항 목록

{isError &&

공지사항을 불러오는 중 오류가 발생했습니다.

} - + columns={[ { header: '작성일시', diff --git a/src/pages/admin/NoticeModal.tsx b/src/pages/admin/NoticeModal.tsx new file mode 100644 index 00000000..91e36fdf --- /dev/null +++ b/src/pages/admin/NoticeModal.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { DialogClose, DialogContent, DialogTitle } from '@components/ui/dialog'; +import Input from '@components/Input'; +import RoundedButton from '@components/RoundedButton'; +import TextArea from '@components/TextArea'; +import { deleteNotice, patchNotice, postCreateNotice } from 'apis/notices'; +import { useToast } from 'hooks/useToast'; +import { noticeDetailOption } from 'queries/notices'; +import { NoticeRequestDto } from 'types/DTO/noticeDto'; +import { AdminDeleteConfirmModal } from '@components/ui/admin'; + +interface NoticeModalProps { + type: 'create' | 'edit'; + noticeId?: number; + closeModal: () => void; +} + +export const NoticeModal = ({ type, noticeId, closeModal }: NoticeModalProps) => { + const toast = useToast(); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const queryClient = useQueryClient(); + + const { data: notice } = useQuery(noticeDetailOption(noticeId ?? 0)); + const upsertMutation = useMutation({ + mutationFn: (payload: NoticeRequestDto) => { + if (type === 'edit' && noticeId) { + return patchNotice(noticeId, payload); + } else { + return postCreateNotice(payload); + } + }, + }); + + useEffect(() => { + if (notice) { + setTitle(notice.title || ''); + setDescription(notice.description || ''); + } + }, [notice]); + + const handleSave = async () => { + await upsertMutation.mutateAsync( + { title, description }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notices'] }); + toast(`공지사항이 작성 되었습니다.`, 'success'); + }, + onError: () => { + toast(`공지사항 작성에 실패했습니다.`, 'error'); + }, + }, + ); + closeModal(); + }; + + return ( + + 공지사항 작성 +
+ + setTitle(e.target.value)} /> + +