From 409221a3252dd331b1f51d6dfc7ec184587631fa Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:50:59 +0900 Subject: [PATCH 01/16] =?UTF-8?q?fix=20:=20=EA=B8=80=EC=9E=90=20=EC=88=98?= =?UTF-8?q?=20=EC=A0=9C=ED=95=9C=20500=EC=9E=90=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/study/group/ui/group-notice-modal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/study/group/ui/group-notice-modal.tsx b/src/features/study/group/ui/group-notice-modal.tsx index 0597f93e..fab3a3f7 100644 --- a/src/features/study/group/ui/group-notice-modal.tsx +++ b/src/features/study/group/ui/group-notice-modal.tsx @@ -132,11 +132,11 @@ function GroupStudyNoticeForm({ label="스터디 공지, 규칙 등을 안내해주세요." direction="vertical" showCounterRight - counterMax={100} + counterMax={500} > From f7e1d23d5dea60c8305b94e13f83f920d8c2d85d Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:51:18 +0900 Subject: [PATCH 02/16] =?UTF-8?q?fix=20:=20=EC=9B=8C=EB=94=A9=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/summary/study-info-summary.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/summary/study-info-summary.tsx b/src/components/summary/study-info-summary.tsx index af5fcb08..de06ff8d 100644 --- a/src/components/summary/study-info-summary.tsx +++ b/src/components/summary/study-info-summary.tsx @@ -102,7 +102,7 @@ export default function SummaryStudyInfo({ data }: Props) { value: STUDY_STATUS_LABELS[groupStudyStatus], }, { - label: '현직자 참여 여부', + label: '스터디 대상', value: experienceLevels .map((level) => EXPERIENCE_LEVEL_LABELS[level]) From 388b2200554ad1cfefe43fb7d2dea42fa2ca7ca8 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:18:43 +0900 Subject: [PATCH 03/16] =?UTF-8?q?fix=20:=20label=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/filtering/study-filter.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/components/filtering/study-filter.tsx b/src/components/filtering/study-filter.tsx index ab07ee47..7777f944 100644 --- a/src/components/filtering/study-filter.tsx +++ b/src/components/filtering/study-filter.tsx @@ -75,19 +75,26 @@ function FilterDropdown({ const hasSelection = selected.length > 0; + const selectedLabels = selected + .map((v) => options.find((option) => option.value === v)?.label) + .filter(Boolean) + .join(', '); + + const displayLabel = hasSelection ? `${label}: ${selectedLabels}` : label; + return ( + + {showOverflow && ( +
+
+ + 참가자 목록 + + +
+
    + {overflow.map((member) => ( +
  • + + + + {member.nickname || '익명'} + + + } + /> +
  • + ))} +
+
+ )} + + )} + + + {/* 안내 문구 */} +

+ {members.length ? guideText : '아직 스터디원이 없습니다.'} +

+ + ); +} + +function AvatarItem({ + member, + index, +}: { + member: AvatarStackMember; + index: number; +}) { + const [hovered, setHovered] = useState(false); + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {/* 왕관 아이콘 (리더) */} + {member.isLeader && ( + + 👑 + + )} + +
+ +
+ + {/* 닉네임 툴팁 */} + {hovered && ( + + {member.nickname || '익명'} + + } + /> + )} + + } + /> + ); +} From a16aee3bb2e84b10eb73912c6cad56e2760d1cfb Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:09:46 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat=20:=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20=EC=8B=9C=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=83=81=ED=83=9C=20=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/group-study-list-page.tsx | 151 ++---------------- .../pages/premium-study-list-page.tsx | 148 ++--------------- .../premium/premium-study-pagination.tsx | 14 +- .../study/group/ui/group-study-pagination.tsx | 14 +- src/hooks/common/use-study-list-filter.ts | 99 ++++++++++++ 5 files changed, 133 insertions(+), 293 deletions(-) create mode 100644 src/hooks/common/use-study-list-filter.ts diff --git a/src/components/pages/group-study-list-page.tsx b/src/components/pages/group-study-list-page.tsx index 98bee3c3..8c42c93e 100644 --- a/src/components/pages/group-study-list-page.tsx +++ b/src/components/pages/group-study-list-page.tsx @@ -2,21 +2,12 @@ import { Plus } from 'lucide-react'; import dynamic from 'next/dynamic'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { useCallback, useMemo, useState } from 'react'; -import type { - GetGroupStudiesTypeEnum, - GetGroupStudiesTargetRolesEnum, - GetGroupStudiesMethodEnum, -} from '@/api/openapi/api/group-study-management-api'; -import StudyFilter, { - StudyFilterValues, -} from '@/components/filtering/study-filter'; +import StudyFilter from '@/components/filtering/study-filter'; import StudySearch from '@/components/filtering/study-search'; import PageContainer from '@/components/layout/page-container'; import Button from '@/components/ui/button'; import { useAuthReady } from '@/hooks/common/use-auth'; -import { useGetStudies } from '@/hooks/queries/study-query'; +import { useStudyListFilter } from '@/hooks/common/use-study-list-filter'; import GroupStudyFormModal from '../../features/study/group/ui/group-study-form-modal'; import GroupStudyPagination from '../../features/study/group/ui/group-study-pagination'; import GroupStudyList from '../lists/group-study-list'; @@ -27,138 +18,23 @@ const Banner = dynamic(() => import('@/widgets/home/banner'), { ssr: false, }); -const PAGE_SIZE = 15; - export default function GroupStudyListPage() { const { isAuthReady } = useAuthReady(); - const router = useRouter(); - const searchParams = useSearchParams(); - - // 로컬 검색 상태 - const [searchQuery, setSearchQuery] = useState(''); - - // URL에서 필터 값 읽기 - // 기본값: recruiting = true (모집 중만 보기) - // 사용자가 토글을 조작하면 URL에 명시적으로 저장됨 - const filterValues = useMemo(() => { - const type = searchParams.get('type')?.split(',').filter(Boolean) ?? []; - const targetRoles = - searchParams.get('targetRoles')?.split(',').filter(Boolean) ?? []; - const method = searchParams.get('method')?.split(',').filter(Boolean) ?? []; - // URL에 recruiting 파라미터가 없으면 기본값 true (모집 중만 보기) - // 파라미터가 있으면 그 값 사용 (명시적 사용자 선택) - const recruitingParam = searchParams.get('recruiting'); - const recruiting = - recruitingParam === null ? true : recruitingParam === 'true'; - - return { type, targetRoles, method, recruiting }; - }, [searchParams]); - - const currentPage = Number(searchParams.get('page')) || 1; - - // 검색어가 있으면 전체 데이터를 가져오고, 없으면 페이지네이션된 데이터를 가져옴 - const { data, isLoading } = useGetStudies({ + const { + searchQuery, + filterValues, + currentPage, + totalPages, + displayStudies, + isLoading, + handleFilterChange, + handlePageChange, + handleSearch, + } = useStudyListFilter({ classification: 'GROUP_STUDY', - page: searchQuery ? 1 : currentPage, - pageSize: searchQuery ? 10000 : PAGE_SIZE, - type: - filterValues.type.length > 0 - ? (filterValues.type as GetGroupStudiesTypeEnum[]) - : undefined, - targetRoles: - filterValues.targetRoles.length > 0 - ? (filterValues.targetRoles as GetGroupStudiesTargetRolesEnum[]) - : undefined, - method: - filterValues.method.length > 0 - ? (filterValues.method as GetGroupStudiesMethodEnum[]) - : undefined, - // 기본값: true (모집 중만), false면 전체 조회 - recruiting: filterValues.recruiting ? true : undefined, }); - const allStudies = useMemo(() => data?.content ?? [], [data?.content]); - - // URL 파라미터 업데이트 함수 - const updateSearchParams = useCallback( - (updates: Record) => { - const params = new URLSearchParams(searchParams.toString()); - - Object.entries(updates).forEach(([key, value]) => { - if (value === undefined || value === '') { - params.delete(key); - } else { - // recruiting의 경우 'false'도 명시적으로 저장 (기본값이 true이므로) - // 다른 필터는 'false'일 때 파라미터 제거 - if (key === 'recruiting' || value !== 'false') { - params.set(key, value); - } else { - params.delete(key); - } - } - }); - - // 필터나 검색이 변경되면 페이지를 1로 리셋 - if (!updates.page) { - params.delete('page'); - } - - const queryString = params.toString(); - router.push(queryString ? `?${queryString}` : '/group-study'); - }, - [router, searchParams], - ); - - // 필터 변경 핸들러 - // recruiting 토글: true면 'true' 저장, false면 'false' 명시적으로 저장 - // (기본값이 true이므로 false일 때도 명시적으로 저장해야 토글이 정상 작동) - const handleFilterChange = useCallback( - (values: StudyFilterValues) => { - updateSearchParams({ - type: values.type.length > 0 ? values.type.join(',') : undefined, - targetRoles: - values.targetRoles.length > 0 - ? values.targetRoles.join(',') - : undefined, - method: values.method.length > 0 ? values.method.join(',') : undefined, - // recruiting: true면 'true', false면 'false' 명시적으로 저장 - // (기본값이 true이므로 false일 때도 URL에 저장해야 토글 해제 가능) - recruiting: values.recruiting ? 'true' : 'false', - }); - }, - [updateSearchParams], - ); - - // 검색 핸들러 (로컬 상태만 업데이트) - const handleSearch = useCallback((query: string) => { - setSearchQuery(query); - }, []); - - // 클라이언트 사이드 검색 필터링 (스터디명만 검색) - const filteredStudies = useMemo(() => { - if (!searchQuery) return allStudies; - - const lowerQuery = searchQuery.toLowerCase(); - - return allStudies.filter((study) => - study.simpleDetailInfo?.title?.toLowerCase().includes(lowerQuery), - ); - }, [allStudies, searchQuery]); - - // 검색어가 있을 때는 클라이언트에서 페이지네이션, 없으면 서버 페이지네이션 사용 - const totalPages = searchQuery - ? Math.ceil(filteredStudies.length / PAGE_SIZE) || 1 - : (data?.totalPages ?? 1); - - const displayStudies = useMemo(() => { - if (!searchQuery) return filteredStudies; - - const startIndex = (currentPage - 1) * PAGE_SIZE; - - return filteredStudies.slice(startIndex, startIndex + PAGE_SIZE); - }, [filteredStudies, searchQuery, currentPage]); - if (isLoading) { return ( @@ -215,6 +91,7 @@ export default function GroupStudyListPage() { )} diff --git a/src/components/pages/premium-study-list-page.tsx b/src/components/pages/premium-study-list-page.tsx index a6a90312..0b10eff0 100644 --- a/src/components/pages/premium-study-list-page.tsx +++ b/src/components/pages/premium-study-list-page.tsx @@ -2,16 +2,7 @@ import { Plus } from 'lucide-react'; import dynamic from 'next/dynamic'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { useCallback, useMemo, useState } from 'react'; -import type { - GetGroupStudiesTypeEnum, - GetGroupStudiesTargetRolesEnum, - GetGroupStudiesMethodEnum, -} from '@/api/openapi/api/group-study-management-api'; -import StudyFilter, { - StudyFilterValues, -} from '@/components/filtering/study-filter'; +import StudyFilter from '@/components/filtering/study-filter'; import StudySearch from '@/components/filtering/study-search'; import PageContainer from '@/components/layout/page-container'; import PremiumStudyList from '@/components/premium/premium-study-list'; @@ -19,7 +10,7 @@ import PremiumStudyPagination from '@/components/premium/premium-study-paginatio import Button from '@/components/ui/button'; import GroupStudyFormModal from '@/features/study/group/ui/group-study-form-modal'; import { useAuthReady } from '@/hooks/common/use-auth'; -import { useGetStudies } from '@/hooks/queries/study-query'; +import { useStudyListFilter } from '@/hooks/common/use-study-list-filter'; import MyParticipatingStudiesSection from '../section/my-participating-studies-section'; // Carousel이 클라이언트 전용이므로 dynamic import로 로드 @@ -27,135 +18,23 @@ const Banner = dynamic(() => import('@/widgets/home/banner'), { ssr: false, }); -const PAGE_SIZE = 15; - export default function PremiumStudyListPage() { const { isAuthReady } = useAuthReady(); - const router = useRouter(); - const searchParams = useSearchParams(); - - // 로컬 검색 상태 - const [searchQuery, setSearchQuery] = useState(''); - - // URL에서 필터 값 읽기 (기본값: recruiting = true, 모집 중만 보기) - const filterValues = useMemo(() => { - const type = searchParams.get('type')?.split(',').filter(Boolean) ?? []; - const targetRoles = - searchParams.get('targetRoles')?.split(',').filter(Boolean) ?? []; - const method = searchParams.get('method')?.split(',').filter(Boolean) ?? []; - // URL에 recruiting 파라미터가 없으면 기본값 true (모집 중만 보기) - const recruitingParam = searchParams.get('recruiting'); - const recruiting = - recruitingParam === null ? true : recruitingParam === 'true'; - - return { type, targetRoles, method, recruiting }; - }, [searchParams]); - - const currentPage = Number(searchParams.get('page')) || 1; - - // 검색어가 있으면 전체 데이터를 가져오고, 없으면 페이지네이션된 데이터를 가져옴 - const { data, isLoading } = useGetStudies({ + const { + searchQuery, + filterValues, + currentPage, + totalPages, + displayStudies, + isLoading, + handleFilterChange, + handlePageChange, + handleSearch, + } = useStudyListFilter({ classification: 'PREMIUM_STUDY', - page: searchQuery ? 1 : currentPage, - pageSize: searchQuery ? 10000 : PAGE_SIZE, - type: - filterValues.type.length > 0 - ? (filterValues.type as GetGroupStudiesTypeEnum[]) - : undefined, - targetRoles: - filterValues.targetRoles.length > 0 - ? (filterValues.targetRoles as GetGroupStudiesTargetRolesEnum[]) - : undefined, - method: - filterValues.method.length > 0 - ? (filterValues.method as GetGroupStudiesMethodEnum[]) - : undefined, - // 기본값: true (모집 중만), false면 전체 조회 - recruiting: filterValues.recruiting ? true : undefined, }); - const allStudies = useMemo(() => data?.content ?? [], [data?.content]); - - // URL 파라미터 업데이트 함수 - const updateSearchParams = useCallback( - (updates: Record) => { - const params = new URLSearchParams(searchParams.toString()); - - Object.entries(updates).forEach(([key, value]) => { - if (value === undefined || value === '') { - params.delete(key); - } else { - // recruiting의 경우 'false'도 명시적으로 저장 (기본값이 true이므로) - // 다른 필터는 'false'일 때 파라미터 제거 - if (key === 'recruiting' || value !== 'false') { - params.set(key, value); - } else { - params.delete(key); - } - } - }); - - // 필터나 검색이 변경되면 페이지를 1로 리셋 - if (!updates.page) { - params.delete('page'); - } - - const queryString = params.toString(); - router.push(queryString ? `?${queryString}` : '/premium-study'); - }, - [router, searchParams], - ); - - // 필터 변경 핸들러 - // recruiting 토글: true면 'true' 저장, false면 'false' 명시적으로 저장 - // (기본값이 true이므로 false일 때도 명시적으로 저장해야 토글이 정상 작동) - const handleFilterChange = useCallback( - (values: StudyFilterValues) => { - updateSearchParams({ - type: values.type.length > 0 ? values.type.join(',') : undefined, - targetRoles: - values.targetRoles.length > 0 - ? values.targetRoles.join(',') - : undefined, - method: values.method.length > 0 ? values.method.join(',') : undefined, - // recruiting: true면 'true', false면 'false' 명시적으로 저장 - // (기본값이 true이므로 false일 때도 URL에 저장해야 토글 해제 가능) - recruiting: values.recruiting ? 'true' : 'false', - }); - }, - [updateSearchParams], - ); - - // 검색 핸들러 (로컬 상태만 업데이트) - const handleSearch = useCallback((query: string) => { - setSearchQuery(query); - }, []); - - // 클라이언트 사이드 검색 필터링 (스터디명만 검색) - const filteredStudies = useMemo(() => { - if (!searchQuery) return allStudies; - - const lowerQuery = searchQuery.toLowerCase(); - - return allStudies.filter((study) => - study.simpleDetailInfo?.title?.toLowerCase().includes(lowerQuery), - ); - }, [allStudies, searchQuery]); - - // 검색어가 있을 때는 클라이언트에서 페이지네이션, 없으면 서버 페이지네이션 사용 - const totalPages = searchQuery - ? Math.ceil(filteredStudies.length / PAGE_SIZE) || 1 - : (data?.totalPages ?? 1); - - const displayStudies = useMemo(() => { - if (!searchQuery) return filteredStudies; - - const startIndex = (currentPage - 1) * PAGE_SIZE; - - return filteredStudies.slice(startIndex, startIndex + PAGE_SIZE); - }, [filteredStudies, searchQuery, currentPage]); - if (isLoading) { return ( @@ -212,6 +91,7 @@ export default function PremiumStudyListPage() { )} diff --git a/src/components/premium/premium-study-pagination.tsx b/src/components/premium/premium-study-pagination.tsx index 51851adb..f8e2b7d2 100644 --- a/src/components/premium/premium-study-pagination.tsx +++ b/src/components/premium/premium-study-pagination.tsx @@ -1,31 +1,23 @@ 'use client'; -import { useRouter, useSearchParams } from 'next/navigation'; import Pagination from '@/components/ui/pagination'; interface PremiumStudyPaginationProps { currentPage: number; totalPages: number; + onPageChange: (page: number) => void; } export default function PremiumStudyPagination({ currentPage, totalPages, + onPageChange, }: PremiumStudyPaginationProps) { - const router = useRouter(); - const searchParams = useSearchParams(); - - const handleChangePage = (page: number) => { - const params = new URLSearchParams(searchParams.toString()); - params.set('page', String(page)); - router.push(`/premium-study?${params.toString()}`); - }; - return ( ); diff --git a/src/features/study/group/ui/group-study-pagination.tsx b/src/features/study/group/ui/group-study-pagination.tsx index e519f0f9..154796b0 100644 --- a/src/features/study/group/ui/group-study-pagination.tsx +++ b/src/features/study/group/ui/group-study-pagination.tsx @@ -1,31 +1,23 @@ 'use client'; -import { useRouter, useSearchParams } from 'next/navigation'; import Pagination from '@/components/ui/pagination'; interface GroupStudyPaginationProps { currentPage: number; totalPages: number; + onPageChange: (page: number) => void; } export default function GroupStudyPagination({ currentPage, totalPages, + onPageChange, }: GroupStudyPaginationProps) { - const router = useRouter(); - const searchParams = useSearchParams(); - - const handleChangePage = (page: number) => { - const params = new URLSearchParams(searchParams.toString()); - params.set('page', String(page)); - router.push(`/group-study?${params.toString()}`); - }; - return ( ); diff --git a/src/hooks/common/use-study-list-filter.ts b/src/hooks/common/use-study-list-filter.ts new file mode 100644 index 00000000..ef12c5e9 --- /dev/null +++ b/src/hooks/common/use-study-list-filter.ts @@ -0,0 +1,99 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import type { + GetGroupStudiesTypeEnum, + GetGroupStudiesTargetRolesEnum, + GetGroupStudiesMethodEnum, + GetGroupStudiesClassificationEnum, +} from '@/api/openapi/api/group-study-management-api'; +import type { StudyFilterValues } from '@/components/filtering/study-filter'; +import { useGetStudies } from '@/hooks/queries/study-query'; + +const PAGE_SIZE = 15; + +interface UseStudyListFilterParams { + classification: GetGroupStudiesClassificationEnum; +} + +export function useStudyListFilter({ + classification, +}: UseStudyListFilterParams) { + const [searchQuery, setSearchQuery] = useState(''); + const [filterValues, setFilterValues] = useState({ + type: [], + targetRoles: [], + method: [], + recruiting: true, + }); + const [currentPage, setCurrentPage] = useState(1); + + const { data, isLoading } = useGetStudies({ + classification, + page: searchQuery ? 1 : currentPage, + pageSize: searchQuery ? 10000 : PAGE_SIZE, + type: + filterValues.type.length > 0 + ? (filterValues.type as GetGroupStudiesTypeEnum[]) + : undefined, + targetRoles: + filterValues.targetRoles.length > 0 + ? (filterValues.targetRoles as GetGroupStudiesTargetRolesEnum[]) + : undefined, + method: + filterValues.method.length > 0 + ? (filterValues.method as GetGroupStudiesMethodEnum[]) + : undefined, + recruiting: filterValues.recruiting ? true : undefined, + }); + + const allStudies = useMemo(() => data?.content ?? [], [data?.content]); + + const handleFilterChange = useCallback((values: StudyFilterValues) => { + setFilterValues(values); + setCurrentPage(1); + }, []); + + const handlePageChange = useCallback((page: number) => { + setCurrentPage(page); + }, []); + + const handleSearch = useCallback((query: string) => { + setSearchQuery(query); + }, []); + + // 클라이언트 사이드 검색 필터링 (스터디명만 검색) + const filteredStudies = useMemo(() => { + if (!searchQuery) return allStudies; + + const lowerQuery = searchQuery.toLowerCase(); + + return allStudies.filter((study) => + study.simpleDetailInfo?.title?.toLowerCase().includes(lowerQuery), + ); + }, [allStudies, searchQuery]); + + const totalPages = searchQuery + ? Math.ceil(filteredStudies.length / PAGE_SIZE) || 1 + : (data?.totalPages ?? 1); + + const displayStudies = useMemo(() => { + if (!searchQuery) return filteredStudies; + + const startIndex = (currentPage - 1) * PAGE_SIZE; + + return filteredStudies.slice(startIndex, startIndex + PAGE_SIZE); + }, [filteredStudies, searchQuery, currentPage]); + + return { + searchQuery, + filterValues, + currentPage, + totalPages, + displayStudies, + isLoading, + handleFilterChange, + handlePageChange, + handleSearch, + }; +} From d1f0336436e81e1e7f23c0f3504ed01e50b91fa1 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Sun, 15 Feb 2026 19:53:02 +0900 Subject: [PATCH 06/16] =?UTF-8?q?feat:=20createdAt=EC=9D=84=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=95=84=EB=B0=94=ED=83=80=20?= =?UTF-8?q?=EC=8A=A4=ED=83=9D=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스터디 상세 페이지의 아바타 스택과 신청자 목록 페이지에서 신청자들을 createdAt을 기준으로 오름차순 정렬합니다. 이를 통해 먼저 신청한 유저가 먼저 보이게 됩니다. --- .../section/group-study-info-section.tsx | 18 ++++++------- .../section/premium-study-info-section.tsx | 18 ++++++------- src/features/my-page/ui/applicant-page.tsx | 25 +++++++++---------- 3 files changed, 30 insertions(+), 31 deletions(-) diff --git a/src/components/section/group-study-info-section.tsx b/src/components/section/group-study-info-section.tsx index 2a1ba385..fa6bd99c 100644 --- a/src/components/section/group-study-info-section.tsx +++ b/src/components/section/group-study-info-section.tsx @@ -1,8 +1,8 @@ 'use client'; import Image from 'next/image'; -import { useMemo } from 'react'; import { useParams, useRouter } from 'next/navigation'; +import { useMemo } from 'react'; import { GroupStudyFullResponseDto } from '@/api/openapi'; import UserAvatar from '@/components/ui/avatar'; import AvatarStack from '@/components/ui/avatar-stack'; @@ -38,14 +38,14 @@ export default function StudyInfoSection({ const avatarMembers = useMemo(() => { if (!applicants) return []; - return applicants.map((data) => ({ - memberId: data.applicantInfo.memberId, - nickname: data.applicantInfo.memberNickname || '익명', - profileImageUrl: - data.applicantInfo.profileImage?.resizedImages[0]?.resizedImageUrl ?? - '', - isLeader: data.role === 'LEADER', - })); + return [...applicants] + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + .map((data) => ({ + memberId: data.applicantInfo.memberId, + nickname: data.applicantInfo.memberNickname || '익명', + profileImageUrl: data.applicantInfo.profileImage?.resizedImages[0]?.resizedImageUrl ?? '', + isLeader: data.role === 'LEADER', + })); }, [applicants]); return ( diff --git a/src/components/section/premium-study-info-section.tsx b/src/components/section/premium-study-info-section.tsx index 476a2205..77685d24 100644 --- a/src/components/section/premium-study-info-section.tsx +++ b/src/components/section/premium-study-info-section.tsx @@ -1,8 +1,8 @@ 'use client'; import Image from 'next/image'; -import { useMemo } from 'react'; import { useParams, useRouter } from 'next/navigation'; +import { useMemo } from 'react'; import UserAvatar from '@/components/ui/avatar'; import AvatarStack from '@/components/ui/avatar-stack'; import type { AvatarStackMember } from '@/components/ui/avatar-stack'; @@ -44,14 +44,14 @@ export default function PremiumStudyInfoSection({ const applicantsList = getApplicantsList(approvedApplicants?.pages); const avatarMembers = useMemo(() => { - return applicantsList.map((data) => ({ - memberId: data.applicantInfo.memberId, - nickname: data.applicantInfo.memberNickname || '익명', - profileImageUrl: - data.applicantInfo.profileImage?.resizedImages[0] - ?.resizedImageUrl ?? '', - isLeader: data.role === 'LEADER', - })); + return [...applicantsList] + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + .map((data) => ({ + memberId: data.applicantInfo.memberId, + nickname: data.applicantInfo.memberNickname || '익명', + profileImageUrl: data.applicantInfo.profileImage?.resizedImages[0]?.resizedImageUrl ?? '', + isLeader: data.role === 'LEADER', + })); }, [applicantsList]); return ( diff --git a/src/features/my-page/ui/applicant-page.tsx b/src/features/my-page/ui/applicant-page.tsx index 6e1220f4..76da8d1a 100644 --- a/src/features/my-page/ui/applicant-page.tsx +++ b/src/features/my-page/ui/applicant-page.tsx @@ -63,19 +63,18 @@ export default function ApplicantPage(props: ApplicantListProps) {
{data?.pages.map((page, pageIndex) => ( - {page.content.map((applicant) => ( - - handleApprove( - Number(props.studyId), - applicant.applyId, - status, - ) - } - /> - ))} + {page.content + .slice() + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + .map((applicant) => ( + + handleApprove(Number(props.studyId), applicant.applyId, status) + } + /> + ))} ))}
From fdbca590eb8e7de3f1d964b74ddd6745aadffa9b Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Sun, 15 Feb 2026 20:01:53 +0900 Subject: [PATCH 07/16] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EC=B0=B8=EA=B0=80=EC=9E=90=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A0=95=EB=A0=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../section/group-study-info-section.tsx | 253 ++++++++++-------- src/components/summary/study-info-summary.tsx | 4 +- 2 files changed, 137 insertions(+), 120 deletions(-) diff --git a/src/components/section/group-study-info-section.tsx b/src/components/section/group-study-info-section.tsx index fa6bd99c..dc32e7d9 100644 --- a/src/components/section/group-study-info-section.tsx +++ b/src/components/section/group-study-info-section.tsx @@ -1,135 +1,152 @@ -'use client'; +"use client"; -import Image from 'next/image'; -import { useParams, useRouter } from 'next/navigation'; -import { useMemo } from 'react'; -import { GroupStudyFullResponseDto } from '@/api/openapi'; -import UserAvatar from '@/components/ui/avatar'; -import AvatarStack from '@/components/ui/avatar-stack'; -import type { AvatarStackMember } from '@/components/ui/avatar-stack'; -import Button from '@/components/ui/button'; -import UserProfileModal from '@/entities/user/ui/user-profile-modal'; -import { useApplicantsByStatusQuery } from '@/features/study/group/application/model/use-applicant-qeury'; -import { useIsLeader } from '@/stores/useLeaderStore'; -import { useUserStore } from '@/stores/useUserStore'; +import Image from "next/image"; +import { useParams, useRouter } from "next/navigation"; +import { useMemo } from "react"; +import { GroupStudyFullResponseDto } from "@/api/openapi"; +import UserAvatar from "@/components/ui/avatar"; +import AvatarStack from "@/components/ui/avatar-stack"; +import type { AvatarStackMember } from "@/components/ui/avatar-stack"; +import Button from "@/components/ui/button"; +import UserProfileModal from "@/entities/user/ui/user-profile-modal"; +import { useApplicantsByStatusQuery } from "@/features/study/group/application/model/use-applicant-qeury"; +import { useIsLeader } from "@/stores/useLeaderStore"; +import { useUserStore } from "@/stores/useUserStore"; -import SummaryStudyInfo from '../summary/study-info-summary'; +import SummaryStudyInfo from "../summary/study-info-summary"; interface StudyInfoSectionProps { - study: GroupStudyFullResponseDto; + study: GroupStudyFullResponseDto; } export default function StudyInfoSection({ - study: studyDetail, + study: studyDetail, }: StudyInfoSectionProps) { - const router = useRouter(); - const params = useParams(); - const memberId = useUserStore((state) => state.memberId); - const isLeader = useIsLeader(memberId); + const router = useRouter(); + const params = useParams(); + const memberId = useUserStore((state) => state.memberId); + const isLeader = useIsLeader(memberId); - const groupStudyId = Number(params.id); + const groupStudyId = Number(params.id); - const { data: approvedApplicants } = useApplicantsByStatusQuery({ - groupStudyId, - status: 'APPROVED', - }); - const applicants = approvedApplicants?.pages[0]?.content; + const { data: approvedApplicants } = useApplicantsByStatusQuery({ + groupStudyId, + status: "APPROVED", + }); + const applicants = approvedApplicants?.pages[0]?.content; - const avatarMembers = useMemo(() => { - if (!applicants) return []; + const avatarMembers = useMemo(() => { + if (!applicants) return []; - return [...applicants] - .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) - .map((data) => ({ - memberId: data.applicantInfo.memberId, - nickname: data.applicantInfo.memberNickname || '익명', - profileImageUrl: data.applicantInfo.profileImage?.resizedImages[0]?.resizedImageUrl ?? '', - isLeader: data.role === 'LEADER', - })); - }, [applicants]); + const leader = applicants.find((applicant) => applicant.role === "LEADER"); + const participants = applicants.filter( + (applicant) => applicant.role !== "LEADER", + ); - return ( -
-
-
- 썸네일 -
+ const sortedParticipants = [...participants].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); -
-
-

스터디 소개

-
-
- -
-
- - {studyDetail.basicInfo.leader.memberNickname} - -
- 스터디 리더 - - - {studyDetail.basicInfo.leader.simpleIntroduction} - -
-
-
-
- - 프로필 -
- } - /> -
-
- {studyDetail?.detailInfo.description} -
-
+ const sortedApplicants = leader + ? [leader, ...sortedParticipants] + : sortedParticipants; -
-
-
- 참가자 목록 - {`${applicants?.length ?? 0}명`} -
- {isLeader && ( - - )} -
+ return sortedApplicants.map((data) => ({ + memberId: data.applicantInfo.memberId, + nickname: + data.role === "LEADER" + ? `👑 ${data.applicantInfo.memberNickname || "익명"}` + : data.applicantInfo.memberNickname || "익명", + profileImageUrl: + data.applicantInfo.profileImage?.resizedImages[0]?.resizedImageUrl ?? + "", + isLeader: data.role === "LEADER", + })); + }, [applicants]); - -
-
-
- - - ); + return ( +
+
+
+ 썸네일 +
+ +
+
+

스터디 소개

+
+
+ +
+
+ + {studyDetail.basicInfo.leader.memberNickname} + +
+ 스터디 리더 + + + {studyDetail.basicInfo.leader.simpleIntroduction} + +
+
+
+
+ + 프로필 +
+ } + /> +
+
+ {studyDetail?.detailInfo.description} +
+
+ +
+
+
+ 참가자 목록 + {`${applicants?.length ?? 0}명`} +
+ {isLeader && ( + + )} +
+ + +
+
+
+ + + ); } diff --git a/src/components/summary/study-info-summary.tsx b/src/components/summary/study-info-summary.tsx index 8edb0fbe..b150bb7a 100644 --- a/src/components/summary/study-info-summary.tsx +++ b/src/components/summary/study-info-summary.tsx @@ -20,11 +20,11 @@ import { STUDY_TYPE_LABELS, } from '../../features/study/group/const/group-study-const'; -interface Props { +interface SummaryStudyInfoProps { data: GroupStudyFullResponse; } -export default function SummaryStudyInfo({ data }: Props) { +export default function SummaryStudyInfo({ data }: SummaryStudyInfoProps) { const router = useRouter(); const queryClient = useQueryClient(); const [isExpanded, setIsExpanded] = useState(false); From 2a9f28921f460b480a638d1bd07e21f8cb2af740 Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:29:01 +0900 Subject: [PATCH 08/16] =?UTF-8?q?feat=20:=20=EB=AC=B8=EC=9D=98=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=ED=95=98=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/modals/inquiry-modal.tsx | 154 ++++++++++++++++++ .../pages/group-study-detail-page.tsx | 20 ++- .../study/group/api/create-inquiry.ts | 34 ++++ .../study/group/model/inquiry.schema.ts | 30 ++++ src/hooks/queries/inquiry-api.ts | 21 +++ 5 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 src/components/modals/inquiry-modal.tsx create mode 100644 src/features/study/group/api/create-inquiry.ts create mode 100644 src/features/study/group/model/inquiry.schema.ts create mode 100644 src/hooks/queries/inquiry-api.ts diff --git a/src/components/modals/inquiry-modal.tsx b/src/components/modals/inquiry-modal.tsx new file mode 100644 index 00000000..7ed8ab28 --- /dev/null +++ b/src/components/modals/inquiry-modal.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { XIcon } from 'lucide-react'; +import { FormProvider, useForm } from 'react-hook-form'; +import Button from '@/components/ui/button'; +import { BaseInput, TextAreaInput } from '@/components/ui/input'; +import { Modal } from '@/components/ui/modal'; +import { + INQUIRY_CONTENT_MAX_LENGTH, + inquirySchema, + InquiryCategory, + InquiryFormValues, + INQUIRY_TITLE_MAX_LENGTH, +} from '@/features/study/group/model/inquiry.schema'; +import { useCreateInquiry } from '@/hooks/queries/inquiry-api'; +import { useToastStore } from '@/stores/use-toast-store'; +import { SingleDropdown } from '../ui/dropdown'; +import FormField from '../ui/form/form-field'; + +const INQUIRY_CATEGORY_OPTIONS = [ + { value: InquiryCategory.CURRICULUM, label: '커리큘럼' }, + { value: InquiryCategory.DIFFICULTY, label: '난이도' }, + { value: InquiryCategory.HW_AMOUNT, label: '과제량' }, + { value: InquiryCategory.ETC, label: '기타' }, +] as const; + +interface InquiryModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + studyId: number; +} + +export default function InquiryModal({ + open, + onOpenChange, + studyId, +}: InquiryModalProps) { + const showToast = useToastStore((state) => state.showToast); + const { mutate: createInquiry, isPending } = useCreateInquiry(); + + const form = useForm({ + resolver: zodResolver(inquirySchema), + defaultValues: { + title: '', + content: '', + category: undefined, + }, + }); + + const { handleSubmit, reset } = form; + + const onSubmit = (data: InquiryFormValues) => { + createInquiry( + { + groupStudyId: studyId, + request: { + title: data.title, + content: data.content, + category: data.category, + }, + }, + { + onSuccess: () => { + showToast('문의가 성공적으로 제출되었습니다.', 'success'); + reset(); + onOpenChange(false); + }, + onError: (error) => { + showToast('문의 제출에 실패했습니다. 다시 시도해주세요.', 'error'); + console.error('문의 제출 오류:', error); + }, + }, + ); + }; + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + reset(); + } + onOpenChange(isOpen); + }; + + return ( + + + + + + + 스터디 문의하기 + + + + + + +
+ + + name="category" + label="문의 종류" + direction="vertical" + required + > + + + + name="title" + label="제목" + direction="vertical" + required + > + + + + name="content" + label="내용" + direction="vertical" + required + > + + + + + + + +
+
+
+
+
+ ); +} diff --git a/src/components/pages/group-study-detail-page.tsx b/src/components/pages/group-study-detail-page.tsx index f4b1b9a6..0905d568 100644 --- a/src/components/pages/group-study-detail-page.tsx +++ b/src/components/pages/group-study-detail-page.tsx @@ -3,6 +3,8 @@ import { sendGTMEvent } from '@next/third-parties/google'; import { useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; +import InquiryModal from '@/components/modals/inquiry-modal'; +import Button from '@/components/ui/button'; import MoreMenu from '@/components/ui/dropdown/more-menu'; import Tabs from '@/components/ui/tabs'; import { STUDY_DETAIL_TABS, StudyTabValue } from '@/config/constants'; @@ -59,6 +61,7 @@ export default function StudyDetailPage({ const [showModal, setShowModal] = useState(false); const [action, setAction] = useState(null); const [showStudyFormModal, setShowStudyFormModal] = useState(false); + const [showInquiryModal, setShowInquiryModal] = useState(false); const { data: myApplicationStatus } = useGetGroupStudyMyStatus({ groupStudyId, @@ -158,12 +161,25 @@ export default function StudyDetailPage({ groupStudyId={groupStudyId} onOpenChange={() => setShowStudyFormModal(!showStudyFormModal)} /> +
-

+

{studyDetail?.detailInfo.title} -

+ +

{studyDetail?.detailInfo.summary}

diff --git a/src/features/study/group/api/create-inquiry.ts b/src/features/study/group/api/create-inquiry.ts new file mode 100644 index 00000000..0419c458 --- /dev/null +++ b/src/features/study/group/api/create-inquiry.ts @@ -0,0 +1,34 @@ +import { axiosInstance } from '@/api/client/axios'; + +export interface CreateInquiryRequest { + title: string; + content: string; + category?: 'CURRICULUM' | 'DIFFICULTY' | 'HW_AMOUNT' | 'SCHEDULE' | 'ETC'; +} + +export interface CreateInquiryResponse { + statusCode: number; + timestamp: string; + content: { + generatedQuestionId: number; + }; + message: string; +} + +// 문의 작성 +export const createInquiry = async ( + groupStudyId: number, + request: CreateInquiryRequest, +) => { + try { + const { data } = await axiosInstance.post( + `/group-studies/${groupStudyId}/questions`, + request, + ); + + return data; + } catch (error) { + console.error('문의 작성 실패:', error); + throw error; + } +}; diff --git a/src/features/study/group/model/inquiry.schema.ts b/src/features/study/group/model/inquiry.schema.ts new file mode 100644 index 00000000..e34d8f05 --- /dev/null +++ b/src/features/study/group/model/inquiry.schema.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +export enum InquiryCategory { + CURRICULUM = 'CURRICULUM', + DIFFICULTY = 'DIFFICULTY', + HW_AMOUNT = 'HW_AMOUNT', + ETC = 'ETC', +} + +export const INQUIRY_TITLE_MAX_LENGTH=50; +export const INQUIRY_CONTENT_MAX_LENGTH = 500; + + +export const inquirySchema = z.object({ + title: z.string().min(1, '제목을 입력해주세요.').min(1).max( + INQUIRY_TITLE_MAX_LENGTH,`제목은 ${INQUIRY_TITLE_MAX_LENGTH}자 이하로 입력해주세요.` + ), + content: z + .string() + .min(1, '내용을 입력해주세요.') + .max( + INQUIRY_CONTENT_MAX_LENGTH, + `내용은 ${INQUIRY_CONTENT_MAX_LENGTH}자 이하로 입력해주세요.`, + ), + category: z.nativeEnum(InquiryCategory, { + message: '카테고리를 선택해주세요.', + }), +}); + +export type InquiryFormValues = z.infer; diff --git a/src/hooks/queries/inquiry-api.ts b/src/hooks/queries/inquiry-api.ts new file mode 100644 index 00000000..2b781bbc --- /dev/null +++ b/src/hooks/queries/inquiry-api.ts @@ -0,0 +1,21 @@ +import { useMutation } from '@tanstack/react-query'; +import { + createInquiry, + CreateInquiryRequest, +} from '@/features/study/group/api/create-inquiry'; + +export const useCreateInquiry = () => { + return useMutation({ + mutationFn: async ({ + groupStudyId, + request, + }: { + groupStudyId: number; + request: CreateInquiryRequest; + }) => { + const data = await createInquiry(groupStudyId, request); + + return data.content; + }, + }); +}; From 755ff53d99d46af546b476139e8b7d9dda876bcc Mon Sep 17 00:00:00 2001 From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:14:28 +0900 Subject: [PATCH 09/16] =?UTF-8?q?feat=20:=20=ED=8F=BC=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=A6=AC=EB=8D=94=20=ED=8C=90=EB=B3=84?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useScrollToNextField 훅 추가 (data-scroll-field 속성 기반 다음 필드 자동 스크롤) - FieldControl에 onAfterChange, onAfterBlurFilled 콜백 지원 추가 - FormField에 scrollable prop 및 data-scroll-field 마킹 지원 추가 - 스터디 개설 폼(step1, step2), 문의/과제 제출/평가 모달에 자동 스크롤 적용 - StudyInfoSection isLeader를 props로 직접 전달하여 렌더 타이밍 버그 수정 - useLeaderStore/useUserStore 의존성 제거 (homework-detail-content 포함) Co-Authored-By: Claude Sonnet 4.5 --- .../contents/homework-detail-content.tsx | 751 +++++++++--------- .../modals/create-evaluation-modal.tsx | 311 ++++---- src/components/modals/inquiry-modal.tsx | 7 + .../modals/submit-homework-modal.tsx | 301 +++---- .../pages/group-study-detail-page.tsx | 462 +++++------ .../section/group-study-info-section.tsx | 6 +- src/components/ui/form/field-control.tsx | 31 +- src/components/ui/form/form-field.tsx | 13 + .../study/group/ui/step/step1-group.tsx | 527 ++++++------ .../study/group/ui/step/step2-group.tsx | 226 +++--- src/hooks/use-scroll-to-next-field.ts | 22 + 11 files changed, 1384 insertions(+), 1273 deletions(-) create mode 100644 src/hooks/use-scroll-to-next-field.ts diff --git a/src/components/contents/homework-detail-content.tsx b/src/components/contents/homework-detail-content.tsx index c4eb6a88..d0732d1f 100644 --- a/src/components/contents/homework-detail-content.tsx +++ b/src/components/contents/homework-detail-content.tsx @@ -1,405 +1,402 @@ -'use client'; - -import { useRouter, useSearchParams } from 'next/navigation'; -import { useState } from 'react'; - -import type { PeerReviewResponse } from '@/api/openapi/models'; -import Avatar from '@/components/ui/avatar'; -import Button from '@/components/ui/button'; -import MoreMenu from '@/components/ui/dropdown/more-menu'; -import ConfirmDeleteModal from '@/features/study/group/ui/confirm-delete-modal'; -import { useGetHomework } from '@/hooks/queries/group-study-homework-api'; -import { useGetMission } from '@/hooks/queries/mission-api'; +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; + +import type { PeerReviewResponse } from "@/api/openapi/models"; +import Avatar from "@/components/ui/avatar"; +import Button from "@/components/ui/button"; +import MoreMenu from "@/components/ui/dropdown/more-menu"; +import ConfirmDeleteModal from "@/features/study/group/ui/confirm-delete-modal"; +import { useGetHomework } from "@/hooks/queries/group-study-homework-api"; +import { useGetMission } from "@/hooks/queries/mission-api"; import { - useCreatePeerReview, - useDeletePeerReview, - useUpdatePeerReview, -} from '@/hooks/queries/peer-review-api'; -import { useIsLeader } from '@/stores/useLeaderStore'; -import { useUserStore } from '@/stores/useUserStore'; -import DeleteHomeworkModal from '../modals/delete-homework-modal'; -import EditHomeworkModal from '../modals/edit-homework-modal'; + useCreatePeerReview, + useDeletePeerReview, + useUpdatePeerReview, +} from "@/hooks/queries/peer-review-api"; + +import { useUserStore } from "@/stores/useUserStore"; +import DeleteHomeworkModal from "../modals/delete-homework-modal"; +import EditHomeworkModal from "../modals/edit-homework-modal"; interface HomeworkDetailContentProps { - missionId: number; - homeworkId: number; + missionId: number; + homeworkId: number; } export default function HomeworkDetailContent({ - homeworkId, - missionId, + homeworkId, + missionId, }: HomeworkDetailContentProps) { - const router = useRouter(); - const searchParams = useSearchParams(); - const currentUserId = useUserStore((state) => state.memberId); - const { data: homework, isLoading: isHomeworkLoading } = - useGetHomework(homeworkId); - const { - data: mission, - isLoading: isMissionLoading, - refetch: refetchMission, - } = useGetMission(missionId); - - const handleDeleteSuccess = async () => { - const params = new URLSearchParams(searchParams.toString()); - params.delete('homeworkId'); - router.push(`?${params.toString()}`); - await refetchMission(); - }; - - if (isHomeworkLoading || !homework || isMissionLoading || !mission) { - return null; - } - - const peerReviews = homework.peerReviews ?? []; - const isEvaluated = !!homework.evaluation; - - // 미션 제출 가능 기간이 지나지 않았는지 확인 - const isMissionActive = mission.status !== 'ENDED'; - - // 수정/삭제 가능 조건: 본인 과제이면서 평가 전이면서 미션 제출 가능 기간이 지나지 않은 상태 - const isMyHomework = homework.submitterId === currentUserId; - const canEditOrDelete = isMyHomework && !isEvaluated && isMissionActive; - - const profileImageUrl = - homework.submitterProfileImage?.resizedImages?.[0]?.resizedImageUrl ?? - '/profile-default.svg'; - - const formatDate = (dateString?: string) => { - if (!dateString) return ''; - const date = new Date(dateString); - - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} 제출`; - }; - - return ( -
- {/* 제출자 정보 및 과제 내용 */} -
- {/* 제출자 정보 */} -
-
- -
- - {homework.submitterNickname} - - - {formatDate(homework.submissionTime)} - -
-
- - {/* 수정/삭제 버튼 - 본인 과제이면서 평가 전이면서 미션 제출 가능 기간이 지나지 않은 경우에만 노출 */} - {canEditOrDelete && ( -
- - -
- )} -
- - {/* 과제 내용 */} -
- {homework.homeworkContent?.textContent} -
- - {/* 제출한 과제 링크 */} - {homework.homeworkContent?.optionalContent?.link && ( - - )} -
- - {/* 피어 리뷰 */} - -
- ); + const router = useRouter(); + const searchParams = useSearchParams(); + const currentUserId = useUserStore((state) => state.memberId); + const { data: homework, isLoading: isHomeworkLoading } = + useGetHomework(homeworkId); + const { + data: mission, + isLoading: isMissionLoading, + refetch: refetchMission, + } = useGetMission(missionId); + + const handleDeleteSuccess = async () => { + const params = new URLSearchParams(searchParams.toString()); + params.delete("homeworkId"); + router.push(`?${params.toString()}`); + await refetchMission(); + }; + + if (isHomeworkLoading || !homework || isMissionLoading || !mission) { + return null; + } + + const peerReviews = homework.peerReviews ?? []; + const isEvaluated = !!homework.evaluation; + + // 미션 제출 가능 기간이 지나지 않았는지 확인 + const isMissionActive = mission.status !== "ENDED"; + + // 수정/삭제 가능 조건: 본인 과제이면서 평가 전이면서 미션 제출 가능 기간이 지나지 않은 상태 + const isMyHomework = homework.submitterId === currentUserId; + const canEditOrDelete = isMyHomework && !isEvaluated && isMissionActive; + + const profileImageUrl = + homework.submitterProfileImage?.resizedImages?.[0]?.resizedImageUrl ?? + "/profile-default.svg"; + + const formatDate = (dateString?: string) => { + if (!dateString) return ""; + const date = new Date(dateString); + + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} 제출`; + }; + + return ( +
+ {/* 제출자 정보 및 과제 내용 */} +
+ {/* 제출자 정보 */} +
+
+ +
+ + {homework.submitterNickname} + + + {formatDate(homework.submissionTime)} + +
+
+ + {/* 수정/삭제 버튼 - 본인 과제이면서 평가 전이면서 미션 제출 가능 기간이 지나지 않은 경우에만 노출 */} + {canEditOrDelete && ( +
+ + +
+ )} +
+ + {/* 과제 내용 */} +
+ {homework.homeworkContent?.textContent} +
+ + {/* 제출한 과제 링크 */} + {homework.homeworkContent?.optionalContent?.link && ( + + )} +
+ + {/* 피어 리뷰 */} + +
+ ); } interface PeerReviewSectionProps { - homeworkId: number; - peerReviews: PeerReviewResponse[]; - isMyHomework: boolean; + homeworkId: number; + peerReviews: PeerReviewResponse[]; + isMyHomework: boolean; } function PeerReviewSection({ - homeworkId, - peerReviews, - isMyHomework, + homeworkId, + peerReviews, + isMyHomework, }: PeerReviewSectionProps) { - const currentUserId = useUserStore((state) => state.memberId); - const isMissionCreator = useIsLeader(currentUserId); - - // 자기 과제가 아니고, 미션 생성자(리더)가 아닌 경우에만 리뷰 작성 가능 - const canWriteReview = !isMyHomework && !isMissionCreator; - const [reviewText, setReviewText] = useState(''); - const { mutate: createPeerReview, isPending } = useCreatePeerReview(); - - const handleSubmitReview = () => { - if (!reviewText.trim()) return; - - createPeerReview( - { - homeworkId, - request: { comment: reviewText }, - }, - { - onSuccess: () => { - setReviewText(''); - }, - }, - ); - }; - - return ( -
-
- 피어 리뷰 - - {peerReviews.length}건 - -
- -
- {/* 피어 리뷰 목록 */} - {peerReviews.length > 0 && ( -
- {peerReviews.map((review) => ( - - ))} -
- )} - - {/* 리뷰 입력 - 자기 과제가 아닌 경우에만 표시 */} - {canWriteReview && ( - - )} -
-
- ); + // 자기 과제가 아닌 경우에만 리뷰 작성 가능 (리더도 허용) + const canWriteReview = !isMyHomework; + const [reviewText, setReviewText] = useState(""); + const { mutate: createPeerReview, isPending } = useCreatePeerReview(); + + const handleSubmitReview = () => { + if (!reviewText.trim()) return; + + createPeerReview( + { + homeworkId, + request: { comment: reviewText }, + }, + { + onSuccess: () => { + setReviewText(""); + }, + }, + ); + }; + + return ( +
+
+ 피어 리뷰 + + {peerReviews.length}건 + +
+ +
+ {/* 피어 리뷰 목록 */} + {peerReviews.length > 0 && ( +
+ {peerReviews.map((review) => ( + + ))} +
+ )} + + {/* 리뷰 입력 - 자기 과제가 아닌 경우에만 표시 */} + {canWriteReview && ( + + )} +
+
+ ); } interface PeerReviewItemProps { - review: PeerReviewResponse; - homeworkId: number; + review: PeerReviewResponse; + homeworkId: number; } function PeerReviewItem({ review, homeworkId }: PeerReviewItemProps) { - const currentUserId = useUserStore((state) => state.memberId); - const [isEditing, setIsEditing] = useState(false); - const [editValue, setEditValue] = useState(review.comment ?? ''); - const [showDeleteModal, setShowDeleteModal] = useState(false); - - const { mutate: updatePeerReview, isPending: isUpdating } = - useUpdatePeerReview(); - const { mutate: deletePeerReview } = useDeletePeerReview(); - - const isMyReview = review.reviewerId === currentUserId; - - const profileImageUrl = - review.reviewerProfileImage?.resizedImages?.[0]?.resizedImageUrl ?? - '/profile-default.svg'; - - const formatDateTime = (dateString?: string) => { - if (!dateString) return ''; - const date = new Date(dateString); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - - return `${year}.${month}.${day} ${hours}:${minutes}`; - }; - - const handleUpdate = () => { - if (!editValue.trim() || !review.peerReviewId) return; - - updatePeerReview( - { - peerReviewId: review.peerReviewId, - request: { comment: editValue }, - }, - { - onSuccess: () => { - setIsEditing(false); - }, - }, - ); - }; - - const handleDelete = () => { - if (!review.peerReviewId) return; - - deletePeerReview(review.peerReviewId, { - onSuccess: () => { - setShowDeleteModal(false); - }, - }); - }; - - const getMenuOptions = () => { - if (!isMyReview) return []; - - return [ - { - label: '수정하기', - value: 'edit', - onMenuClick: () => { - setEditValue(review.comment ?? ''); - setIsEditing(true); - }, - }, - { - label: '삭제하기', - value: 'delete', - onMenuClick: () => { - setShowDeleteModal(true); - }, - }, - ]; - }; - - return ( -
- setShowDeleteModal(false)} - title="피어 리뷰를 삭제하시겠습니까?" - content={ - <> - 삭제 시 모든 데이터가 영구적으로 제거됩니다. -
이 동작은 되돌릴 수 없습니다. - - } - confirmText="삭제" - onConfirm={handleDelete} - /> -
-
- -
- - {review.reviewerNickname} - - - {formatDateTime(review.createdAt)} - {review.updated && ' (수정됨)'} - -
-
- {isMyReview && } -
- - {isEditing ? ( -
-