From 056d2973ed85de17b4525946ef04eb60e07fc9a2 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, 8 Sep 2024 18:15:05 +0900 Subject: [PATCH] =?UTF-8?q?[Feature]=20=EC=9E=84=EC=8B=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=94=94=EC=9B=90=20=EA=B4=80=EB=A6=AC=20UI,=20API=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: fetcher에 임시 에러코드 토스트 추가 * feat: 어드민, 클라이언트에 css 및 토스트 컨테이너 추가 * feat: participants -> students url 변경 및 헤더 구현 * chore: 불필요한 예시 모달 삭제 * feat: 스터디리스트 드롭다운 api 연결 * feat: 임시 table UI * feat: 스터디 수강생 명단 api 등록 * feat: 스터디 수강생 api 연결 * fix: thead tbody 추가 * feat: studyAtom값으로 드롭다운 조절 * feat: studyList 상위에서 내려주기 * fix: 변경된 MyStudyList API 수정 * feat: 스터디 수강생이 없을 때 * chore: dto 네이밍 변경, studentList 초기값 변경 * chore: 불필요한 typo prop 삭제, style 객체 분리 * rename: StudyProvider 위치 변경 * chore: 타입 명시 삭제 * chore: PropsWithChildren 타입 사용 * rename: StudyDropDown 폴더 관리 * feat: 타이틀 줄바꿈 방지 및 스타일 분리 * refactor: useFetchStudents 커스텀 훅 분리 * feat: 담당한 스터디가 없을 때 * chore: study -> selectedStudy 네이밍 변경 --- apps/admin/apis/study/studyApi.ts | 11 ++++ .../admin/app/@modal/(.)participants/page.tsx | 23 -------- apps/admin/app/@modal/default.tsx | 5 -- apps/admin/app/layout.tsx | 6 +++ apps/admin/app/participants/page.tsx | 5 -- .../app/students/_components/StudentList.tsx | 50 +++++++++++++++++ .../students/_components/StudentListItem.tsx | 51 ++++++++++++++++++ .../students/_components/StudentsHeader.tsx | 28 ++++++++++ .../StudyDropDown/DropDownTrigger.tsx | 19 +++++++ .../_components/StudyDropDown/index.tsx | 53 +++++++++++++++++++ .../app/students/_contexts/StudyProvider.tsx | 18 +++++++ apps/admin/app/students/layout.tsx | 30 +++++++++++ apps/admin/app/students/page.tsx | 49 +++++++++++++++++ apps/admin/components/Navbar.tsx | 12 ++--- apps/admin/constants/tags.ts | 1 + apps/admin/hooks/fetch/useFetchStudents.ts | 28 ++++++++++ apps/admin/middleware.ts | 2 +- apps/admin/types/dtos/studyStudent.ts | 8 +++ packages/utils/src/fetcher/index.ts | 1 - 19 files changed, 359 insertions(+), 41 deletions(-) delete mode 100644 apps/admin/app/@modal/(.)participants/page.tsx delete mode 100644 apps/admin/app/@modal/default.tsx delete mode 100644 apps/admin/app/participants/page.tsx create mode 100644 apps/admin/app/students/_components/StudentList.tsx create mode 100644 apps/admin/app/students/_components/StudentListItem.tsx create mode 100644 apps/admin/app/students/_components/StudentsHeader.tsx create mode 100644 apps/admin/app/students/_components/StudyDropDown/DropDownTrigger.tsx create mode 100644 apps/admin/app/students/_components/StudyDropDown/index.tsx create mode 100644 apps/admin/app/students/_contexts/StudyProvider.tsx create mode 100644 apps/admin/app/students/layout.tsx create mode 100644 apps/admin/app/students/page.tsx create mode 100644 apps/admin/hooks/fetch/useFetchStudents.ts create mode 100644 apps/admin/types/dtos/studyStudent.ts diff --git a/apps/admin/apis/study/studyApi.ts b/apps/admin/apis/study/studyApi.ts index 7105f882..bbd4d1b8 100644 --- a/apps/admin/apis/study/studyApi.ts +++ b/apps/admin/apis/study/studyApi.ts @@ -9,6 +9,7 @@ import type { import type { AttendanceApiResponseDto } from "types/dtos/attendance"; import type { CurriculumApiResponseDto } from "types/dtos/curriculumList"; import type { StudyBasicInfoApiResponseDto } from "types/dtos/studyBasicInfo"; +import type { StudyStudentApiResponseDto } from "types/dtos/studyStudent"; import type { StudyAnnouncementType } from "types/entities/study"; import type { StudyListApiResponseDto } from "../../types/dtos/studyList"; @@ -148,4 +149,14 @@ export const studyApi = { ); return response.data; }, + getStudyStudents: async (studyId: number) => { + const response = await fetcher.get( + `/mentor/studies/${studyId}/students`, + { + next: { tags: [tags.students] }, + cache: "force-cache", + } + ); + return response.data; + }, }; diff --git a/apps/admin/app/@modal/(.)participants/page.tsx b/apps/admin/app/@modal/(.)participants/page.tsx deleted file mode 100644 index b90748d3..00000000 --- a/apps/admin/app/@modal/(.)participants/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; - -import { Flex } from "@styled-system/jsx"; -import { Modal } from "@wow-class/ui"; -import { useModalRoute } from "@wow-class/ui/hooks"; -import Button from "wowds-ui/Button"; - -const TestModal = () => { - const { onClose } = useModalRoute(); - - return ( - - - - - - - ); -}; - -export default TestModal; diff --git a/apps/admin/app/@modal/default.tsx b/apps/admin/app/@modal/default.tsx deleted file mode 100644 index 395785b9..00000000 --- a/apps/admin/app/@modal/default.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const Default = () => { - return null; -}; - -export default Default; diff --git a/apps/admin/app/layout.tsx b/apps/admin/app/layout.tsx index 7b198a7b..958856e0 100644 --- a/apps/admin/app/layout.tsx +++ b/apps/admin/app/layout.tsx @@ -56,6 +56,12 @@ const RootLayout = ({ limit={1} /> + {children} {modal} diff --git a/apps/admin/app/participants/page.tsx b/apps/admin/app/participants/page.tsx deleted file mode 100644 index 19a3c50b..00000000 --- a/apps/admin/app/participants/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const Participants = () => { - return
Participants
; -}; - -export default Participants; diff --git a/apps/admin/app/students/_components/StudentList.tsx b/apps/admin/app/students/_components/StudentList.tsx new file mode 100644 index 00000000..652ef925 --- /dev/null +++ b/apps/admin/app/students/_components/StudentList.tsx @@ -0,0 +1,50 @@ +import { css } from "@styled-system/css"; +import { styled } from "@styled-system/jsx"; +import { Text } from "@wow-class/ui"; +import type { StudyStudentApiResponseDto } from "types/dtos/studyStudent"; + +import StudentListItem from "./StudentListItem"; + +const StudentList = ({ + studentList, +}: { + studentList: StudyStudentApiResponseDto[] | []; +}) => { + if (!studentList.length) return 스터디 수강생이 없어요.; + + return ( + + + + + 이름 + + + 학번 + + + 디스코드 사용자명 + + + 디스코드 닉네임 + + + 깃허브 링크 + + + + + {studentList.map((student) => ( + + ))} + + + ); +}; + +const tableThStyle = css({ + padding: "1rem", + textAlign: "left", +}); + +export default StudentList; diff --git a/apps/admin/app/students/_components/StudentListItem.tsx b/apps/admin/app/students/_components/StudentListItem.tsx new file mode 100644 index 00000000..d4f00256 --- /dev/null +++ b/apps/admin/app/students/_components/StudentListItem.tsx @@ -0,0 +1,51 @@ +import { css } from "@styled-system/css"; +import { styled } from "@styled-system/jsx"; +import { Text } from "@wow-class/ui"; +import Link from "next/link"; +import type { CSSProperties } from "react"; +import type { StudyStudentApiResponseDto } from "types/dtos/studyStudent"; +import TextButton from "wowds-ui/TextButton"; + +const StudentListItem = ({ + name, + studentId, + discordUsername, + nickname, + githubLink, +}: StudyStudentApiResponseDto) => { + return ( + + + {name} + + + {studentId} + + + {discordUsername} + + + {nickname} + + + + + + ); +}; + +const tableThStyle = css({ + padding: "1rem", +}); + +const textButtonStyle: CSSProperties = { + width: "fit-content", + padding: 0, +}; + +export default StudentListItem; diff --git a/apps/admin/app/students/_components/StudentsHeader.tsx b/apps/admin/app/students/_components/StudentsHeader.tsx new file mode 100644 index 00000000..3ab763dd --- /dev/null +++ b/apps/admin/app/students/_components/StudentsHeader.tsx @@ -0,0 +1,28 @@ +import { Text } from "@wow-class/ui"; +import ItemSeparator from "components/ItemSeparator"; +import type { CSSProperties } from "react"; +import type { StudyListApiResponseDto } from "types/dtos/studyList"; + +import StudyDropDown from "./StudyDropDown"; + +const StudentsHeader = ({ + studyList, +}: { + studyList: StudyListApiResponseDto[]; +}) => { + return ( + + 수강생 관리 + + + ); +}; + +const titleStyle: CSSProperties = { + display: "flex", + alignItems: "center", + gap: "0.75rem", + whiteSpace: "nowrap", +}; + +export default StudentsHeader; diff --git a/apps/admin/app/students/_components/StudyDropDown/DropDownTrigger.tsx b/apps/admin/app/students/_components/StudyDropDown/DropDownTrigger.tsx new file mode 100644 index 00000000..89b8013b --- /dev/null +++ b/apps/admin/app/students/_components/StudyDropDown/DropDownTrigger.tsx @@ -0,0 +1,19 @@ +import { Flex } from "@styled-system/jsx"; +import { DownArrow } from "wowds-icons"; + +const DropDownTrigger = () => { + return ( + + + + ); +}; + +export default DropDownTrigger; diff --git a/apps/admin/app/students/_components/StudyDropDown/index.tsx b/apps/admin/app/students/_components/StudyDropDown/index.tsx new file mode 100644 index 00000000..d19a55a9 --- /dev/null +++ b/apps/admin/app/students/_components/StudyDropDown/index.tsx @@ -0,0 +1,53 @@ +import { Flex } from "@styled-system/jsx"; +import { useAtom } 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 DropDownTrigger from "./DropDownTrigger"; + +const StudyDropDown = ({ + studyList, +}: { + studyList: StudyListApiResponseDto[]; +}) => { + const [study, setStudy] = useAtom(studyAtom); + + if (!study) return null; + + return ( + + {study.title} + + + } + onChange={(value: { selectedValue: string; selectedText: ReactNode }) => { + setStudy({ + studyId: +value.selectedValue, + title: value.selectedText, + }); + }} + > + {studyList.map((study: StudyListApiResponseDto) => ( + + ))} + + ); +}; + +export default StudyDropDown; diff --git a/apps/admin/app/students/_contexts/StudyProvider.tsx b/apps/admin/app/students/_contexts/StudyProvider.tsx new file mode 100644 index 00000000..484a8224 --- /dev/null +++ b/apps/admin/app/students/_contexts/StudyProvider.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { atom, createStore, Provider } from "jotai"; +import type { PropsWithChildren, ReactNode } from "react"; + +const studyIdStore = createStore(); + +export type StudyAtomprops = { + studyId: number; + title: ReactNode; +}; + +export const studyAtom = atom(); +studyIdStore.set(studyAtom, undefined); + +export const StudyProvider = ({ children }: PropsWithChildren) => { + return {children}; +}; diff --git a/apps/admin/app/students/layout.tsx b/apps/admin/app/students/layout.tsx new file mode 100644 index 00000000..5b02ec4c --- /dev/null +++ b/apps/admin/app/students/layout.tsx @@ -0,0 +1,30 @@ +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, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return ( + + + {children} + + ); +}; + +export default StudentsLayout; + +const StudentsLayoutStyle = css({ + display: "flex", + flexDirection: "column", + gap: "sm", + height: "100vh", + overflow: "scroll", + width: "100%", + padding: "54px 101px", +}); diff --git a/apps/admin/app/students/page.tsx b/apps/admin/app/students/page.tsx new file mode 100644 index 00000000..7d6c5cbc --- /dev/null +++ b/apps/admin/app/students/page.tsx @@ -0,0 +1,49 @@ +"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 StudentList from "./_components/StudentList"; +import StudentsHeader from "./_components/StudentsHeader"; +import { studyAtom } from "./_contexts/StudyProvider"; + +const StudentsPage = () => { + const [studyList, setStudyList] = useState(); + const [selectedStudy, setSelectedStudy] = useAtom(studyAtom); + + useEffect(() => { + const fetchData = async () => { + const adminStatus = await isAdmin(); + if (adminStatus) { + 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 student = useFetchStudents(selectedStudy); + if (!studyList) return 담당한 스터디가 없어요.; + + return ( + + + + + ); +}; + +export default StudentsPage; diff --git a/apps/admin/components/Navbar.tsx b/apps/admin/components/Navbar.tsx index c69807d2..25c77cec 100644 --- a/apps/admin/components/Navbar.tsx +++ b/apps/admin/components/Navbar.tsx @@ -37,12 +37,12 @@ const Navbar = async () => { }; }), }, - // { - // href: "participants", - // imageUrl: participantImageUrl, - // alt: "participant-icon", - // name: "수강생 관리", - // }, + { + href: "/students", + imageUrl: participantImageUrl, + alt: "participant-icon", + name: "수강생 관리", + }, ]; return ( diff --git a/apps/admin/constants/tags.ts b/apps/admin/constants/tags.ts index 268bf0de..73d8eb94 100644 --- a/apps/admin/constants/tags.ts +++ b/apps/admin/constants/tags.ts @@ -8,4 +8,5 @@ export const enum tags { announcements = "announcements", memberList = "memberList", attendances = "attendances", + students = "students", } diff --git a/apps/admin/hooks/fetch/useFetchStudents.ts b/apps/admin/hooks/fetch/useFetchStudents.ts new file mode 100644 index 00000000..c35eecdb --- /dev/null +++ b/apps/admin/hooks/fetch/useFetchStudents.ts @@ -0,0 +1,28 @@ +import { studyApi } from "apis/study/studyApi"; +import { useEffect, useState } from "react"; +import type { StudyStudentApiResponseDto } from "types/dtos/studyStudent"; + +import type { StudyAtomprops } from "@/students/_contexts/StudyProvider"; + +const useFetchStudents = ( + study: StudyAtomprops | undefined +): { studentList: StudyStudentApiResponseDto[] | [] } => { + const [studentList, setStudentList] = useState< + StudyStudentApiResponseDto[] | [] + >([]); + + useEffect(() => { + const fetchStudentsData = async () => { + if (study) { + const studentsData = await studyApi.getStudyStudents(study.studyId); + if (studentsData) setStudentList(studentsData); + } + }; + + fetchStudentsData(); + }, [study]); + + return { studentList }; +}; + +export default useFetchStudents; diff --git a/apps/admin/middleware.ts b/apps/admin/middleware.ts index f32cfe32..ba9b1a28 100644 --- a/apps/admin/middleware.ts +++ b/apps/admin/middleware.ts @@ -5,7 +5,7 @@ import { cookies } from "next/headers"; import { NextResponse } from "next/server"; import setExpireTime from "utils/setExpireTime"; export const config = { - matcher: ["/studies/:path*", "/participants/:path*"], + matcher: ["/studies/:path*", "/students/:path*"], }; const middleware = async () => { diff --git a/apps/admin/types/dtos/studyStudent.ts b/apps/admin/types/dtos/studyStudent.ts new file mode 100644 index 00000000..f9867939 --- /dev/null +++ b/apps/admin/types/dtos/studyStudent.ts @@ -0,0 +1,8 @@ +export interface StudyStudentApiResponseDto { + memberId: number; + name: string; + studentId: string; + discordUsername: string; + nickname: string; + githubLink: string; +} diff --git a/packages/utils/src/fetcher/index.ts b/packages/utils/src/fetcher/index.ts index 00e69839..451775a3 100644 --- a/packages/utils/src/fetcher/index.ts +++ b/packages/utils/src/fetcher/index.ts @@ -106,7 +106,6 @@ class Fetcher { let response: ApiResponse = await fetch(fullUrl, fetchOptions); const data = await this.parseJsonResponse(response); - await this.handleError(response, data); response = await this.interceptResponse(response);