From a497f2036c2491bf9191aa3adec7fe00b401ed1f Mon Sep 17 00:00:00 2001 From: llddang <77055208+llddang@users.noreply.github.com> Date: Tue, 17 Dec 2024 12:07:56 +0900 Subject: [PATCH] =?UTF-8?q?Feature/#234=20=EB=8C=80=ED=9A=8C=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#235)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 해커톤 정보 수정 페이지 구현 #232 * feat: 공통 컴포넌트 모달 구현 #234 * design: 해커톤 관리 테이블에서 관리 버튼의 색상 변경 #234 * feat: 해커톤 팀 목록 검색창 구현 #234 * feat: 해커톤 팀 관리 테이블 구현 #234 * refactor: 전공 dropdown의 속성명 정확하게 변경 #234 * feat: 해커톤 팀 생성 모달 구현 #234 * feat: 해커톤 팀 수정 모달 구현 #234 * feat: 해커톤 팀 공통 디자인 컴포넌트로 구현 #234 * feat: 해커톤 팀 멤버 인풋 컴포넌트로 구현 #234 * feat: 해커톤 상 관련 상태 정의 #234 * refactor: 적절한 변수명으로 수정 #234 * feat: 해커톤 팀 관리 페이지 구현 #234 * fix: 빌드 에러 수정 #234 --- frontend/next.config.mjs | 5 + .../HackathonTeamCreateModal/index.tsx | 6 +- .../HackathonTeamReadModal/index.tsx | 4 +- .../(client)/hackathon/[slug]/vote/page.tsx | 9 +- frontend/src/app/(client)/hackathon/page.tsx | 6 +- .../app/admin/hackathon/edit/[slug]/page.tsx | 197 ++++++++++++ .../admin/hackathon/manage/[slug]/page.tsx | 33 ++ frontend/src/app/admin/hackathon/page.tsx | 4 +- .../src/app/admin/hackathon/register/page.tsx | 2 +- .../components/common/modal/ActionModal.tsx | 58 ++++ .../hackathon/AdminHackathonManageTable.tsx | 16 +- .../AdminHackathonManageTeamSearchBox.tsx | 71 +++++ .../AdminHackathonManageTeamTable.tsx | 164 ++++++++++ .../ui/auth/AuthSignUpMajorDropdown.tsx | 10 +- .../components/ui/auth/AuthSignUpSecond.tsx | 12 +- .../ui/hackathon/HackathonTeamCreateModal.tsx | 282 ++++++++++++++++++ .../ui/hackathon/HackathonTeamEditModal.tsx | 245 +++++++++++++++ .../hackathon/HackathonTeamInputSection.tsx | 19 ++ .../ui/hackathon/HackathonTeamMemberInput.tsx | 67 +++++ frontend/src/data/hackathon.ts | 38 +++ frontend/src/lib/api/server.api.ts | 29 +- frontend/src/lib/hooks/useApi.ts | 14 +- frontend/src/mocks/hackathon.ts | 85 +++--- frontend/src/types/common.dto.ts | 37 +-- 24 files changed, 1313 insertions(+), 100 deletions(-) create mode 100644 frontend/src/app/admin/hackathon/edit/[slug]/page.tsx create mode 100644 frontend/src/app/admin/hackathon/manage/[slug]/page.tsx create mode 100644 frontend/src/components/common/modal/ActionModal.tsx create mode 100644 frontend/src/components/ui/admin/hackathon/AdminHackathonManageTeamSearchBox.tsx create mode 100644 frontend/src/components/ui/admin/hackathon/AdminHackathonManageTeamTable.tsx create mode 100644 frontend/src/components/ui/hackathon/HackathonTeamCreateModal.tsx create mode 100644 frontend/src/components/ui/hackathon/HackathonTeamEditModal.tsx create mode 100644 frontend/src/components/ui/hackathon/HackathonTeamInputSection.tsx create mode 100644 frontend/src/components/ui/hackathon/HackathonTeamMemberInput.tsx diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index e6bbfe75..84a9c9c4 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -8,6 +8,11 @@ const nextConfig = { hostname: 'localhost', pathname: '**', }, + { + protocol: 'https', + hostname: 'www.pusan.ac.kr', + pathname: '**', + }, { protocol: 'https', hostname: 'swcss.pusan.ac.kr', diff --git a/frontend/src/app/(client)/hackathon/[slug]/vote/components/HackathonTeamCreateModal/index.tsx b/frontend/src/app/(client)/hackathon/[slug]/vote/components/HackathonTeamCreateModal/index.tsx index 562fe318..8e3104fa 100644 --- a/frontend/src/app/(client)/hackathon/[slug]/vote/components/HackathonTeamCreateModal/index.tsx +++ b/frontend/src/app/(client)/hackathon/[slug]/vote/components/HackathonTeamCreateModal/index.tsx @@ -115,9 +115,9 @@ const HackathonTeamCreateModal = ({ hackathonId, open, onClose }: HackathonTeamC registerTeam( { hackathonId: hackathonId, - image: values.image, - name: values.name, - work: values.work, + thumbnailImage: values.image, + teamName: values.name, + projectTitle: values.work, githubUrl: values.githubUrl, members: [values.leader, ...values.members], password: values.password, diff --git a/frontend/src/app/(client)/hackathon/[slug]/vote/components/HackathonTeamReadModal/index.tsx b/frontend/src/app/(client)/hackathon/[slug]/vote/components/HackathonTeamReadModal/index.tsx index 7089508a..64fe8b36 100644 --- a/frontend/src/app/(client)/hackathon/[slug]/vote/components/HackathonTeamReadModal/index.tsx +++ b/frontend/src/app/(client)/hackathon/[slug]/vote/components/HackathonTeamReadModal/index.tsx @@ -40,7 +40,7 @@ const HackathonTeamReadModal = ({ selectedTeam, onClose }: HackathonTeamReadModa className="flex h-[600px] w-full max-w-[900px] flex-col items-center gap-2 rounded bg-white p-5 sm:h-[700px]" >
- +
@@ -67,7 +67,7 @@ const HackathonTeamReadModal = ({ selectedTeam, onClose }: HackathonTeamReadModa {member.name} {member.id} - {member.majorName} + {member.major} {member.isLeader && (팀장)} ))} diff --git a/frontend/src/app/(client)/hackathon/[slug]/vote/page.tsx b/frontend/src/app/(client)/hackathon/[slug]/vote/page.tsx index d1385433..e63b5f32 100644 --- a/frontend/src/app/(client)/hackathon/[slug]/vote/page.tsx +++ b/frontend/src/app/(client)/hackathon/[slug]/vote/page.tsx @@ -7,6 +7,7 @@ import Image from 'next/image'; import { useState } from 'react'; import HackathonTeamCreateModal from './components/HackathonTeamCreateModal'; import HackathonTeamReadModal from './components/HackathonTeamReadModal'; +import { mockHackathonTeamPageableData } from '@/mocks/hackathon'; interface HackathonVotePageProps { params: { @@ -20,7 +21,9 @@ const Page = ({ params: { slug }, searchParams }: HackathonVotePageProps) => { const [teamCreateModalOpen, setTeamCreateModalOpen] = useState(false); const page = searchParams?.page ? parseInt(searchParams.page, 10) : 1; - const { data: teams } = useHackathonTeamsQuery(slug, page, 8); + // const { data: teams } = useHackathonTeamsQuery(slug, page, 8); + const teams = mockHackathonTeamPageableData; + return (
setSelectedTeam(null)} /> @@ -48,7 +51,7 @@ const Page = ({ params: { slug }, searchParams }: HackathonVotePageProps) => { >
팀 섬네일 { quality={100} />
-

{team.name}

+

{team.teamName}

{team.voteCount}표 득표 diff --git a/frontend/src/app/(client)/hackathon/page.tsx b/frontend/src/app/(client)/hackathon/page.tsx index 30052f69..4b8322f9 100644 --- a/frontend/src/app/(client)/hackathon/page.tsx +++ b/frontend/src/app/(client)/hackathon/page.tsx @@ -50,9 +50,9 @@ const Page = async ({ searchParams }: { searchParams?: { [key: string]: string | />
-
개발 중인 기능입니다.
+ {/*
개발 중인 기능입니다.
*/} - {/* {hackathons?.content && hackathons.content.length > 0 ? ( + {hackathons?.content && hackathons.content.length > 0 ? (
{hackathons.content.map((hackathon) => ( 해커톤 정보가 없습니다.
)} - */} +
); }; diff --git a/frontend/src/app/admin/hackathon/edit/[slug]/page.tsx b/frontend/src/app/admin/hackathon/edit/[slug]/page.tsx new file mode 100644 index 00000000..848742af --- /dev/null +++ b/frontend/src/app/admin/hackathon/edit/[slug]/page.tsx @@ -0,0 +1,197 @@ +'use client'; + +import * as Yup from 'yup'; +import { Form, Formik } from 'formik'; + +import PageTitle from '@/components/common/PageTitle'; +import TextInput from '@/components/common/formik/TextInput'; +import ImageUploader from '@/components/common/formik/ImageUploader'; +import { DatePicker } from '@/components/common/formik/DatePicker'; +import MarkdownEditor from '@/components/common/formik/MarkdownEditor'; +import AdminHackathonInputSection from '@/components/ui/admin/hackathon/AdminHackathonInputSection'; +import { MdImage } from '@react-icons/all-files/md/MdImage'; +import { MdTextFields } from '@react-icons/all-files/md/MdTextFields'; +import { MdDateRange } from '@react-icons/all-files/md/MdDateRange'; +import { HackathonDto } from '@/types/common.dto'; + +export interface AdminHackathonEditPageProps { + params: { slug: number }; +} + +export type HackathonInfo = Omit & { bannerImage: File | null }; + +export default function AdminHackathonEditPage({ params: { slug } }: AdminHackathonEditPageProps) { + // TODO: API 연결 + const hackathonInfoInitialValue: HackathonInfo = { + title: '', + content: '', + bannerImage: null, + applyStartDate: '', + applyEndDate: '', + hackathonStartDate: '', + hackathonEndDate: '', + teamCode: '', + }; + + return ( +
+ + { + setSubmitting(false); + // TODO: API 연결 + }} + > + {({ isSubmitting, values, touched, handleChange, handleBlur, setFieldValue, errors }) => ( +
+ + } + /> + + +
+ } + /> + + } + /> + + + ~ + +
+ } + /> + + + ~ + +
+ } + /> + + } + /> +
+ +
+ + )} + +
+ ); +} + +const hackathonValidationSchema = Yup.object().shape({ + name: Yup.string().max(15, '대회명을 50자 이내로 입력해주세요.').required('등록할 대회명을 입력해주세요.'), + content: Yup.string().required('등록할 대회의 상세정보를 입력해주세요.'), + bannerImage: Yup.mixed() + .required('대회의 배너 이미지를 첨부해주세요.') + .test( + 'fileFormat', + '이미지 파일(.jpg, .jpeg, .png), PDF 파일(.pdf)만 업로드 가능합니다.', + (value) => + value instanceof File && ['image/jpeg', 'image/jpg', 'image/png', 'application/pdf'].includes(value.type), + ), + applyStartDate: Yup.date().required('대회 신청 시작일을 입력해주세요.'), + applyEndDate: Yup.date() + .min(Yup.ref('applyStartDate'), '신청 시작일보다 늦은 날짜로 지정해야 합니다') + .required('대회 신청 마지막 날을 입력해주세요.'), + hackathonStartDate: Yup.date().required('대회 시작일을 입력해주세요.'), + hackathonEndDate: Yup.date() + .min(Yup.ref('hackathonStartDate'), '대회 시작일보다 늦은 날짜로 지정해야 합니다') + .required('대회 마지막 일을 입력해주세요.'), + teamCode: Yup.string().required('대회에 참여할 수 있는 팀 등록 코드를 입력해주세요.'), +}); diff --git a/frontend/src/app/admin/hackathon/manage/[slug]/page.tsx b/frontend/src/app/admin/hackathon/manage/[slug]/page.tsx new file mode 100644 index 00000000..9e7cb026 --- /dev/null +++ b/frontend/src/app/admin/hackathon/manage/[slug]/page.tsx @@ -0,0 +1,33 @@ +import PageTitle from '@/components/common/PageTitle'; +import AdminHackathonManageTeamSearchBox from '@/components/ui/admin/hackathon/AdminHackathonManageTeamSearchBox'; +import AdminHackathonManageTeamTable from '@/components/ui/admin/hackathon/AdminHackathonManageTeamTable'; +import { getAuthFromCookie } from '@/lib/utils/auth'; +import { mockHackathonTeamPageableData } from '@/mocks/hackathon'; +import { AuthSliceState } from '@/store/auth.slice'; +import { headers } from 'next/headers'; + +export interface AdminHackathonEditPageProps { + params: { slug: number }; + searchParams?: { [key: string]: string | undefined }; +} + +export default function AdminHackathonManagePage({ params: { slug }, searchParams }: AdminHackathonEditPageProps) { + const headersList = headers(); + const pathname = headersList.get('x-pathname') || ''; + + const auth: AuthSliceState = getAuthFromCookie(); + + const page = searchParams?.page ? parseInt(searchParams.page, 10) : 1; + const keyword = searchParams?.keyword ? searchParams.keyword : ''; + + // TODO: api 연결 + const hackathonTeamInfos = mockHackathonTeamPageableData.content; + + return ( + <> + + + + + ); +} diff --git a/frontend/src/app/admin/hackathon/page.tsx b/frontend/src/app/admin/hackathon/page.tsx index e44171f3..3188afd9 100644 --- a/frontend/src/app/admin/hackathon/page.tsx +++ b/frontend/src/app/admin/hackathon/page.tsx @@ -9,11 +9,11 @@ import { getAuthFromCookie } from '@/lib/utils/auth'; import { AuthSliceState } from '@/store/auth.slice'; import Pagination from '@/components/common/Pagination'; -export interface HackathonListPageProps { +export interface AdminHackathonListPageProps { searchParams?: { [key: string]: string | undefined }; } -export default function HackathonListPage({ searchParams }: HackathonListPageProps) { +export default function AdminHackathonListPage({ searchParams }: AdminHackathonListPageProps) { const headersList = headers(); const pathname = headersList.get('x-pathname') || ''; diff --git a/frontend/src/app/admin/hackathon/register/page.tsx b/frontend/src/app/admin/hackathon/register/page.tsx index 964bdc86..96db1deb 100644 --- a/frontend/src/app/admin/hackathon/register/page.tsx +++ b/frontend/src/app/admin/hackathon/register/page.tsx @@ -14,7 +14,7 @@ import { MdTextFields } from '@react-icons/all-files/md/MdTextFields'; import { MdDateRange } from '@react-icons/all-files/md/MdDateRange'; import { HackathonDto } from '@/types/common.dto'; -export default function HackathonCreatePage() { +export default function AdminHackathonCreatePage() { return (
diff --git a/frontend/src/components/common/modal/ActionModal.tsx b/frontend/src/components/common/modal/ActionModal.tsx new file mode 100644 index 00000000..f510094b --- /dev/null +++ b/frontend/src/components/common/modal/ActionModal.tsx @@ -0,0 +1,58 @@ +import { useCallback } from 'react'; +import PageTitle from '../PageTitle'; +import { VscClose } from '@react-icons/all-files/vsc/VscClose'; + +type DivProps = Omit, HTMLDivElement>, 'size'>; +export interface ActionModalProps { + isOpen: boolean; + title: string; + description?: string; + size?: 'sm' | 'md' | 'lg'; + onClose: () => void; + children?: Readonly; +} + +export default function ActionModal({ + isOpen, + size = 'sm', + title, + description, + onClose, + children, + ...props +}: ActionModalProps & DivProps) { + const widthSize = useCallback(() => { + switch (size) { + case 'sm': + return 'w-[450px]'; + case 'md': + return 'w-[650px]'; + case 'lg': + return 'w-[900px]'; + } + }, []); + + if (!isOpen) return null; + return ( + +
+
{children}
+
+ + ); +} diff --git a/frontend/src/components/ui/admin/hackathon/AdminHackathonManageTable.tsx b/frontend/src/components/ui/admin/hackathon/AdminHackathonManageTable.tsx index 6575d118..3a231db4 100644 --- a/frontend/src/components/ui/admin/hackathon/AdminHackathonManageTable.tsx +++ b/frontend/src/components/ui/admin/hackathon/AdminHackathonManageTable.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'; import { MdDeleteForever } from '@react-icons/all-files/md/MdDeleteForever'; import { MdFileDownload } from '@react-icons/all-files/md/MdFileDownload'; import { MdEdit } from '@react-icons/all-files/md/MdEdit'; +import { MdSettings } from '@react-icons/all-files/md/MdSettings'; import { HackathonManageDto } from '@/types/common.dto'; @@ -27,8 +28,12 @@ export default function AdminHackathonManageTable({ hackathonInfos }: AdminHacka ); } + function handleManageContestClick(hackathonId: number) { + router.push(`/admin/hackathon/manage/${hackathonId}`); + } + function handleEditContestClick(hackathonId: number) { - router.push(`/admin/hackathon/${hackathonId}`); + router.push(`/admin/hackathon/edit/${hackathonId}`); } function handleDeleteContestClick(selectedHackathon: HackathonManageDto) { @@ -59,6 +64,7 @@ export default function AdminHackathonManageTable({ hackathonInfos }: AdminHacka 대회명 대회기간 활성 여부 + 대회 관리 대회 수정 대회 삭제 투표 결과 @@ -83,6 +89,14 @@ export default function AdminHackathonManageTable({ hackathonInfos }: AdminHacka /> + + + + + )} + + + ); +} + +interface AdminHackathonSearchBoxField { + keyword: string; +} diff --git a/frontend/src/components/ui/admin/hackathon/AdminHackathonManageTeamTable.tsx b/frontend/src/components/ui/admin/hackathon/AdminHackathonManageTeamTable.tsx new file mode 100644 index 00000000..d8593e70 --- /dev/null +++ b/frontend/src/components/ui/admin/hackathon/AdminHackathonManageTeamTable.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { MdDeleteForever } from '@react-icons/all-files/md/MdDeleteForever'; +import { MdEdit } from '@react-icons/all-files/md/MdEdit'; + +import Dropdown from '@/components/common/formik/Dropdown'; +import HackathonTeamEditModal from '@/components/ui/hackathon/HackathonTeamEditModal'; +import HackathonTeamCreateModal from '@/components/ui/hackathon/HackathonTeamCreateModal'; + +import { HackathonTeamDto } from '@/types/common.dto'; +import { hackathonPrizeCategories, prizeNumberToString, prizeStringToNumber } from '@/data/hackathon'; + +export interface AdminHackathonManageTeamTableProps { + hackathonId: number; + teamInfos: HackathonTeamDto[]; +} + +export default function AdminHackathonManageTeamTable({ hackathonId, teamInfos }: AdminHackathonManageTeamTableProps) { + const [teams, setTeams] = useState([]); + const [selectedEditTeam, setSelectedEditTeam] = useState(initialTeamMember); + const [teamEditModalOpen, setTeamEditModalOpen] = useState(false); + const [teamCreateModalOpen, setTeamCreateModalOpen] = useState(false); + + function handleEditTeamButtonClick(teamInfo: HackathonTeamDto) { + setSelectedEditTeam(teamInfo); + setTeamEditModalOpen(true); + } + + function handleDeleteTeamClick(teamInfo: HackathonTeamDto) { + const willDelete = window.confirm(teamInfo.teamName + ' 팀 을 정말 삭제하겠습니까?'); + if (!willDelete) return; + + // TODO: API 연결 + setTeams((prev) => prev.map((team) => (team.id === teamInfo.id ? null : team)).filter((v) => v !== null)); + } + + function handleSelectedPrizeChange(teamId: number, value: number) { + setTeams((prev) => + prev.map((team) => (team.id === teamId ? { ...team, prize: prizeNumberToString(value) } : team)), + ); + } + + function handleStorePrizeButtonClick() { + // TODO: API 연결 + } + + function handleTeamEditModalClose() { + setSelectedEditTeam(initialTeamMember); + setTeamEditModalOpen(false); + } + + useEffect(() => { + setTeams(teamInfos); + }, [teamInfos, setTeams]); + + return ( + <> +
+ + +
+ + + + + + + + + + + + + {teams.map((teamInfo) => { + return ( + + + + + + + + + ); + })} + +
팀명프로젝트명득표수팀 수정팀 삭제최종 순위
{teamInfo.teamName} + {teamInfo.projectTitle} + {teamInfo.voteCount} + + + + + handleSelectedPrizeChange(teamInfo.id, value)} + size="sm" + isAdmin + /> +
+
+ + +
+ setTeamCreateModalOpen(false)} + /> + + + ); +} + +const initialTeamMember: HackathonTeamDto = { + id: -1, + teamName: '', + projectTitle: '', + githubUrl: '', + thumbnailImage: '', + teamMembers: {}, + voteCount: 0, + prize: 'NONE', +}; diff --git a/frontend/src/components/ui/auth/AuthSignUpMajorDropdown.tsx b/frontend/src/components/ui/auth/AuthSignUpMajorDropdown.tsx index d340d4e9..e6f0edf0 100644 --- a/frontend/src/components/ui/auth/AuthSignUpMajorDropdown.tsx +++ b/frontend/src/components/ui/auth/AuthSignUpMajorDropdown.tsx @@ -7,8 +7,8 @@ import { CollegeDto, MajorDto } from '@/types/common.dto'; export interface AuthSignUpMajorDropdownProps extends Omit { collegeId: number; majorId: number; - collegeName: string; - majorName: string; + collegeType: string; + majorType: string; } export default function AuthSignUpMajorDropdown({ ...props }: AuthSignUpMajorDropdownProps) { @@ -25,7 +25,7 @@ export default function AuthSignUpMajorDropdown({ ...props }: AuthSignUpMajorDro setMajors(filteredMajors[0].majors); } if (props.collegeId === 0) { - props.setFieldValue(props.majorName, 0); + props.setFieldValue(props.majorType, 0); setMajors([{ id: 0, name: `이수 중인 ${props.label}이 없습니다.`, createdAt: '' }]); } }, [colleges, props.collegeId]); @@ -43,7 +43,7 @@ export default function AuthSignUpMajorDropdown({ ...props }: AuthSignUpMajorDro return (
(
& { leader: TeamMember }; + +interface HackathonTeamCreateModalProps { + hackathonId: number; + isOpen: boolean; + onClose: () => void; +} + +export default function HackathonTeamCreateModal({ hackathonId, isOpen, onClose }: HackathonTeamCreateModalProps) { + const { mutate: registerTeam } = useRegisterTeamMutation(); + const router = useRouter(); + + function handleCreateTeamButtonClick(values: HackathonCreateTeamFormProps) { + registerTeam( + { + hackathonId: hackathonId, + thumbnailImage: values.thumbnailImage, + teamName: values.teamName, + projectTitle: values.projectTitle, + githubUrl: values.githubUrl, + members: [values.leader, ...values.members], + password: values.password, + }, + { + onSuccess: () => { + toast.info('팀 등록에 성공하였습니다.'); + router.refresh(); + }, + onError: () => { + toast.error('팀 등록에 실패하였습니다.'); + }, + }, + ); + } + + return ( + + { + handleCreateTeamButtonClick(values); + setSubmitting(false); + }} + > + {({ isSubmitting, touched, values, handleBlur, handleChange, setFieldValue, errors }) => ( + + + +
+ } + /> + + } + /> + + } + /> + + + } + /> + +
+

팀장

+
+ +
+
+
+

팀원

+
+ {values.members.map((_, index) => ( +
+ + +
+ ))} + +
+
+ + } + /> + + } + /> +
+ +
+ + )} + + + ); +} + +const validationSchema = Yup.object().shape({ + thumbnailImage: Yup.mixed() + .required('섬네일 이미지를 첨부해주세요.') + .test( + 'fileFormat', + '이미지 파일(.jpg, .jpeg, .png)만 업로드 가능합니다.', + (value) => value instanceof File && ['image/jpeg', 'image/jpg', 'image/png'].includes(value.type), + ), + teamName: Yup.string().required('필수 입력란입니다. 이름을 입력해주세요.'), + projectTitle: Yup.string().required('필수 입력란입니다. 서비스 이름을 입력해주세요.'), + githubUrl: Yup.string().required('필수 입력란입니다. Github Repository url을 입력해주세요.'), + leader: Yup.object().shape({ + id: Yup.string() + .required('필수 입력란입니다.') + .matches(/^[\d]{9}$/, '9자리의 학번을 입력해주세요.'), + }), + members: Yup.array() + .of( + Yup.object().shape({ + id: Yup.string() + .required('필수 입력란입니다.') + .matches(/^[\d]{9}$/, '9자리의 학번을 입력해주세요.'), + }), + ) + .test('unique-student-number', '같은 팀원을 2번 이상 등록할 수 없습니다.', function (members) { + const memberIds = members?.map((member) => member.id) || []; + const allIds = [this.parent.leader.id, ...memberIds]; + const uniqueMemberIds = new Set(allIds); + return uniqueMemberIds.size === allIds.length; + }), + password: Yup.string().required('필수 입력란입니다. 팀 등록 코드(비밀번호)를 입력해주세요.'), +}); + +const initialHackathonTeamCreateValues: HackathonCreateTeamFormProps = { + thumbnailImage: null, + teamName: '', + projectTitle: '', + githubUrl: '', + leader: { id: null, role: TeamMemberRole.DEVELOPER, isLeader: true }, + members: [], + password: '', +}; diff --git a/frontend/src/components/ui/hackathon/HackathonTeamEditModal.tsx b/frontend/src/components/ui/hackathon/HackathonTeamEditModal.tsx new file mode 100644 index 00000000..04147250 --- /dev/null +++ b/frontend/src/components/ui/hackathon/HackathonTeamEditModal.tsx @@ -0,0 +1,245 @@ +'use client'; + +import * as Yup from 'yup'; +import { Form, Formik } from 'formik'; + +import TextInput from '@/components/common/formik/TextInput'; +import ActionModal from '@/components/common/modal/ActionModal'; +import ImageUploader from '@/components/common/formik/ImageUploader'; +import HackathonTeamMemberInput from '@/components/ui/hackathon/HackathonTeamMemberInput'; +import HackathonTeamInputSection from '@/components/ui/hackathon/HackathonTeamInputSection'; + +import { TeamMemberRole } from '@/data/hackathon'; +import { HackathonTeamCreateDto, HackathonTeamDto, TeamMember, TeamMemberDto } from '@/types/common.dto'; + +import { CgMathPlus } from '@react-icons/all-files/cg/CgMathPlus'; +import { MdImage } from '@react-icons/all-files/md/MdImage'; +import { MdPerson } from '@react-icons/all-files/md/MdPerson'; +import { MdTextFields } from '@react-icons/all-files/md/MdTextFields'; +import { VscClose } from '@react-icons/all-files/vsc/VscClose'; +import { VscGithubInverted } from '@react-icons/all-files/vsc/VscGithubInverted'; +import Image from 'next/image'; + +type HackathonEditTeamFormProps = Omit & { + leader: TeamMember; +}; + +export interface HackathonTeamEditModalProps { + hackathonId: number; + teamInfo: HackathonTeamDto; + isOpen: boolean; + onClose: () => void; +} + +export default function HackathonTeamEditModal({ + hackathonId, + teamInfo, + isOpen, + onClose, +}: HackathonTeamEditModalProps) { + const [leader, members] = memberDtoToMember(teamInfo.teamMembers); + const team: HackathonEditTeamFormProps = { + teamName: teamInfo.teamName, + projectTitle: teamInfo.projectTitle, + githubUrl: teamInfo.githubUrl, + leader, + members, + }; + + function handleSubmitTeamButtonClick(values: HackathonEditTeamFormProps) { + // TODO: api 연결 + } + + return ( + + { + handleSubmitTeamButtonClick(values); + setSubmitting(false); + }} + > + {({ isSubmitting, touched, values, handleBlur, handleChange, setFieldValue, errors }) => ( +
+ + } + /> + + } + /> + + } + /> + +
+

