diff --git a/src/api/evaluation.api.ts b/src/api/evaluation.api.ts new file mode 100644 index 00000000..5bb99af1 --- /dev/null +++ b/src/api/evaluation.api.ts @@ -0,0 +1,22 @@ +import { apiEvaluatedUser } from '../models/evaluation'; +import { httpClient } from './http.api'; + +export const postEvaluation = async (userEvaluation: apiEvaluatedUser) => { + try { + const response = await httpClient.post(`/evaluations`, userEvaluation); + return response.status; + } catch (e) { + console.error(e); + throw e; + } +}; + +export const getEvaluation = async (projectId: number) => { + try { + const response = await httpClient.get(`/evaluations/${projectId}/members`); + return response.data.data; + } catch (e) { + console.error(e); + throw e; + } +}; diff --git a/src/components/evaluation/EvaluationContent.styled.ts b/src/components/evaluation/EvaluationContent.styled.ts new file mode 100644 index 00000000..76242ac1 --- /dev/null +++ b/src/components/evaluation/EvaluationContent.styled.ts @@ -0,0 +1,186 @@ +import styled from 'styled-components'; +import Button from '../common/Button/Button'; + +export const Container = styled.div` + display: flex; + width: 100%; + height: 100vh; +`; + +export const SidebarLeft = styled.div` + width: 240px; + background-color: #fff; + border-right: 1px solid #e0e0e0; + padding: 20px; + + @media (max-width: 1100px) { + width: 190px; + } +`; + +export const ProjectName = styled.h2` + font-size: 20px; + margin-bottom: 20px; +`; + +export const ParticipantButton = styled.button<{ $active?: boolean }>` + width: 100%; + padding: 10px; + margin-bottom: 10px; + background-color: ${({ $active }) => ($active ? '#2a3f5f' : '#3e5c7c')}; + color: ${({ theme }) => theme.color.white}; + border: none; + border-radius: 4px; + cursor: pointer; +`; + +export const MainContent = styled.div` + flex: 1; + display: flex; + flex-direction: column; + padding: 20px 40px 40px 40px; +`; + +export const Header = styled.div` + @media (min-width: 1100px) { + display: flex; + align-items: center; + margin-bottom: 3px; + } + + @media (max-width: 1100px) { + margin-bottom: 3px; + } +`; + +export const Title = styled.h1` + flex: 1; + font-size: 24px; + margin: 0; +`; + +export const MessageContainer = styled.div` + @media (min-width: 1100px) { + display: flex; + justify-content: right; + margin-bottom: 30px; + } + + @media (max-width: 1100px) { + margin-bottom: 30px; + } +`; + +export const ErrorMessage = styled.p` + font-size: 11px; + margin: 0 10px 0 0; + color: ${({ theme }) => theme.color.red}; +`; + +export const SubmitButton = styled(Button)` + padding: 8px 16px; + font-size: 13px; + color: #fff; + cursor: pointer; +`; + +export const ScrollArea = styled.div` + flex: 1; + overflow-y: auto; + padding-right: 16px; + padding-bottom: 70px; + + &::-webkit-scrollbar { + width: 6px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.2); + border-radius: 4px; + } +`; + +export const QuestionBlock = styled.div` + margin-bottom: 40px; +`; + +export const QuestionHeader = styled.div` + background-color: #f0f0f0; + padding: 12px; + border-radius: 4px; + font-weight: 500; + margin-bottom: 16px; +`; + +export const Options = styled.div` + display: flex; + align-items: center; + flex-wrap: wrap; +`; + +export const Option = styled.label` + display: flex; + align-items: center; + margin-right: 24px; + margin-bottom: 6px; +`; + +export const Radio = styled.input` + display: none; + + &:checked + span { + background-color: #3e5c7c; + color: #fff; + } +`; + +export const RadioLabel = styled.span` + display: inline-flex; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: #ddd; + margin-right: 8px; + cursor: pointer; +`; + +export const OptionLabel = styled.span` + font-size: 14px; + color: #333; +`; + +export const SidebarRight = styled.div` + width: 200px; + border-left: 1px solid #e0e0e0; + padding: 20px; + background-color: #fff; + + @media (max-width: 1100px) { + width: 190px; + } +`; + +export const CompletedTitle = styled.h3` + font-size: 18px; + margin-bottom: 20px; +`; + +export const CompletedButton = styled.button<{ $active?: boolean }>` + width: 100%; + padding: 10px; + margin-bottom: 10px; + background-color: ${({ $active }) => ($active ? '#3e5c7c' : '#f0f0f0')}; + color: ${({ $active }) => ($active ? '#fff' : '#999')}; + border: none; + border-radius: 4px; + cursor: ${({ $active }) => ($active ? 'pointer' : 'default')}; + transition: background-color 0.2s; + + &:hover { + background-color: ${({ $active }) => ($active ? '#2f4a6b' : '#e0e0e0')}; + } +`; diff --git a/src/components/evaluation/EvaluationContent.tsx b/src/components/evaluation/EvaluationContent.tsx new file mode 100644 index 00000000..e64be104 --- /dev/null +++ b/src/components/evaluation/EvaluationContent.tsx @@ -0,0 +1,107 @@ +import * as S from './EvaluationContent.styled'; +import ScrollPreventor from '../common/modal/ScrollPreventor'; +import useEvaluationStep from '../../hooks/evaluationHooks/useEvaluationStep'; +import { MemberList } from '../../models/evaluation'; +import { optionLabels, questions } from '../../constants/evaluation'; +interface EvaluationContentProps { + projectId: number; + projectName: string; + memberList: MemberList[]; +} + +const EvaluationContent = ({ + projectId, + projectName, + memberList, +}: EvaluationContentProps) => { + const { + step, + notDone, + handleClickLeftUser, + handleClickOption, + handleNextStep, + currentScores, + isNotFill, + } = useEvaluationStep({ projectId, memberList }); + + return ( + + + + {projectName} + {notDone.map((name, idx) => ( + handleClickLeftUser(idx)} + > + {name.nickname} + + ))} + + + + + {notDone[step]?.nickname}님 평가하기 + + 제출하기 + + + + {isNotFill && ( + 모든 질문에 답변해주세요. + )} + + + + {questions.map((q, questionNumber) => ( + + + {questionNumber + 1}. {q} + + + {optionLabels.map((label, optionValue) => ( + + + handleClickOption(questionNumber, optionValue) + } + value={optionValue + 1} + /> + {optionValue + 1} + {label} + + ))} + + + ))} + + + + + 평가완료 + {memberList + .filter((memberData) => memberData.evaluated) + .map((memberData) => ( + + {memberData.nickname} + + ))} + + + + ); +}; + +export default EvaluationContent; diff --git a/src/constants/evaluation.ts b/src/constants/evaluation.ts new file mode 100644 index 00000000..d4f84c6f --- /dev/null +++ b/src/constants/evaluation.ts @@ -0,0 +1,16 @@ +export const questions = [ + '다른 팀원에 비해 비상한 아이디어가 있었나요?', + '맡은 파트를 책임감 있게 한다고 생각하시나요', + '기술력이 좋다고 생각 하시나요?', + '다른 팀원들과의 협업이 잘 이루어졌다 생각 하시나요?', + '문제에 직면 했을 때 끈기 있게 해결 하려고 하나요?', + '참여자가 프로젝트에 성실히 임했다 생각 하시나요?', +] as const; + +export const optionLabels = [ + '매우그렇다', + '그렇다', + '보통이다', + '그렇지않다', + '매우그렇지않다', +] as const; diff --git a/src/constants/routes.ts b/src/constants/routes.ts index 3b0c5566..c79eb04b 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -28,5 +28,6 @@ export const ROUTES = { notice: 'notice', noticeDetail: 'notice-detail', inquiry: '/inquiry', + evaluation: '/evaluation', loginSuccess: '/login/oauth2/code', } as const; diff --git a/src/hooks/evaluationHooks/useEvaluationStep.ts b/src/hooks/evaluationHooks/useEvaluationStep.ts new file mode 100644 index 00000000..0ad8d295 --- /dev/null +++ b/src/hooks/evaluationHooks/useEvaluationStep.ts @@ -0,0 +1,97 @@ +import { useEffect, useMemo, useState } from 'react'; +import { MemberList } from '../../models/evaluation'; +import { questions } from '../../constants/evaluation'; +import { usePostEvaluation } from './usePostEvaluation'; + +interface useEvaluationStepProps { + projectId: number; + memberList: MemberList[]; +} + +const useEvaluationStep = ({ + projectId, + memberList = [], +}: useEvaluationStepProps) => { + const questionLength = questions.length; + const [step, setStep] = useState(0); + const [notDone, setNotDone] = useState([]); + const [progress, setProgress] = useState[]>([]); + const [isNotFill, setIsNotFill] = useState(false); + + const { createEvaluation } = usePostEvaluation(projectId); + + useEffect(() => { + if (memberList.length === 0) return; + + setNotDone(memberList.filter((m) => !m.evaluated)); + + setProgress( + memberList.map((m) => ({ + [m.userId]: Array(questionLength).fill(0), + })) + ); + + setStep(0); + setIsNotFill(false); + }, [memberList, questionLength]); + + const user = notDone[step]?.userId; + + const handleClickLeftUser = (idx: number) => { + setIsNotFill(false); + setStep(idx); + }; + + const handleClickOption = (questionNumber: number, optionValue: number) => { + const realValue = optionValue + 1; + + setProgress((prev) => + prev.map((record) => + user in record + ? { + [user]: record[user].map((v, i) => + i === questionNumber ? realValue : v + ), + } + : record + ) + ); + }; + const handleNextStep = () => { + if (user == null) return; + + const record = progress.find((r) => user in r); + const scores = record ? record[user] : []; + + if (scores.some((v) => v === 0)) { + setIsNotFill(true); + return; + } else { + setIsNotFill(false); + createEvaluation({ + projectId: projectId, + evaluateeId: user, + scores: scores, + }); + + setNotDone((prev) => prev.filter((e) => e.userId !== user)); + } + }; + + const currentScores = useMemo(() => { + const record = progress.find((r) => user in r); + return record ? record[user] : Array(questionLength).fill(0); + }, [progress, questionLength, user]); + + return { + step, + handleClickLeftUser, + handleClickOption, + handleNextStep, + notDone, + currentScores, + isNotFill, + }; +}; + +export default useEvaluationStep; diff --git a/src/hooks/evaluationHooks/useGetEvaluation.ts b/src/hooks/evaluationHooks/useGetEvaluation.ts new file mode 100644 index 00000000..f660a8a5 --- /dev/null +++ b/src/hooks/evaluationHooks/useGetEvaluation.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; +import { getEvaluation } from '../../api/evaluation.api'; +import { ProjectMemberListEval } from '../queries/keys'; + +const useGetCompletedEvaluation = (id: number) => { + const { data, isLoading, isFetching, isError } = useQuery({ + queryKey: [ProjectMemberListEval.MemberListEval, id], + queryFn: () => getEvaluation(id), + staleTime: 1000 * 60 * 5, + }); + + return { + memberList: data, + isLoading, + isFetching, + isError, + }; +}; + +export default useGetCompletedEvaluation; diff --git a/src/hooks/evaluationHooks/usePostEvaluation.ts b/src/hooks/evaluationHooks/usePostEvaluation.ts new file mode 100644 index 00000000..9fdddbf3 --- /dev/null +++ b/src/hooks/evaluationHooks/usePostEvaluation.ts @@ -0,0 +1,34 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { postEvaluation } from '../../api/evaluation.api'; +import { ProjectMemberListEval } from '../queries/keys'; +import { apiEvaluatedUser } from '../../models/evaluation'; + +export const usePostEvaluation = (projectId: number) => { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: (userEvaluation: apiEvaluatedUser) => + postEvaluation(userEvaluation), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [ProjectMemberListEval.MemberListEval, projectId], + exact: true, + }); + }, + onError: (error) => { + console.error(error); + }, + }); + + const createEvaluation = async (userEvaluation: apiEvaluatedUser) => { + mutation.mutate(userEvaluation); + }; + + return { + createEvaluation, + isLoading: mutation.isPending, + isError: mutation.isError, + error: mutation.error, + isSuccess: mutation.isSuccess, + }; +}; diff --git a/src/hooks/queries/keys.ts b/src/hooks/queries/keys.ts index 7522fcd1..157a6eb3 100644 --- a/src/hooks/queries/keys.ts +++ b/src/hooks/queries/keys.ts @@ -25,19 +25,23 @@ export const ProjectListKey = { export const ProjectCommentList = { projectComment: ['projectCommentList'], -}; +} as const; export const AlarmList = { myAlarmList: ['AlarmList'], -}; +} as const; export const ProjectReplyList = { commentReply: ['CommentReplyList'], -}; +} as const; export const ChartDataList = { chartData: ['ChartDataList'], -}; +} as const; + +export const ProjectMemberListEval = { + MemberListEval: ['MemberListEval'], +} as const; export const ActivityLog = { myComments: ['MyComments'], diff --git a/src/models/evaluation.ts b/src/models/evaluation.ts new file mode 100644 index 00000000..239114e0 --- /dev/null +++ b/src/models/evaluation.ts @@ -0,0 +1,16 @@ +export interface apiEvaluatedUser { + projectId: number; + evaluateeId: number; + scores: number[]; +} + +export interface apiMemberList { + projectName: string; + userData: MemberList[]; +} + +export interface MemberList { + userId: number; + nickname: string; + evaluated: boolean; +} diff --git a/src/pages/evaluation/Evaluation.styled.ts b/src/pages/evaluation/Evaluation.styled.ts new file mode 100644 index 00000000..c3389834 --- /dev/null +++ b/src/pages/evaluation/Evaluation.styled.ts @@ -0,0 +1,3 @@ +import styled from 'styled-components'; + +export const Container = styled.div``; diff --git a/src/pages/evaluation/Evaluation.tsx b/src/pages/evaluation/Evaluation.tsx new file mode 100644 index 00000000..a957b7e8 --- /dev/null +++ b/src/pages/evaluation/Evaluation.tsx @@ -0,0 +1,33 @@ +import { useParams } from 'react-router-dom'; +import * as S from './Evaluation.styled'; +import useGetCompletedEvaluation from '../../hooks/evaluationHooks/useGetEvaluation'; +import EvaluationContent from '../../components/evaluation/EvaluationContent'; +import LoadingSpinner from '../../components/common/loadingSpinner/LoadingSpinner'; + +const Evaluation = () => { + const { projectId: projectIdParam } = useParams(); + const projectId = Number(projectIdParam); + + const { memberList, isLoading, isFetching } = + useGetCompletedEvaluation(projectId); + + if (isLoading || isFetching) { + return ; + } + + if (!memberList.userData.length) { + return 평가할 멤버가 없습니다.; + } + + return ( + + + + ); +}; + +export default Evaluation; diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx index 3f47fef3..0916a1c3 100644 --- a/src/routes/AppRoutes.tsx +++ b/src/routes/AppRoutes.tsx @@ -90,6 +90,7 @@ const UserJoinProject = lazy( const ModifyProject = lazy( () => import('../pages/modifyProject/ModifyProject') ); +const Evaluation = lazy(() => import('../pages/evaluation/Evaluation')); const AppRoutes = () => { const isLoggedIn = useAuthStore((state) => state.isLoggedIn); @@ -345,6 +346,20 @@ const AppRoutes = () => { ), }, + { + path: `${ROUTES.evaluation}/:projectId`, + element: ( + + + }> + + + + + + + ), + }, ]; const newRouteList = routeList.map((item) => {