diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index d96a5b1e..0ba764b9 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1 +1 @@
-* @ghdtjgus76 @eugene028 @hamo-o @SeieunYoo
\ No newline at end of file
+* @eugene028 @hamo-o @SeieunYoo @soulchicken
\ No newline at end of file
diff --git a/apps/admin/apis/study/studyAchievementApi.ts b/apps/admin/apis/study/studyAchievementApi.ts
new file mode 100644
index 00000000..e3c05a16
--- /dev/null
+++ b/apps/admin/apis/study/studyAchievementApi.ts
@@ -0,0 +1,28 @@
+import { fetcher } from "@wow-class/utils";
+import type { OutstandingStudentApiRequestDto } from "types/dtos/outstandingStudent";
+
+const studyAchievementApi = {
+ postStudyAchievement: async (
+ studyId: number,
+ data: OutstandingStudentApiRequestDto
+ ) => {
+ const response = await fetcher.post(
+ `/mentor/study-achievements?studyId=${studyId}`,
+ data
+ );
+ return { success: response.ok };
+ },
+
+ deleteStudyAchievement: async (
+ studyId: number,
+ data: OutstandingStudentApiRequestDto
+ ) => {
+ const response = await fetcher.delete(
+ `/mentor/study-achievements?studyId=${studyId}`,
+ data
+ );
+ return { success: response.ok };
+ },
+};
+
+export default studyAchievementApi;
diff --git a/apps/admin/apis/study/studyCompleteApi.ts b/apps/admin/apis/study/studyCompleteApi.ts
new file mode 100644
index 00000000..01a59875
--- /dev/null
+++ b/apps/admin/apis/study/studyCompleteApi.ts
@@ -0,0 +1,19 @@
+import { fetcher } from "@wow-class/utils";
+import type { StudyCompleteRequestDto } from "types/dtos/studyComplete";
+
+const studyCompleteApi = {
+ postStudyComplete: async (data: StudyCompleteRequestDto) => {
+ const response = await fetcher.post(`/mentor/study-history/complete`, data);
+ return { success: response.ok };
+ },
+
+ postStudyCompleteWithdraw: async (data: StudyCompleteRequestDto) => {
+ const response = await fetcher.post(
+ `/mentor/study-history/withdraw-completion`,
+ data
+ );
+ return { success: response.ok };
+ },
+};
+
+export default studyCompleteApi;
diff --git a/apps/admin/app/layout.tsx b/apps/admin/app/layout.tsx
index 958856e0..d23bf51a 100644
--- a/apps/admin/app/layout.tsx
+++ b/apps/admin/app/layout.tsx
@@ -49,12 +49,6 @@ const RootLayout = ({
return (
-
{
+ return ;
+};
+
+export default StudentStatusModalPage;
diff --git a/apps/admin/app/students/@modal/_components/CompleteModalButton.tsx b/apps/admin/app/students/@modal/_components/CompleteModalButton.tsx
new file mode 100644
index 00000000..519ba074
--- /dev/null
+++ b/apps/admin/app/students/@modal/_components/CompleteModalButton.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import studyCompleteApi from "apis/study/studyCompleteApi";
+import { useAtomValue } from "jotai";
+import Button from "wowds-ui/Button";
+
+import {
+ enabledOutstandingStudentsAtom,
+ outstandingStudentsAtom,
+ selectedStudentsAtom,
+ studyAtom,
+} from "@/students/_contexts/StudyProvider";
+
+import useCloseStudentStatusModal from "../_hooks/useCloseStudentStatusModal";
+
+const CompleteModalButton = () => {
+ const study = useAtomValue(studyAtom);
+ const { enabled } = useAtomValue(enabledOutstandingStudentsAtom);
+ const { type } = useAtomValue(outstandingStudentsAtom);
+ const { students } = useAtomValue(selectedStudentsAtom);
+
+ const {
+ handleClickCloseModal,
+ closeModalWithSuccess,
+ closeModalWithFailure,
+ } = useCloseStudentStatusModal();
+
+ const handleClickComplete = async () => {
+ if (!study || !type) return;
+
+ const apiMap = {
+ 처리: studyCompleteApi.postStudyComplete,
+ 철회: studyCompleteApi.postStudyCompleteWithdraw,
+ };
+
+ const fetchApi = () => {
+ const fetch = apiMap[type];
+ return fetch({
+ studyId: study.studyId,
+ studentIds: Array.from(students),
+ });
+ };
+ const result = await fetchApi();
+ if (result.success) closeModalWithSuccess();
+ else closeModalWithFailure();
+ };
+
+ return enabled ? (
+
+ ) : (
+
+ );
+};
+
+export default CompleteModalButton;
diff --git a/apps/admin/app/students/@modal/_components/OutstandingModalButton.tsx b/apps/admin/app/students/@modal/_components/OutstandingModalButton.tsx
new file mode 100644
index 00000000..53ea7e1c
--- /dev/null
+++ b/apps/admin/app/students/@modal/_components/OutstandingModalButton.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import studyAchievementApi from "apis/study/studyAchievementApi";
+import { useAtomValue } from "jotai";
+import type { AchievementType } from "types/entities/achievement";
+import Button from "wowds-ui/Button";
+
+import {
+ enabledOutstandingStudentsAtom,
+ outstandingStudentsAtom,
+ selectedStudentsAtom,
+ studyAtom,
+} from "@/students/_contexts/StudyProvider";
+
+import useCloseStudentStatusModal from "../_hooks/useCloseStudentStatusModal";
+
+const OutstandingModalButton = () => {
+ const study = useAtomValue(studyAtom);
+ const { enabled } = useAtomValue(enabledOutstandingStudentsAtom);
+ const { type, achievement } = useAtomValue(outstandingStudentsAtom);
+ const { students } = useAtomValue(selectedStudentsAtom);
+
+ const {
+ handleClickCloseModal,
+ closeModalWithSuccess,
+ closeModalWithFailure,
+ } = useCloseStudentStatusModal();
+
+ const handleClickOutstanding = async () => {
+ if (!study || !achievement || !type) return;
+
+ const apiMap = {
+ 처리: studyAchievementApi.postStudyAchievement,
+ 철회: studyAchievementApi.deleteStudyAchievement,
+ };
+
+ const fetchApi = () => {
+ const fetch = apiMap[type];
+ return fetch(study.studyId, {
+ studentIds: Array.from(students),
+ achievementType: achievement as AchievementType,
+ });
+ };
+ const result = await fetchApi();
+ if (result.success) closeModalWithSuccess();
+ else closeModalWithFailure();
+ };
+
+ return enabled ? (
+
+ ) : (
+
+ );
+};
+
+export default OutstandingModalButton;
diff --git a/apps/admin/app/students/@modal/_components/StudentStatusModal.tsx b/apps/admin/app/students/@modal/_components/StudentStatusModal.tsx
new file mode 100644
index 00000000..cc7f63a9
--- /dev/null
+++ b/apps/admin/app/students/@modal/_components/StudentStatusModal.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { Flex, styled } from "@styled-system/jsx";
+import { Modal, Text } from "@wow-class/ui";
+import { useAtomValue } from "jotai";
+
+import {
+ outstandingStudentsAtom,
+ selectedStudentsAtom,
+} from "@/students/_contexts/StudyProvider";
+
+import CompleteModalButton from "./CompleteModalButton";
+import OutstandingModalButton from "./OutstandingModalButton";
+import StudentStatusModalHeader from "./StudentStatusModalHeader";
+
+const StudentStatusModal = () => {
+ const { firstStudentName, students } = useAtomValue(selectedStudentsAtom);
+ const { type, achievement } = useAtomValue(outstandingStudentsAtom);
+
+ const STUDENTS_NUM = students.size;
+ if (!type || !achievement) return null;
+ if (!STUDENTS_NUM) return 선택된 수강생이 없습니다.;
+
+ const renderAdditionalStudents = () => {
+ if (STUDENTS_NUM === 1) return null;
+ return (
+
+ 외 {STUDENTS_NUM - 1}명
+
+ );
+ };
+
+ return (
+
+
+
+
+ {firstStudentName} 님 {renderAdditionalStudents()}
+
+ {achievement === "COMPLETE" ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default StudentStatusModal;
diff --git a/apps/admin/app/students/@modal/_components/StudentStatusModalHeader.tsx b/apps/admin/app/students/@modal/_components/StudentStatusModalHeader.tsx
new file mode 100644
index 00000000..240e70ac
--- /dev/null
+++ b/apps/admin/app/students/@modal/_components/StudentStatusModalHeader.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import { Text } from "@wow-class/ui";
+import {
+ outstandingRoundMap,
+ outstandingTypeMap,
+} from "constants/status/outstandigOptions";
+import { useAtomValue } from "jotai";
+
+import {
+ enabledOutstandingStudentsAtom,
+ outstandingStudentsAtom,
+} from "@/students/_contexts/StudyProvider";
+
+const StudentStatusModalHeader = () => {
+ const { enabled } = useAtomValue(enabledOutstandingStudentsAtom);
+ const { type, achievement } = useAtomValue(outstandingStudentsAtom);
+
+ if (!type || !achievement) return null;
+ return enabled ? (
+
+ 선택한 수강생을
+ {outstandingRoundMap[achievement]} {outstandingTypeMap[type]}
+ 하시겠어요?
+
+ ) : (
+
+ {outstandingRoundMap[achievement]} {outstandingTypeMap[type]}
+ 되었어요.
+
+ );
+};
+export default StudentStatusModalHeader;
diff --git a/apps/admin/app/students/@modal/_hooks/useCloseStudentStatusModal.ts b/apps/admin/app/students/@modal/_hooks/useCloseStudentStatusModal.ts
new file mode 100644
index 00000000..7dba684c
--- /dev/null
+++ b/apps/admin/app/students/@modal/_hooks/useCloseStudentStatusModal.ts
@@ -0,0 +1,51 @@
+import { useModalRoute } from "@wow-class/ui/hooks";
+import { tags } from "constants/tags";
+import { useSetAtom } from "jotai";
+import { revalidateTagByName } from "utils/revalidateTagByName";
+
+import {
+ enabledOutstandingStudentsAtom,
+ selectedStudentsAtom,
+} from "@/students/_contexts/StudyProvider";
+
+const useCloseStudentStatusModal = () => {
+ const setSelectedStudents = useSetAtom(selectedStudentsAtom);
+ const setEnabledOutstandingStudents = useSetAtom(
+ enabledOutstandingStudentsAtom
+ );
+ const { onClose } = useModalRoute();
+
+ const handleClickCloseModal = () => {
+ setSelectedStudents({
+ students: new Set(),
+ firstStudentName: "",
+ });
+ onClose();
+ };
+
+ const resetStudents = () => {
+ revalidateTagByName(tags.students);
+ setEnabledOutstandingStudents({
+ enabled: false,
+ });
+ };
+
+ const closeModalWithSuccess = () => {
+ resetStudents();
+ setTimeout(() => {
+ handleClickCloseModal();
+ }, 1000);
+ };
+
+ const closeModalWithFailure = () => {
+ resetStudents();
+ handleClickCloseModal();
+ };
+
+ return {
+ handleClickCloseModal,
+ closeModalWithSuccess,
+ closeModalWithFailure,
+ };
+};
+export default useCloseStudentStatusModal;
diff --git a/apps/admin/app/students/@modal/default.tsx b/apps/admin/app/students/@modal/default.tsx
new file mode 100644
index 00000000..395785b9
--- /dev/null
+++ b/apps/admin/app/students/@modal/default.tsx
@@ -0,0 +1,5 @@
+const Default = () => {
+ return null;
+};
+
+export default Default;
diff --git a/apps/admin/app/students/_components/OutstandingDropDown/DropDownTrigger.tsx b/apps/admin/app/students/_components/OutstandingDropDown/DropDownTrigger.tsx
new file mode 100644
index 00000000..68187263
--- /dev/null
+++ b/apps/admin/app/students/_components/OutstandingDropDown/DropDownTrigger.tsx
@@ -0,0 +1,12 @@
+import type { OutstandingType } from "constants/status/outstandigOptions";
+import Button from "wowds-ui/Button";
+
+const DropDownTrigger = ({ type }: { type: OutstandingType }) => {
+ return (
+
+ );
+};
+
+export default DropDownTrigger;
diff --git a/apps/admin/app/students/_components/OutstandingDropDown/index.tsx b/apps/admin/app/students/_components/OutstandingDropDown/index.tsx
new file mode 100644
index 00000000..12d1a4ef
--- /dev/null
+++ b/apps/admin/app/students/_components/OutstandingDropDown/index.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { styled } from "@styled-system/jsx";
+import type { OutstandingType } from "constants/status/outstandigOptions";
+import {
+ OUTSTANDING_ADD_OPTIONS,
+ OUTSTANDING_DEL_OPTIONS,
+} from "constants/status/outstandigOptions";
+import { useSetAtom } from "jotai";
+import type { AchievementType } from "types/entities/achievement";
+import DropDown from "wowds-ui/DropDown";
+import DropDownOption from "wowds-ui/DropDownOption";
+
+import {
+ enabledOutstandingStudentsAtom,
+ outstandingStudentsAtom,
+} from "@/students/_contexts/StudyProvider";
+
+import DropDownTrigger from "./DropDownTrigger";
+
+const OutstandingDropDown = ({ type }: { type: OutstandingType }) => {
+ const setOutstandingStudents = useSetAtom(outstandingStudentsAtom);
+ const setEnabled = useSetAtom(enabledOutstandingStudentsAtom);
+
+ const findOptions = () => {
+ if (type === "처리") return OUTSTANDING_ADD_OPTIONS;
+ if (type === "철회") return OUTSTANDING_DEL_OPTIONS;
+ return null;
+ };
+
+ const options = findOptions();
+
+ return (
+
+
+
+ }
+ onChange={(value: {
+ selectedValue: string;
+ selectedText: React.ReactNode;
+ }) => {
+ setOutstandingStudents({
+ type,
+ achievement: value.selectedValue as AchievementType,
+ });
+ setEnabled({ enabled: true });
+ }}
+ >
+ {options &&
+ options.map((option) => (
+
+ ))}
+
+ );
+};
+
+export default OutstandingDropDown;
diff --git a/apps/admin/app/students/_components/StudentTable/StudentList.tsx b/apps/admin/app/students/_components/StudentTable/StudentList.tsx
index 205ab035..4095f143 100644
--- a/apps/admin/app/students/_components/StudentTable/StudentList.tsx
+++ b/apps/admin/app/students/_components/StudentTable/StudentList.tsx
@@ -1,7 +1,16 @@
+import { styled } from "@styled-system/jsx";
import { Text } from "@wow-class/ui";
+import { useAtom, useAtomValue } from "jotai";
import type { StudyStudentApiResponseDto } from "types/dtos/studyStudent";
+import Checkbox from "wowds-ui/Checkbox";
import Table from "wowds-ui/Table";
+import {
+ enabledOutstandingStudentsAtom,
+ outstandingStudentsAtom,
+ selectedStudentsAtom,
+} from "@/students/_contexts/StudyProvider";
+
import StudentListItem from "./StudentListItem";
import { StudyTasksThs } from "./StudyTasks";
@@ -23,25 +32,113 @@ const StudentList = ({
}: {
studentList: StudyStudentApiResponseDto[] | [];
}) => {
+ const { enabled } = useAtomValue(enabledOutstandingStudentsAtom);
+ const { type, achievement } = useAtomValue(outstandingStudentsAtom);
+ const [selectedStudents, setSelectedStudents] = useAtom(selectedStudentsAtom);
+
if (!studentList) return null;
if (!studentList.length) return 스터디 수강생이 없어요.;
+ const handleChangeSelectedStudents = (ids: number[]) => {
+ const firstStudent = studentList.find(
+ (student) => student.memberId === ids[0]
+ )?.name;
+
+ setSelectedStudents({
+ firstStudentName: firstStudent,
+ students: new Set(ids),
+ });
+ };
+
+ const isDisabled = (student: StudyStudentApiResponseDto) => {
+ if (achievement === "COMPLETE") {
+ return type === "처리"
+ ? student.studyHistoryStatus === "COMPLETED"
+ : student.studyHistoryStatus !== "COMPLETED";
+ } else if (achievement === "FIRST_ROUND_OUTSTANDING_STUDENT") {
+ return type === "처리"
+ ? student.isFirstRoundOutstandingStudent
+ : !student.isFirstRoundOutstandingStudent;
+ } else if (achievement === "SECOND_ROUND_OUTSTANDING_STUDENT") {
+ return type === "처리"
+ ? student.isSecondRoundOutstandingStudent
+ : !student.isSecondRoundOutstandingStudent;
+ }
+ };
+
+ const isAllChecked =
+ studentList.filter((student) => !isDisabled(student)).length ===
+ selectedStudents.students.size;
+
+ const handleChangeAllChecked = () => {
+ if (isAllChecked) {
+ setSelectedStudents({
+ firstStudentName: "",
+ students: new Set(),
+ });
+ return;
+ }
+ handleChangeSelectedStudents(
+ studentList
+ .filter((student) => !isDisabled(student))
+ .map((student) => student.memberId)
+ );
+ };
+
+ const handleChangeSingleChecked = (id: number) => {
+ if (selectedStudents.students.has(id)) {
+ const newSet = new Set(selectedStudents.students);
+ newSet.delete(id);
+ handleChangeSelectedStudents([...newSet]);
+ return;
+ }
+ handleChangeSelectedStudents([...selectedStudents.students, id]);
+ };
+
return (
-
-
- {STUENT_INFO_LIST_BEFORE.map((info) => (
- {info}
- ))}
- {studentList[0] && }
- {STUDENT_INFO_LIST_AFTER.map((info) => (
- {info}
- ))}
-
+
+
+
+ {enabled && (
+
+
+
+ )}
+ {STUENT_INFO_LIST_BEFORE.map((info) => (
+ {info}
+ ))}
+ {studentList[0] && (
+
+ )}
+ {STUDENT_INFO_LIST_AFTER.map((info) => (
+ {info}
+ ))}
+
+
{studentList.map((student) => (
-
+
+ {enabled && (
+
+ handleChangeSingleChecked(student.memberId)}
+ />
+
+ )}
-
+
))}
@@ -49,3 +146,11 @@ const StudentList = ({
};
export default StudentList;
+
+const tableCheckBoxStyle = {
+ minWidth: "15px",
+ display: "flex",
+ minHeight: "44px",
+ justifyContent: "center",
+ alignItems: "center",
+};
diff --git a/apps/admin/app/students/_components/StudentsContent.tsx b/apps/admin/app/students/_components/StudentsContent.tsx
new file mode 100644
index 00000000..90088ee4
--- /dev/null
+++ b/apps/admin/app/students/_components/StudentsContent.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { Text } from "@wow-class/ui";
+import useFetchStudents from "hooks/fetch/useFetchStudents";
+import { useAtomValue } from "jotai";
+import { useState } from "react";
+
+import { studyAtom } from "../_contexts/StudyProvider";
+import StudentFilter from "./StudentFilter";
+import StudentPagination from "./StudentPagination";
+import StudentList from "./StudentTable/StudentList";
+
+const StudentsContent = () => {
+ const selectedStudy = useAtomValue(studyAtom);
+ const [page, setPage] = useState(1);
+ const handleClickChangePage = (nextPage: number) => {
+ setPage(nextPage);
+ };
+
+ const { studentList, pageInfo } = useFetchStudents(selectedStudy, page);
+ if (!selectedStudy) return 담당한 스터디가 없어요.;
+
+ return (
+ <>
+ {/* TODO: 페이지네이션 API 필터 추가 후 주석 해제
+ {studentList.length ? : null}
+ */}
+
+
+ >
+ );
+};
+
+export default StudentsContent;
diff --git a/apps/admin/app/students/_components/StudentsHeader.tsx b/apps/admin/app/students/_components/StudentsHeader.tsx
deleted file mode 100644
index 10029b52..00000000
--- a/apps/admin/app/students/_components/StudentsHeader.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { Flex, styled } from "@styled-system/jsx";
-import { Text } from "@wow-class/ui";
-import { studyApi } from "apis/study/studyApi";
-import ItemSeparator from "components/ItemSeparator";
-import Image from "next/image";
-import type { CSSProperties } from "react";
-import { useEffect, useState } from "react";
-import type { StudyListApiResponseDto } from "types/dtos/studyList";
-
-import StudyDropDown from "./StudyDropDown";
-
-const StudentsHeader = ({
- studyList,
- studyId,
- studentLength,
-}: {
- studyList: StudyListApiResponseDto[];
- studyId: number;
- studentLength: number;
-}) => {
- const [url, setUrl] = useState("");
-
- useEffect(() => {
- const fetchData = async () => {
- const response = await studyApi.getStudyStudentsExcel(studyId);
- const blob = new Blob([response], {
- type: "application/vnd.ms-excel",
- });
- const url = URL.createObjectURL(blob);
- if (url) setUrl(url);
- };
-
- if (studentLength) fetchData();
- }, [studyId, studentLength]);
-
- return (
-
-
- 수강생 관리
-
-
- {studyId && !!studentLength && (
-
-
-
- )}
-
- );
-};
-
-const titleStyle: CSSProperties = {
- display: "flex",
- alignItems: "center",
- gap: "0.75rem",
- whiteSpace: "nowrap",
-};
-
-export default StudentsHeader;
diff --git a/apps/admin/app/students/_components/StudentsHeader/DownloadButton.tsx b/apps/admin/app/students/_components/StudentsHeader/DownloadButton.tsx
new file mode 100644
index 00000000..c0c65452
--- /dev/null
+++ b/apps/admin/app/students/_components/StudentsHeader/DownloadButton.tsx
@@ -0,0 +1,16 @@
+import { styled } from "@styled-system/jsx";
+import useFetchStudentsExcelUrl from "hooks/fetch/useFetchStudentsExcelUrl";
+import Image from "next/image";
+
+const DownloadButton = ({ studyId }: { studyId: number }) => {
+ const url = useFetchStudentsExcelUrl({
+ studyId,
+ });
+
+ return (
+
+
+
+ );
+};
+export default DownloadButton;
diff --git a/apps/admin/app/students/_components/StudentsHeader/StudentHeaderButtons.tsx b/apps/admin/app/students/_components/StudentsHeader/StudentHeaderButtons.tsx
new file mode 100644
index 00000000..43da08a4
--- /dev/null
+++ b/apps/admin/app/students/_components/StudentsHeader/StudentHeaderButtons.tsx
@@ -0,0 +1,47 @@
+import { outstandingRoundMap } from "constants/status/outstandigOptions";
+import { useAtom, useAtomValue } from "jotai";
+import Link from "next/link";
+import Button from "wowds-ui/Button";
+
+import {
+ enabledOutstandingStudentsAtom,
+ outstandingStudentsAtom,
+ selectedStudentsAtom,
+} from "../../_contexts/StudyProvider";
+import OutstandingDropDown from "../OutstandingDropDown";
+
+const StudentsHeaderButtons = () => {
+ const [{ enabled }, setEnabledOutstandingStudents] = useAtom(
+ enabledOutstandingStudentsAtom
+ );
+ const { type, achievement } = useAtomValue(outstandingStudentsAtom);
+ const [{ students }, setSelectedStudents] = useAtom(selectedStudentsAtom);
+
+ const handleClickCancelButton = () => {
+ setEnabledOutstandingStudents({ enabled: false });
+ setSelectedStudents({ firstStudentName: "", students: new Set() });
+ };
+
+ return enabled ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ );
+};
+
+export default StudentsHeaderButtons;
diff --git a/apps/admin/app/students/_components/StudentsHeader/index.tsx b/apps/admin/app/students/_components/StudentsHeader/index.tsx
new file mode 100644
index 00000000..356af097
--- /dev/null
+++ b/apps/admin/app/students/_components/StudentsHeader/index.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { Flex } from "@styled-system/jsx";
+import { Text } from "@wow-class/ui";
+import { studyApi } from "apis/study/studyApi";
+import ItemSeparator from "components/ItemSeparator";
+import { useAtom } from "jotai";
+import type { CSSProperties } from "react";
+import { useEffect, useState } from "react";
+import type { StudyListApiResponseDto } from "types/dtos/studyList";
+import isAdmin from "utils/isAdmin";
+
+import { studyAtom } from "../../_contexts/StudyProvider";
+import StudyDropDown from "../StudyDropDown";
+import DownloadButton from "./DownloadButton";
+import StudentsHeaderButtons from "./StudentHeaderButtons";
+
+const StudentsHeader = () => {
+ const [studyList, setStudyList] = useState();
+ const [selectedStudy, setSelectedStudy] = useAtom(studyAtom);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ const adminStatus = await isAdmin();
+ const data = adminStatus
+ ? await studyApi.getStudyList()
+ : await studyApi.getMyStudyList();
+
+ if (data && data.length && data[0]) {
+ setStudyList(data);
+ setSelectedStudy({ studyId: data[0].studyId, title: data[0].title });
+ }
+ };
+
+ fetchData();
+ }, [setSelectedStudy]);
+
+ if (!selectedStudy || !studyList) return null;
+
+ return (
+
+
+ 수강생 관리
+
+
+
+
+ {selectedStudy.studyId && (
+
+ )}
+
+
+ );
+};
+
+const titleStyle: CSSProperties = {
+ display: "flex",
+ alignItems: "center",
+ gap: "0.75rem",
+ whiteSpace: "nowrap",
+};
+
+export default StudentsHeader;
diff --git a/apps/admin/app/students/_components/StudyDropDown/index.tsx b/apps/admin/app/students/_components/StudyDropDown/index.tsx
index d19a55a9..161864f6 100644
--- a/apps/admin/app/students/_components/StudyDropDown/index.tsx
+++ b/apps/admin/app/students/_components/StudyDropDown/index.tsx
@@ -1,11 +1,15 @@
import { Flex } from "@styled-system/jsx";
-import { useAtom } from "jotai";
+import { useAtom, useSetAtom } from "jotai";
import type { ReactNode } from "react";
import type { StudyListApiResponseDto } from "types/dtos/studyList";
import DropDown from "wowds-ui/DropDown";
import DropDownOption from "wowds-ui/DropDownOption";
-import { studyAtom } from "../../_contexts/StudyProvider";
+import {
+ enabledOutstandingStudentsAtom,
+ selectedStudentsAtom,
+ studyAtom,
+} from "../../_contexts/StudyProvider";
import DropDownTrigger from "./DropDownTrigger";
const StudyDropDown = ({
@@ -14,6 +18,8 @@ const StudyDropDown = ({
studyList: StudyListApiResponseDto[];
}) => {
const [study, setStudy] = useAtom(studyAtom);
+ const setSelectedStudents = useSetAtom(selectedStudentsAtom);
+ const setEnabled = useSetAtom(enabledOutstandingStudentsAtom);
if (!study) return null;
@@ -36,6 +42,13 @@ const StudyDropDown = ({
studyId: +value.selectedValue,
title: value.selectedText,
});
+ setSelectedStudents({
+ firstStudentName: "",
+ students: new Set(),
+ });
+ setEnabled({
+ enabled: false,
+ });
}}
>
{studyList.map((study: StudyListApiResponseDto) => (
diff --git a/apps/admin/app/students/_contexts/StudyProvider.tsx b/apps/admin/app/students/_contexts/StudyProvider.tsx
index 484a8224..4ac53adb 100644
--- a/apps/admin/app/students/_contexts/StudyProvider.tsx
+++ b/apps/admin/app/students/_contexts/StudyProvider.tsx
@@ -1,7 +1,12 @@
"use client";
+import type {
+ CompleteType,
+ OutstandingType,
+} from "constants/status/outstandigOptions";
import { atom, createStore, Provider } from "jotai";
import type { PropsWithChildren, ReactNode } from "react";
+import type { AchievementType } from "types/entities/achievement";
const studyIdStore = createStore();
@@ -10,8 +15,31 @@ export type StudyAtomprops = {
title: ReactNode;
};
+export type OutstandingStudentsProps = {
+ type?: OutstandingType;
+ achievement?: AchievementType | CompleteType;
+};
+
+export type SetOutstandingStudentsProps = {
+ enabled: boolean;
+};
+
+export type SelectedStudentsProps = {
+ firstStudentName?: string;
+ students: Set;
+};
+
export const studyAtom = atom();
-studyIdStore.set(studyAtom, undefined);
+
+export const outstandingStudentsAtom = atom({});
+
+export const selectedStudentsAtom = atom({
+ students: new Set(),
+});
+
+export const enabledOutstandingStudentsAtom = atom(
+ { enabled: false }
+);
export const StudyProvider = ({ children }: PropsWithChildren) => {
return {children};
diff --git a/apps/admin/app/students/default.tsx b/apps/admin/app/students/default.tsx
new file mode 100644
index 00000000..395785b9
--- /dev/null
+++ b/apps/admin/app/students/default.tsx
@@ -0,0 +1,5 @@
+const Default = () => {
+ return null;
+};
+
+export default Default;
diff --git a/apps/admin/app/students/layout.tsx b/apps/admin/app/students/layout.tsx
index 5b02ec4c..6b0ed40a 100644
--- a/apps/admin/app/students/layout.tsx
+++ b/apps/admin/app/students/layout.tsx
@@ -1,25 +1,29 @@
import { css } from "@styled-system/css";
-import { styled } from "@styled-system/jsx";
import Navbar from "components/Navbar";
import { StudyProvider } from "./_contexts/StudyProvider";
const StudentsLayout = ({
children,
+ modal,
}: Readonly<{
children: React.ReactNode;
+ modal: React.ReactNode;
}>) => {
return (
- {children}
+
+ {children}
+ {modal}
+
);
};
export default StudentsLayout;
-const StudentsLayoutStyle = css({
+const studentsLayoutStyle = css({
display: "flex",
flexDirection: "column",
gap: "sm",
diff --git a/apps/admin/app/students/page.tsx b/apps/admin/app/students/page.tsx
index defba01f..d06a1807 100644
--- a/apps/admin/app/students/page.tsx
+++ b/apps/admin/app/students/page.tsx
@@ -1,65 +1,13 @@
-"use client";
-
import { Flex } from "@styled-system/jsx";
-import { Text } from "@wow-class/ui";
-import { studyApi } from "apis/study/studyApi";
-import useFetchStudents from "hooks/fetch/useFetchStudents";
-import { useAtom } from "jotai";
-import { useEffect, useState } from "react";
-import type { StudyListApiResponseDto } from "types/dtos/studyList";
-import isAdmin from "utils/isAdmin";
-import StudentFilter from "./_components/StudentFilter";
-import StudentPagination from "./_components/StudentPagination";
+import StudentsContent from "./_components/StudentsContent";
import StudentsHeader from "./_components/StudentsHeader";
-import StudentList from "./_components/StudentTable/StudentList";
-import { studyAtom } from "./_contexts/StudyProvider";
const StudentsPage = () => {
- const [studyList, setStudyList] = useState();
- const [selectedStudy, setSelectedStudy] = useAtom(studyAtom);
-
- useEffect(() => {
- const fetchData = async () => {
- const adminStatus = await isAdmin();
- const data = adminStatus
- ? await studyApi.getStudyList()
- : await studyApi.getMyStudyList();
-
- if (data && data.length && data[0]) {
- setStudyList(data);
- setSelectedStudy({ studyId: data[0].studyId, title: data[0].title });
- }
- };
-
- fetchData();
- }, [setSelectedStudy]);
-
- const [page, setPage] = useState(1);
- const handleClickChangePage = (nextPage: number) => {
- setPage(nextPage);
- };
-
- const { studentList, pageInfo } = useFetchStudents(selectedStudy, page);
- if (!selectedStudy) return null;
- if (!studyList) return 담당한 스터디가 없어요.;
-
return (
-
-
- {/* TODO: 페이지네이션 API 필터 추가 후 주석 해제
- {studentList.length ? : null}
- */}
-
-
+
+
);
};
diff --git a/apps/admin/app/students/status/page.tsx b/apps/admin/app/students/status/page.tsx
new file mode 100644
index 00000000..3d5aa234
--- /dev/null
+++ b/apps/admin/app/students/status/page.tsx
@@ -0,0 +1,8 @@
+import { routerPath } from "constants/router/routerPath";
+import { redirect } from "next/navigation";
+
+const StudentStatusPage = () => {
+ return redirect(routerPath.students.href);
+};
+
+export default StudentStatusPage;
diff --git a/apps/admin/app/studies/layout.tsx b/apps/admin/app/studies/layout.tsx
index 7cb5699e..8bba047b 100644
--- a/apps/admin/app/studies/layout.tsx
+++ b/apps/admin/app/studies/layout.tsx
@@ -1,5 +1,4 @@
import { css } from "@styled-system/css";
-import { styled } from "@styled-system/jsx";
import Navbar from "components/Navbar";
const StudiesLayout = ({
@@ -10,14 +9,14 @@ const StudiesLayout = ({
return (
<>
- {children}
+ {children}
>
);
};
export default StudiesLayout;
-const StudiesLayoutStyle = css({
+const studiesLayoutStyle = css({
display: "flex",
flexDirection: "column",
gap: "sm",
diff --git a/apps/admin/constants/router/routerPath.ts b/apps/admin/constants/router/routerPath.ts
index b8415d60..7eb8a83c 100644
--- a/apps/admin/constants/router/routerPath.ts
+++ b/apps/admin/constants/router/routerPath.ts
@@ -39,4 +39,8 @@ export const routerPath = {
href: (studyDetailId: number | string) =>
`/studies/assignments/${studyDetailId}/edit-assignment`,
},
+ students: {
+ description: "스터디 학생 관리 페이지로 이동합니다.",
+ href: "/students",
+ },
};
diff --git a/apps/admin/constants/status/outstandigOptions.ts b/apps/admin/constants/status/outstandigOptions.ts
new file mode 100644
index 00000000..7b22bac2
--- /dev/null
+++ b/apps/admin/constants/status/outstandigOptions.ts
@@ -0,0 +1,36 @@
+import type { AchievementType } from "types/entities/achievement";
+
+export type CompleteType = "COMPLETE";
+
+export type OutstandingDropDownOption = {
+ id: number;
+ text: string;
+ value: AchievementType | CompleteType;
+};
+
+export const OUTSTANDING_ADD_OPTIONS: OutstandingDropDownOption[] = [
+ { id: 1, text: "1차 우수 처리", value: "FIRST_ROUND_OUTSTANDING_STUDENT" },
+ { id: 2, text: "2차 우수 처리", value: "SECOND_ROUND_OUTSTANDING_STUDENT" },
+ { id: 3, text: "수료 처리", value: "COMPLETE" },
+];
+export const OUTSTANDING_DEL_OPTIONS: OutstandingDropDownOption[] = [
+ { id: 1, text: "1차 우수 철회", value: "FIRST_ROUND_OUTSTANDING_STUDENT" },
+ { id: 2, text: "2차 우수 철회", value: "SECOND_ROUND_OUTSTANDING_STUDENT" },
+ { id: 3, text: "수료 철회", value: "COMPLETE" },
+];
+
+export const outstandingRoundMap: Record<
+ AchievementType | CompleteType,
+ "1차 우수" | "2차 우수" | "수료"
+> = {
+ FIRST_ROUND_OUTSTANDING_STUDENT: "1차 우수",
+ SECOND_ROUND_OUTSTANDING_STUDENT: "2차 우수",
+ COMPLETE: "수료",
+};
+
+export type OutstandingType = "처리" | "철회";
+
+export const outstandingTypeMap: Record = {
+ 처리: "회원으로 등록",
+ 철회: "회원에서 철회",
+};
diff --git a/apps/admin/hooks/fetch/useFetchStudents.ts b/apps/admin/hooks/fetch/useFetchStudents.ts
index 43296d8d..5dbb0a6d 100644
--- a/apps/admin/hooks/fetch/useFetchStudents.ts
+++ b/apps/admin/hooks/fetch/useFetchStudents.ts
@@ -1,4 +1,5 @@
import { studyApi } from "apis/study/studyApi";
+import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import type {
PaginatedStudyStudentResponseDto,
@@ -23,6 +24,7 @@ const useFetchStudents = (
PaginatedStudyStudentResponseDto,
"content"
> | null>(null);
+ const pathname = usePathname();
useEffect(() => {
const fetchStudentsData = async () => {
@@ -40,7 +42,7 @@ const useFetchStudents = (
};
fetchStudentsData();
- }, [study, page]);
+ }, [study, page, pathname]);
return { studentList, pageInfo };
};
diff --git a/apps/admin/hooks/fetch/useFetchStudentsExcelUrl.ts b/apps/admin/hooks/fetch/useFetchStudentsExcelUrl.ts
new file mode 100644
index 00000000..b61c44a1
--- /dev/null
+++ b/apps/admin/hooks/fetch/useFetchStudentsExcelUrl.ts
@@ -0,0 +1,23 @@
+import { studyApi } from "apis/study/studyApi";
+import { useEffect, useState } from "react";
+
+const useFetchStudentsExcelUrl = ({ studyId }: { studyId: number }) => {
+ const [url, setUrl] = useState("");
+
+ useEffect(() => {
+ const fetchData = async () => {
+ const response = await studyApi.getStudyStudentsExcel(studyId);
+ const blob = new Blob([response], {
+ type: "application/vnd.ms-excel",
+ });
+ const url = URL.createObjectURL(blob);
+ if (url) setUrl(url);
+ };
+
+ fetchData();
+ }, [studyId]);
+
+ return url;
+};
+
+export default useFetchStudentsExcelUrl;
diff --git a/apps/admin/types/dtos/outstandingStudent.ts b/apps/admin/types/dtos/outstandingStudent.ts
new file mode 100644
index 00000000..17e11527
--- /dev/null
+++ b/apps/admin/types/dtos/outstandingStudent.ts
@@ -0,0 +1,6 @@
+import type { AchievementType } from "../entities/achievement";
+
+export interface OutstandingStudentApiRequestDto {
+ studentIds: number[];
+ achievementType: AchievementType;
+}
diff --git a/apps/admin/types/dtos/studyComplete.ts b/apps/admin/types/dtos/studyComplete.ts
new file mode 100644
index 00000000..cc488078
--- /dev/null
+++ b/apps/admin/types/dtos/studyComplete.ts
@@ -0,0 +1,4 @@
+export interface StudyCompleteRequestDto {
+ studyId: number;
+ studentIds: number[];
+}
diff --git a/apps/admin/types/entities/achievement.ts b/apps/admin/types/entities/achievement.ts
new file mode 100644
index 00000000..d08d93bb
--- /dev/null
+++ b/apps/admin/types/entities/achievement.ts
@@ -0,0 +1,3 @@
+export type AchievementType =
+ | "FIRST_ROUND_OUTSTANDING_STUDENT"
+ | "SECOND_ROUND_OUTSTANDING_STUDENT";
diff --git a/package.json b/package.json
index 15f3fa8c..02559cde 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,6 @@
"clsx": "^2.1.1",
"wowds-icons": "^0.1.4",
"wowds-tokens": "^0.1.1",
- "wowds-ui": "^0.1.19"
+ "wowds-ui": "^0.1.20"
}
}
diff --git a/packages/utils/src/fetcher/index.ts b/packages/utils/src/fetcher/index.ts
index 5c9c8b66..7e1ad6e6 100644
--- a/packages/utils/src/fetcher/index.ts
+++ b/packages/utils/src/fetcher/index.ts
@@ -157,7 +157,7 @@ class Fetcher {
patch(
url: string,
- body: any,
+ body: any = {},
options: RequestInit = {}
): Promise> {
return this.request(url, {
@@ -169,9 +169,14 @@ class Fetcher {
delete(
url: string,
+ body: any = {},
options: RequestInit = {}
): Promise> {
- return this.request(url, { ...options, method: "DELETE" });
+ return this.request(url, {
+ ...options,
+ method: "DELETE",
+ body: JSON.stringify(body),
+ });
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d85ccd67..ee3fdfef 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -18,8 +18,8 @@ importers:
specifier: ^0.1.1
version: 0.1.3
wowds-ui:
- specifier: ^0.1.19
- version: 0.1.19(next@14.2.5)(react-dom@18.3.1)(react@18.3.1)
+ specifier: ^0.1.20
+ version: 0.1.20(next@14.2.5)(react-dom@18.3.1)(react@18.3.1)
devDependencies:
'@pandacss/dev':
specifier: ^0.44.0
@@ -13756,8 +13756,8 @@ packages:
resolution: {integrity: sha512-2mYkhEOZFNXbkH++osn0Qabni08wa0HkbUjnEQVh8j+cni0Te64ZkF9HbynHk2gj1nrFyD6oOSNsIwnJ1IQqmQ==}
dev: false
- /wowds-icons@0.1.5:
- resolution: {integrity: sha512-F2M4+oL2onugKcDH1yizQiVzwMXWA1+L7KI+ryRmCdmclkguVaxQJpboTAH9M2tVs3PrI3OeDV6Q6rCumzkR9w==}
+ /wowds-icons@0.1.6:
+ resolution: {integrity: sha512-/7xLOIvHNTuOfMhSM+JfPF6MaDwS5jC6JN7JXPvo3T52l63Wy63cNskwWrZ1WaD5fHvuG9Il4p9AOB8+zp/ppg==}
dev: false
/wowds-theme@0.1.3:
@@ -13768,8 +13768,8 @@ packages:
resolution: {integrity: sha512-Pej6MuUec/i6A04gWELi4bb/T2I8gf/57L9rX8G7ElVeMc7/03ELoM0nBFWKybtpYExXhhlQPp0Lg7iqATpy6Q==}
dev: false
- /wowds-ui@0.1.19(next@14.2.5)(react-dom@18.3.1)(react@18.3.1):
- resolution: {integrity: sha512-N+AF9zLW9Tdipa53tlvcNcZS4oCelWZZsQ1CT6E1QWkF2Bn4mcj+D+KtkZDXJlK67JNlatMzmLmmOJWUHjUI0A==}
+ /wowds-ui@0.1.20(next@14.2.5)(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-UhdmUrN/fSEt0dpWC3UflSE2qK5Y+n46tR9ZEvfvLOW5J8fwBFhbKhgW867qKGxC851GP/o16TOTU9jMsuzZ8A==}
peerDependencies:
next: ^14.1.1
react: ^18.2.0
@@ -13780,7 +13780,7 @@ packages:
react: 18.3.1
react-day-picker: 9.0.8(react@18.3.1)
uuid: 10.0.0
- wowds-icons: 0.1.5
+ wowds-icons: 0.1.6
transitivePeerDependencies:
- react-dom
dev: false