팀장

+
+ +
+
+
+

팀원

+
+ {values.members.map((_, index) => ( +
+ + +
+ ))} + +
+
+ + } + /> +
+ +
+ + )} +
+
+ ); +} + +const validationSchema = Yup.object().shape({ + teamName: Yup.string().required('필수 입력란입니다. 이름을 입력해주세요.'), + projectTitle: Yup.string().required('필수 입력란입니다. 서비스 이름을 입력해주세요.'), + githubUrl: Yup.string().required('필수 입력란입니다. Github Repository url을 입력해주세요.'), + leader: Yup.object().shape({ + id: Yup.string() + .required('필수 입력란입니다.') + .matches(/^[\d]{9}$/, '9자리의 학번을 입력해주세요.'), + }), + members: Yup.array() + .of( + Yup.object().shape({ + id: Yup.string() + .required('필수 입력란입니다.') + .matches(/^[\d]{9}$/, '9자리의 학번을 입력해주세요.'), + }), + ) + .test('unique-student-number', '같은 팀원을 2번 이상 등록할 수 없습니다.', function (members) { + const memberIds = members?.map((member) => member.id) || []; + const allIds = [this.parent.leader.id, ...memberIds]; + const uniqueMemberIds = new Set(allIds); + return uniqueMemberIds.size === allIds.length; + }), +}); + +function memberDtoToMember(member: TeamMemberDto): [TeamMember, TeamMember[]] { + let leader = { id: -1, role: TeamMemberRole.DEVELOPER, isLeader: true }; + const members: TeamMember[] = []; + member.DEVELOPER?.forEach((info) => { + if (info.isLeader) leader = { id: info.id, role: TeamMemberRole.DEVELOPER, isLeader: true }; + else members.push({ id: info.id, role: TeamMemberRole.DEVELOPER, isLeader: info.isLeader }); + }); + member.DESIGNER?.forEach((info) => { + if (info.isLeader) leader = { id: info.id, role: TeamMemberRole.DESIGNER, isLeader: true }; + else members.push({ id: info.id, role: TeamMemberRole.DESIGNER, isLeader: info.isLeader }); + }); + member.PLANNER?.forEach((info) => { + if (info.isLeader) leader = { id: info.id, role: TeamMemberRole.PLANNER, isLeader: true }; + else members.push({ id: info.id, role: TeamMemberRole.PLANNER, isLeader: info.isLeader }); + }); + member.OTHER?.forEach((info) => { + if (info.isLeader) leader = { id: info.id, role: TeamMemberRole.OTHER, isLeader: true }; + else members.push({ id: info.id, role: TeamMemberRole.OTHER, isLeader: info.isLeader }); + }); + return [leader, members]; +} diff --git a/frontend/src/components/ui/hackathon/HackathonTeamInputSection.tsx b/frontend/src/components/ui/hackathon/HackathonTeamInputSection.tsx new file mode 100644 index 00000000..44748950 --- /dev/null +++ b/frontend/src/components/ui/hackathon/HackathonTeamInputSection.tsx @@ -0,0 +1,19 @@ +import { IconType } from '@react-icons/all-files/lib'; + +interface HackathonTeamInputSectionProps { + icon: IconType; + label: string; + inputElement: JSX.Element; +} +export default function HackathonTeamInputSection({ icon: Icon, label, inputElement }: HackathonTeamInputSectionProps) { + return ( +
+

+ + {label} +

+
+ {inputElement} +
+ ); +} diff --git a/frontend/src/components/ui/hackathon/HackathonTeamMemberInput.tsx b/frontend/src/components/ui/hackathon/HackathonTeamMemberInput.tsx new file mode 100644 index 00000000..9576e50c --- /dev/null +++ b/frontend/src/components/ui/hackathon/HackathonTeamMemberInput.tsx @@ -0,0 +1,67 @@ +import Dropdown from '@/components/common/formik/Dropdown'; +import TextInput from '@/components/common/formik/TextInput'; +import { memberRoleOptions, teamMemberRoleInfo } from '@/data/hackathon'; +import { useStudentMemberMutation } from '@/lib/hooks/useApi'; +import useDebounce from '@/lib/hooks/useDebounce'; +import { StudentMemberDto, TeamMember } from '@/types/common.dto'; +import { ChangeEventHandler, FocusEventHandler, useState } from 'react'; + +interface HackathonTeamMemberInputProps { + fieldName: string; + student: TeamMember; + onChange: ChangeEventHandler | undefined; + onBlur: FocusEventHandler | undefined; + errorText: string | undefined; + setFieldValue: (field: string, value: any, shouldValidate?: boolean | undefined) => void; +} + +export default function HackathonTeamMemberInput({ + fieldName, + student, + onChange, + onBlur, + errorText, + setFieldValue, +}: HackathonTeamMemberInputProps) { + const [studentInfo, setStudentInfo] = useState(); + const { mutate: searchStudent } = useStudentMemberMutation(); + + useDebounce( + () => { + if (student.id) { + searchStudent(student.id, { + onSuccess: (data) => { + setStudentInfo(data); + }, + }); + } + }, + 300, + student.id, + ); + + return ( + <> + + +
+ +
+ + + ); +} diff --git a/frontend/src/data/hackathon.ts b/frontend/src/data/hackathon.ts index 67249216..40b2b915 100644 --- a/frontend/src/data/hackathon.ts +++ b/frontend/src/data/hackathon.ts @@ -30,3 +30,41 @@ export enum HackathonState { IN_PROGRESS = '진행중', COMPLETED = '종료', } + +export enum HackathonPrizeType { + GRAND_PRIZE = 'GRAND_PRIZE', + EXCELLENCE_PRIZE = 'EXCELLENCE_PRIZE', + MERIT_PRIZE = 'MERIT_PRIZE', + ENCOURAGEMENT_PRIZE = 'ENCOURAGEMENT_PRIZE', + NONE_PRIZE = 'NONE', +} + +export const hackathonPrizeType = [ + { id: HackathonPrizeType.NONE_PRIZE, name: '' }, + { id: HackathonPrizeType.GRAND_PRIZE, name: '대상' }, + { id: HackathonPrizeType.EXCELLENCE_PRIZE, name: '최우수상' }, + { id: HackathonPrizeType.MERIT_PRIZE, name: '우수상' }, + { id: HackathonPrizeType.ENCOURAGEMENT_PRIZE, name: '장려상' }, +]; + +export const hackathonPrizeCategories = [ + { id: -1, prize: HackathonPrizeType.NONE_PRIZE, name: 'X' }, + { id: 1, prize: HackathonPrizeType.GRAND_PRIZE, name: '대상' }, + { id: 2, prize: HackathonPrizeType.EXCELLENCE_PRIZE, name: '최우수상' }, + { id: 3, prize: HackathonPrizeType.MERIT_PRIZE, name: '우수상' }, + { id: 4, prize: HackathonPrizeType.ENCOURAGEMENT_PRIZE, name: '장려상' }, +]; + +export function prizeNumberToString(prizeId: number) { + for (const { id, prize, name } of hackathonPrizeCategories) { + if (id === prizeId) return prize; + } + return 'NONE'; +} + +export function prizeStringToNumber(selectedPrize: string) { + for (const { id, prize, name } of hackathonPrizeCategories) { + if (prize === selectedPrize) return id; + } + return -1; +} diff --git a/frontend/src/lib/api/server.api.ts b/frontend/src/lib/api/server.api.ts index f8de2007..bfec13db 100644 --- a/frontend/src/lib/api/server.api.ts +++ b/frontend/src/lib/api/server.api.ts @@ -101,12 +101,12 @@ export async function getMyMilestoneHistory(token: string, studentId: number, st } export async function getHackathons(page: number = 0, size: number = 10) { - const response = await server.get('/hackathons', { - params: removeEmptyField({ - page, - size, - }), - }); + // const response = await server.get('/hackathons', { + // params: removeEmptyField({ + // page, + // size, + // }), + // }); // TODO : API 구현 //return response?.data; return { @@ -119,7 +119,8 @@ export async function getHackathons(page: number = 0, size: number = 10) { applyEndDate: '2022-01-31', hackathonStartDate: '2022-01-01', hackathonEndDate: '2022-01-31', - bannerImageName: 'test1.jpeg', + bannerImageName: + 'https://www.google.com/url?sa=i&url=https%3A%2F%2Fnamu.wiki%2Fw%2F%25EA%25B3%25A0%25EC%2596%2591%25EC%259D%25B4&psig=AOvVaw3B-ulgoJXam-5fTUvEPGSY&ust=1734485784474000&source=images&cd=vfe&opi=89978449&ved=0CBQQjRxqFwoTCMCozZrVrYoDFQAAAAAdAAAAABAE', }, { id: 2, @@ -129,7 +130,7 @@ export async function getHackathons(page: number = 0, size: number = 10) { applyEndDate: '2022-01-31', hackathonStartDate: '2022-01-01', hackathonEndDate: '2022-01-31', - bannerImageName: 'test2.jpeg', + bannerImageName: 'https://www.pusan.ac.kr/_contents/eng/_Img/Content/sym_pic1.png', }, { id: 3, @@ -139,7 +140,7 @@ export async function getHackathons(page: number = 0, size: number = 10) { applyEndDate: '2022-01-31', hackathonStartDate: '2022-01-01', hackathonEndDate: '2022-01-31', - bannerImageName: 'test3.png', + bannerImageName: 'https://www.pusan.ac.kr/_contents/eng/_Img/Content/sym_pic1.png', }, { id: 4, @@ -149,7 +150,7 @@ export async function getHackathons(page: number = 0, size: number = 10) { applyEndDate: '2022-01-31', hackathonStartDate: '2022-01-01', hackathonEndDate: '2022-01-31', - bannerImageName: 'test1.jpeg', + bannerImageName: 'https://www.pusan.ac.kr/_contents/eng/_Img/Content/sym_pic1.png', }, { id: 5, @@ -159,7 +160,7 @@ export async function getHackathons(page: number = 0, size: number = 10) { applyEndDate: '2022-01-31', hackathonStartDate: '2022-01-01', hackathonEndDate: '2022-01-31', - bannerImageName: 'test2.jpeg', + bannerImageName: 'https://www.pusan.ac.kr/_contents/eng/_Img/Content/sym_pic1.png', }, ], empty: false, @@ -173,7 +174,7 @@ export async function getHackathons(page: number = 0, size: number = 10) { } export async function getHackathonInformation(hackathonId: number) { - const response = await server.get(`/hackathons/${hackathonId}`); + // const response = await server.get(`/hackathons/${hackathonId}`); // TODO : API 구현 //return response?.data; return { @@ -187,12 +188,12 @@ This is a **bold** text with some *italic* and [a link](https://example.com). - ㅁ렁ㄹㄴㄹ 1. ㄹㄴㅇㄹㅁㄹ `, - bannerImageName: 'test1.jpeg', + bannerImageName: 'https://www.pusan.ac.kr/_contents/eng/_Img/Content/sym_pic1.png', }; } export async function getHackathonPrize(hackathonId: number) { - const response = await server.get(`/hackathons/${hackathonId}/prizes`); + // const response = await server.get(`/hackathons/${hackathonId}/prizes`); //TODO : API 구현 // return response?.data; return mockHackathonPrize; diff --git a/frontend/src/lib/hooks/useApi.ts b/frontend/src/lib/hooks/useApi.ts index b74c07aa..d942769f 100644 --- a/frontend/src/lib/hooks/useApi.ts +++ b/frontend/src/lib/hooks/useApi.ts @@ -228,10 +228,18 @@ export function useMilestoneHistoryDeleteMutation() { export function useRegisterTeamMutation() { return useAxiosMutation({ - mutationFn: async ({ hackathonId, image, name, work, githubUrl, members, password }: HackathonTeamCreateDto) => { + mutationFn: async ({ + hackathonId, + thumbnailImage, + teamName, + projectTitle, + githubUrl, + members, + password, + }: HackathonTeamCreateDto) => { const formData = new FormData(); - formData.append('image', image!); - const blob = new Blob([JSON.stringify({ name, work, githubUrl, members, password })], { + formData.append('thumbnailImage', thumbnailImage!); + const blob = new Blob([JSON.stringify({ teamName, projectTitle, githubUrl, members, password })], { type: 'application/json', }); formData.append('request', blob); diff --git a/frontend/src/mocks/hackathon.ts b/frontend/src/mocks/hackathon.ts index c375eac0..4ef5393c 100644 --- a/frontend/src/mocks/hackathon.ts +++ b/frontend/src/mocks/hackathon.ts @@ -11,69 +11,71 @@ export const mockHackathonTeamPageableData: HackathonTeamPageableDto = { content: [ { id: 1, - name: '팀 알파', - work: 'ㅁㅁㅁ서비스', + teamName: '알파aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + projectTitle: 'ㅁㅁㅁ서비스', githubUrl: 'https://github.com/pnu-code-place/code-place', teamMembers: { DEVELOPER: [ { id: 3, name: '박하나', - majorName: '공학', + major: '공학', isLeader: false, }, { id: 4, name: '최민수', - majorName: '물리학', + major: '물리학', isLeader: true, }, ], }, - thumbnailImageName: 'test1.jpeg', + thumbnailImage: 'https://www.pusan.ac.kr/_contents/eng/_Img/Content/sym_pic1.png', voteCount: 120, + prize: 'GRAND_PRIZE', }, { id: 2, - name: '팀 베타', - work: 'ㅁㅁㅁ서비스', + teamName: '팀 베타', + projectTitle: 'ㅁㅁㅁ서비스', githubUrl: 'https://github.com/amaran-th/jwp-refactoring', teamMembers: { DEVELOPER: [ { id: 3, name: '박하나', - majorName: '공학', + major: '공학', isLeader: false, }, { id: 4, name: '최민수', - majorName: '물리학', + major: '물리학', isLeader: true, }, ], }, - thumbnailImageName: 'test2.jpeg', + thumbnailImage: 'https://www.pusan.ac.kr/_contents/eng/_Img/Content/sym_pic1.png', voteCount: 95, + prize: 'GRAND_PRIZE', }, { id: 3, - name: '팀 감마', - work: 'ㅁㅁㅁ서비스', + teamName: '팀 감마', + projectTitle: 'ㅁㅁㅁ서비스', githubUrl: 'https://github.com/ueberdosis/tiptap', teamMembers: { DEVELOPER: [ { id: 202055558, name: '정수정', - majorName: '데이터 과학', + major: '데이터 과학', isLeader: true, }, { id: 202055555, name: '김영준', - majorName: '통계학', + major: '통계학', isLeader: false, }, ], @@ -81,61 +83,63 @@ export const mockHackathonTeamPageableData: HackathonTeamPageableDto = { { id: 202000000, name: '김철수', - majorName: '컴퓨터 과학', + major: '컴퓨터 과학', isLeader: false, }, { id: 202012345, name: '이영희', - majorName: '수학', + major: '수학', isLeader: false, }, ], }, - thumbnailImageName: 'test3.png', + thumbnailImage: 'https://www.pusan.ac.kr/_contents/eng/_Img/Content/sym_pic1.png', voteCount: 120, + prize: 'GRAND_PRIZE', }, { id: 4, - name: '팀 베타', - work: 'ㅁㅁㅁ서비스', + teamName: '팀 베타', + projectTitle: 'ㅁㅁㅁ서비스', githubUrl: 'https://github.com/woowacourse-teams/2023-emmsale', teamMembers: { DEVELOPER: [ { id: 3, name: '박하나', - majorName: '공학', + major: '공학', isLeader: false, }, { id: 4, name: '최민수', - majorName: '물리학', + major: '물리학', isLeader: true, }, ], }, - thumbnailImageName: 'test1.jpeg', + thumbnailImage: 'https://www.pusan.ac.kr/_contents/eng/_Img/Content/sym_pic1.png', voteCount: 95, + prize: 'GRAND_PRIZE', }, { id: 5, - name: '팀 감마', - work: 'ㅁㅁㅁ서비스', + teamName: '팀 감마', + projectTitle: 'ㅁㅁㅁ서비스', githubUrl: 'https://github.com/gatsbyjs/gatsby-starter-blog', teamMembers: { DEVELOPER: [ { id: 5, name: '정수정', - majorName: '데이터 과학', + major: '데이터 과학', isLeader: false, }, { id: 6, name: '김영준', - majorName: '통계학', + major: '통계학', isLeader: true, }, ], @@ -143,67 +147,70 @@ export const mockHackathonTeamPageableData: HackathonTeamPageableDto = { { id: 1, name: '김철수', - majorName: '컴퓨터 과학', + major: '컴퓨터 과학', isLeader: true, }, { id: 2, name: '이영희', - majorName: '수학', + major: '수학', isLeader: false, }, ], }, - thumbnailImageName: 'test2.jpeg', + thumbnailImage: 'https://www.pusan.ac.kr/_contents/eng/_Img/Content/sym_pic1.png', voteCount: 120, + prize: 'GRAND_PRIZE', }, { id: 6, - name: '팀 베타', - work: 'ㅁㅁㅁ서비스', + teamName: '팀 베타', + projectTitle: 'ㅁㅁㅁ서비스', githubUrl: 'https://github.com/blueimp/JavaScript-Templates/blob/master/README.md', teamMembers: { DEVELOPER: [ { id: 3, name: '박하나', - majorName: '공학', + major: '공학', isLeader: false, }, { id: 4, name: '최민수', - majorName: '물리학', + major: '물리학', isLeader: true, }, ], }, - thumbnailImageName: 'test3.png', + thumbnailImage: 'https://www.pusan.ac.kr/_contents/eng/_Img/Content/sym_pic1.png', voteCount: 95, + prize: 'GRAND_PRIZE', }, { id: 7, - name: '팀 감마', - work: 'ㅁㅁㅁ서비스', + teamName: '팀 감마', + projectTitle: 'ㅁㅁㅁ서비스', githubUrl: 'https://github.com/team-gamma', teamMembers: { DEVELOPER: [ { id: 5, name: '정수정', - majorName: '데이터 과학', + major: '데이터 과학', isLeader: false, }, { id: 6, name: '김영준', - majorName: '통계학', + major: '통계학', isLeader: true, }, ], }, - thumbnailImageName: 'test1.jpeg', + thumbnailImage: 'https://www.pusan.ac.kr/_contents/eng/_Img/Content/sym_pic1.png', voteCount: 85, + prize: 'GRAND_PRIZE', }, // 필요한 경우 더 많은 팀 데이터를 추가할 수 있습니다. ], diff --git a/frontend/src/types/common.dto.ts b/frontend/src/types/common.dto.ts index 338e7261..1b1e195b 100644 --- a/frontend/src/types/common.dto.ts +++ b/frontend/src/types/common.dto.ts @@ -172,15 +172,15 @@ export interface HackathonDto { teamCode: string; } +export interface HackathonManagePageableDto extends Pageable { + content: HackathonManageDto[]; +} + export interface HackathonManageDto extends Omit { isActive: boolean; } -export interface HackathonManagePageableDto extends Pageable { - content: HackathonManageDto[]; -} - export type HackathonInformationDto = Omit; export type HackathonOverviewDto = Omit; @@ -189,32 +189,33 @@ export interface HackathonPageableDto extends Pageable { content: HackathonOverviewDto[]; } -interface TeamMemberDto { - [key: string]: { id: number; name: string; majorName: string; isLeader: boolean }[]; +export interface TeamMemberDto { + [key: string]: { id: number; name: string; major: string; isLeader: boolean }[]; +} + +export interface TeamMember { + id: number | null; + role: TeamMemberRole; + isLeader: boolean; } export interface HackathonTeamDto { id: number; - name: string; - work: string; + teamName: string; + projectTitle: string; githubUrl: string; + thumbnailImage: string; teamMembers: TeamMemberDto; - thumbnailImageName: string; voteCount: number; -} - -export interface TeamMember { - id: number | null; - role: TeamMemberRole; - isLeader: boolean; + prize: string; } export interface HackathonTeamCreateDto { hackathonId: number; - image: File | null; - name: string; - work: string; + teamName: string; + projectTitle: string; githubUrl: string; + thumbnailImage: File | null; members: TeamMember[]; password: string; }