diff --git a/public/icons/bronze-rank.svg b/public/icons/bronze-rank.svg new file mode 100644 index 00000000..0b6c19c4 --- /dev/null +++ b/public/icons/bronze-rank.svg @@ -0,0 +1,17 @@ + + + + + + +
+
+ + + + + + + + +
diff --git a/public/icons/caret-down.svg b/public/icons/caret-down.svg new file mode 100644 index 00000000..0b2ed4f4 --- /dev/null +++ b/public/icons/caret-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/caret-up.svg b/public/icons/caret-up.svg new file mode 100644 index 00000000..f7bbf3f1 --- /dev/null +++ b/public/icons/caret-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/gold-rank.svg b/public/icons/gold-rank.svg new file mode 100644 index 00000000..41a9200f --- /dev/null +++ b/public/icons/gold-rank.svg @@ -0,0 +1,14 @@ + + + +
+
+ + + + + + + + +
diff --git a/public/icons/seal-check.svg b/public/icons/seal-check.svg index 57512572..6e2d2b45 100644 --- a/public/icons/seal-check.svg +++ b/public/icons/seal-check.svg @@ -1,6 +1,6 @@ - + - + diff --git a/public/icons/silver-rank.svg b/public/icons/silver-rank.svg new file mode 100644 index 00000000..1a061f3e --- /dev/null +++ b/public/icons/silver-rank.svg @@ -0,0 +1,21 @@ + + + +
+
+ + + + + + + + + + + + + + + +
diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index d0623474..40ed0c79 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -202,7 +202,13 @@ export default function MemberListTable() { : '-'} - {user.role.roleId === 'ROLE_MENTOR' && } + {user.role.roleId === 'ROLE_MENTOR' && ( + + )} {ROLE_MAP[user.role.roleId]} diff --git a/src/features/study/group/api/get-group-study-member-list.ts b/src/features/study/group/api/get-group-study-member-list.ts new file mode 100644 index 00000000..54755338 --- /dev/null +++ b/src/features/study/group/api/get-group-study-member-list.ts @@ -0,0 +1,19 @@ +import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { + GroupStudyMembersRequest, + GroupStudyMembersResponse, +} from './group-study-types'; + +// 그룹 스터디 참여자 리스트 조회 API - 페이징 처리 o/x 둘 다 가능 +export const getGroupStudyMemberList = async ({ + id, + pageNumber, + pageSize, +}: GroupStudyMembersRequest): Promise => { + const isPaging = !!(pageNumber && pageSize); + const res = await axiosInstance.get(`/group-studies/${id}/members`, { + params: isPaging ? { pageNumber, pageSize, isPaging } : { isPaging }, + }); + + return res.data; +}; diff --git a/src/features/study/group/api/group-study-types.ts b/src/features/study/group/api/group-study-types.ts index 578f1ede..c4b8fae7 100644 --- a/src/features/study/group/api/group-study-types.ts +++ b/src/features/study/group/api/group-study-types.ts @@ -162,3 +162,48 @@ export interface GroupStudyDetailResponse { detailInfo: DetailInfoDetail; // DetailInfo 확장 interviewPost: InterviewPostDetail; // InterviewPost 확장 } + +// 그룹 스터디 참여자 목록 API 타입 +export interface GroupStudyMembersRequest { + id: number; + pageNumber?: number; + pageSize?: number; +} + +export interface GroupStudyMembersResponse { + pageSize: number; + pageNumber: number; + totalCount: number; + hasPrevious: boolean; + hasNext: boolean; + members: GroupStudyMember[]; +} + +export interface GroupStudyMember { + id: number; + profileImageUrl: string | null; + memberName: string; + progress: MemberProgress; + ranking: number; + greeting: string | null; + lastAccessedAt: string; // ISO datetime string +} + +export interface MemberProgress { + score: number; + progressHistory: ProgressHistoryItem[]; +} + +export interface ProgressHistoryItem { + id: number; + acquiredAt: string; // ISO datetime string + grade: Grade; + reason: string; +} + +export interface Grade { + id: number; + code: 'A+' | 'A-' | 'B+' | 'B-' | 'C+' | 'C-' | 'F'; // e.g. "A+", "C+" + name: string; // e.g. "Great", "Cheer up" + score: 4.5 | 4 | 3.5 | 3 | 2.5 | 2 | 0; +} diff --git a/src/features/study/group/model/use-group-study-member-list-query.ts b/src/features/study/group/model/use-group-study-member-list-query.ts new file mode 100644 index 00000000..34edc268 --- /dev/null +++ b/src/features/study/group/model/use-group-study-member-list-query.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; +import { getGroupStudyMemberList } from '../api/get-group-study-member-list'; +import { GroupStudyMembersRequest } from '../api/group-study-types'; + +export const useGroupStudyMemberListQuery = ({ + id, + pageNumber, + pageSize, +}: GroupStudyMembersRequest) => { + return useQuery({ + queryKey: ['groupStudyMemberList', id, pageNumber, pageSize], + queryFn: () => getGroupStudyMemberList({ id, pageNumber, pageSize }), + enabled: !!id, + }); +}; diff --git a/src/features/study/group/ui/group-study-member-item.tsx b/src/features/study/group/ui/group-study-member-item.tsx new file mode 100644 index 00000000..257e08e7 --- /dev/null +++ b/src/features/study/group/ui/group-study-member-item.tsx @@ -0,0 +1,222 @@ +'use client'; + +import Image from 'next/image'; +import { useState } from 'react'; +import { + formatHHMM, + formatKoreaRelativeTime, + formatYYYYMMDD, +} from '@/shared/lib/time'; +import { getCookie } from '@/shared/tanstack-query/cookie'; +import Button from '@/shared/ui/button'; +import BronzeRankIcon from 'public/icons/bronze-rank.svg'; +import CaretDownIcon from 'public/icons/caret-down.svg'; +import CaretUpIcon from 'public/icons/caret-up.svg'; +import GoldRankIcon from 'public/icons/gold-rank.svg'; +import SealCheckIcon from 'public/icons/seal-check.svg'; +import SilverRankIcon from 'public/icons/silver-rank.svg'; +import { + GroupStudyMember, + ProgressHistoryItem, +} from '../api/group-study-types'; + +export default function GroupStudyMemberItem(member: GroupStudyMember) { + const [isProgressHistoryOpen, setIsProgressHistoryOpen] = + useState(false); + + const isMe = member.id === Number(getCookie('memberId')); + + return ( +
+
+ {/* 사용자 프로필 */} +
+ {`${member.memberName} + + {/* 랭크 아이콘 */} + {[1, 2, 3].includes(member.ranking) && ( +
+ {member.ranking === 1 && } + {member.ranking === 2 && } + {member.ranking === 3 && } +
+ )} +
+ +
+ + {member.memberName} + + + 최근 접속 시간: {formatKoreaRelativeTime(member.lastAccessedAt)} + +
+
+ +
+
+ 가입 인사 + + +
+ +
+ +
+
+
+ + 스터디 진행도 + + + {member.progress.score}% + +
+ + +
+ + + + {isProgressHistoryOpen && ( +
    + {member.progress.progressHistory.map((history) => ( + + ))} +
+ )} +
+
+
+ ); +} + +function ProgressBar({ + value, + className = '', +}: { + value: number; + className?: string; +}) { + const clamped = Math.max(0, Math.min(100, Number(value) || 0)); + + return ( +
+
+ {/* 진행률 표시 영역 */} +
+
+
+ ); +} + +const renderGradeIcon = (code: ProgressHistoryItem['grade']['code']) => { + switch (code) { + case 'A+': + case 'A-': + return ( +
+ +
+ ); + case 'B+': + case 'B-': + return ( +
+ +
+ ); + case 'C+': + case 'C-': + return ( +
+ +
+ ); + default: + return null; + } +}; + +function ProgressScoreItem({ + grade, + reason, + acquiredAt, + id, +}: ProgressHistoryItem) { + return ( +
  • + {renderGradeIcon(grade.code)} + +
    +
    + + {grade.code} : {grade.name} ( + {`${grade.score > 0 ? '+' : ''}${grade.score}`}) + + +
    + + {reason} +
    + + {`${formatYYYYMMDD(acquiredAt, 'dot')} ${formatHHMM(acquiredAt)}`} +
    +
  • + ); +} + +function GreetingBox({ + id, + greeting, +}: Pick) { + // 가입인사를 작성한 경우 + if (greeting) { + return

    {greeting}

    ; + } + + // 가입인사를 작성하지 못한 경우 + const isMe = id === Number(getCookie('memberId')); + + if (isMe) { + return ( +
    + +
    + ); + } + + return ( +
    + 가입 인사를 작성하지 않았습니다. +
    + ); +} diff --git a/src/shared/lib/time.ts b/src/shared/lib/time.ts index 704f878d..e2d8feb3 100644 --- a/src/shared/lib/time.ts +++ b/src/shared/lib/time.ts @@ -20,10 +20,13 @@ export const getKoreaDate = (targetDate?: Date) => { return koreaNow; }; -export const formatYYYYMMDD = (dateString: string) => { +export const formatYYYYMMDD = ( + dateString: string, + separator: 'dash' | 'dot' = 'dash', +) => { const onlyDate = new Date(dateString).toISOString().slice(0, 10); - return onlyDate; + return onlyDate.replace(/-/g, separator === 'dash' ? '-' : '.'); }; export const formatHHMM = (dateString: string) => {