From 45c13e9fc418905f4bc7df0206579137595630c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=98=84=EC=98=81?= <89445100+hamo-o@users.noreply.github.com> Date: Sun, 22 Dec 2024 20:40:53 +0900 Subject: [PATCH] =?UTF-8?q?[Feature]=20=EC=9A=B0=EC=88=98=ED=9A=8C?= =?UTF-8?q?=EC=9B=90/=EC=88=98=EB=A3=8C=ED=9A=8C=EC=9B=90=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EC=B2=A0=ED=9A=8C=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#164)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 우수 회원 수료 처리 및 철회 드롭다운 버튼 UI * refactor: 엑셀 다운로드 fetch 로직 hook으로 분리 * refactor: 헤더 버튼 묶음 컴포넌트 분리 * refactor: 우수회원 옵션 상수 분리 * feat: 1차/2차 우수회원 처리/철회 버튼 상태관리 * feat: 선택된 학생 목록 상태관리 * refactor: 불필요한 분기처리 삭제 * feat: AchievementType, 우수회원 API DTO 등록 * feat: fetch delete 메서드에도 body 추가 * feat: 우수회원 처리, 철회 API 등록 * feat: 우수회원 처리, 철회 API 버튼 클릭이벤트 연결 * fix: 잘못된 AchievementType import * fix: delete 메서드 body 기본값 설정 * feat: 우수회원 처리/철회 모달 임시구현 * fix: 처리, 철회 type으로 사용 및 outstandingRoundMap으로 1차, 2차 워딩 관리 * feat: 우수 처리, 철회 로직 모달로 이동 * feat: 첫 번째 선택된 학생 이름 렌더링 * chore: CODEOWNER 수정 * fix: delete 메서드 body 필수 해제 * fix: 불필요한 코드 제거, 코드 순서 변경 * refactor: 수강생 목록 헤더, 컨텐츠 컴포넌트 분리 * fix: 스터디 변경 시 선택 학생 초기화 * fix: 페이지네이션 시에도 첫번째 학생 이름 유지하도록 * fix: pathname의 변화에 따라 fetch * feat: 모달 닫을 때 선택된 학생 정보 초기화 * refactor: Modal Header, Footer 분리 * fix: 변경된 타입명 적용 * feat: 드롭다운 수료 타입 추가 * feat: 수강생 수료 처리, 철회 API * chore: TODO 주석 추가 * feat: 수료 처리, 철회 API 연결 * chore: TODO 주석 추가 * refactor: 우수, 수료에 따른 분기처리 * fix: 선택한 학생 수가 1명일 때 나머지 학생 수 렌더링하지 않음 * fix: 코드오너 추가 * fix: OutstandingModalFooter -> OutstandingModalButton 네이밍 변경 * feat: 취소하기 버튼 클릭 시 selectedStudentsAtom 초기화 * fix: fragment 대신 span 태그로 변경 * chore: wowds-ui 버전업 * fix: 선택된 학생들 배열 대신 Set으로 관리, 디자인 시스템의 onChange 대신 직접 구현한 핸들러 사용 * fix: 체크박스 직접 관리, 선택된 학생 리스트 API 요청 시 배열로 변환 * fix: selectedRowsProp Set이 아닌 Array로 넘겨주기 * fix: Table.Tr 대신 styled.tr 사용, 개별 체크박스 직접 구현 * fix: 허용되지 않은 value prop 삭제 * feat: 우수 및 수료 모달 버튼 분리, 공통 로직 훅 분리, 모달 네이밍 변경 * feat: 중복처리 체크박스 막기 * fix: 불필요한 styled 태그 삭제, 클래스명 컨벤션 통일 --- .github/CODEOWNERS | 2 +- apps/admin/apis/study/studyAchievementApi.ts | 28 ++++ apps/admin/apis/study/studyCompleteApi.ts | 19 +++ apps/admin/app/layout.tsx | 6 - .../app/students/@modal/(.)status/page.tsx | 7 + .../_components/CompleteModalButton.tsx | 55 ++++++++ .../_components/OutstandingModalButton.tsx | 56 ++++++++ .../@modal/_components/StudentStatusModal.tsx | 50 +++++++ .../_components/StudentStatusModalHeader.tsx | 38 ++++++ .../_hooks/useCloseStudentStatusModal.ts | 51 +++++++ apps/admin/app/students/@modal/default.tsx | 5 + .../OutstandingDropDown/DropDownTrigger.tsx | 12 ++ .../_components/OutstandingDropDown/index.tsx | 64 +++++++++ .../_components/StudentTable/StudentList.tsx | 129 ++++++++++++++++-- .../students/_components/StudentsContent.tsx | 37 +++++ .../students/_components/StudentsHeader.tsx | 63 --------- .../StudentsHeader/DownloadButton.tsx | 16 +++ .../StudentsHeader/StudentHeaderButtons.tsx | 47 +++++++ .../_components/StudentsHeader/index.tsx | 63 +++++++++ .../_components/StudyDropDown/index.tsx | 17 ++- .../app/students/_contexts/StudyProvider.tsx | 30 +++- apps/admin/app/students/default.tsx | 5 + apps/admin/app/students/layout.tsx | 10 +- apps/admin/app/students/page.tsx | 58 +------- apps/admin/app/students/status/page.tsx | 8 ++ apps/admin/app/studies/layout.tsx | 5 +- apps/admin/constants/router/routerPath.ts | 4 + .../constants/status/outstandigOptions.ts | 36 +++++ apps/admin/hooks/fetch/useFetchStudents.ts | 4 +- .../hooks/fetch/useFetchStudentsExcelUrl.ts | 23 ++++ apps/admin/types/dtos/outstandingStudent.ts | 6 + apps/admin/types/dtos/studyComplete.ts | 4 + apps/admin/types/entities/achievement.ts | 3 + package.json | 2 +- packages/utils/src/fetcher/index.ts | 9 +- pnpm-lock.yaml | 14 +- 36 files changed, 829 insertions(+), 157 deletions(-) create mode 100644 apps/admin/apis/study/studyAchievementApi.ts create mode 100644 apps/admin/apis/study/studyCompleteApi.ts create mode 100644 apps/admin/app/students/@modal/(.)status/page.tsx create mode 100644 apps/admin/app/students/@modal/_components/CompleteModalButton.tsx create mode 100644 apps/admin/app/students/@modal/_components/OutstandingModalButton.tsx create mode 100644 apps/admin/app/students/@modal/_components/StudentStatusModal.tsx create mode 100644 apps/admin/app/students/@modal/_components/StudentStatusModalHeader.tsx create mode 100644 apps/admin/app/students/@modal/_hooks/useCloseStudentStatusModal.ts create mode 100644 apps/admin/app/students/@modal/default.tsx create mode 100644 apps/admin/app/students/_components/OutstandingDropDown/DropDownTrigger.tsx create mode 100644 apps/admin/app/students/_components/OutstandingDropDown/index.tsx create mode 100644 apps/admin/app/students/_components/StudentsContent.tsx delete mode 100644 apps/admin/app/students/_components/StudentsHeader.tsx create mode 100644 apps/admin/app/students/_components/StudentsHeader/DownloadButton.tsx create mode 100644 apps/admin/app/students/_components/StudentsHeader/StudentHeaderButtons.tsx create mode 100644 apps/admin/app/students/_components/StudentsHeader/index.tsx create mode 100644 apps/admin/app/students/default.tsx create mode 100644 apps/admin/app/students/status/page.tsx create mode 100644 apps/admin/constants/status/outstandigOptions.ts create mode 100644 apps/admin/hooks/fetch/useFetchStudentsExcelUrl.ts create mode 100644 apps/admin/types/dtos/outstandingStudent.ts create mode 100644 apps/admin/types/dtos/studyComplete.ts create mode 100644 apps/admin/types/entities/achievement.ts 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