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) => {