Skip to content

[feat] 사전의견 조회 페이지 구현#64

Merged
choiyoungae merged 6 commits intodevelopfrom
feat/pre-opinions-63
Feb 8, 2026
Merged

[feat] 사전의견 조회 페이지 구현#64
choiyoungae merged 6 commits intodevelopfrom
feat/pre-opinions-63

Conversation

@choiyoungae
Copy link
Contributor

@choiyoungae choiyoungae commented Feb 8, 2026

🚀 풀 리퀘스트 제안

📋 작업 내용

사전의견(Pre-Opinions) 조회 및 삭제 기능 구현

🔧 변경 사항

  • 사전의견 목록 페이지 구현 (PreOpinionListPage.tsx)
    • 멤버별 사전의견 탭 전환
    • IntersectionObserver를 활용한 헤더 스크롤 감지
  • 사전의견 상세 컴포넌트 구현 (PreOpinionDetail.tsx)
    • 회원 정보 및 주제별 답변 표시
    • 내 사전의견 삭제 기능 (확인 모달 포함)
  • 멤버 리스트 컴포넌트 구현 (PreOpinionMemberList.tsx)
  • API 연동 (preOpinions.api.ts, preOpinions.endpoints.ts)
    • 사전의견 답변 조회 / 삭제 API
  • TanStack Query 훅 구현
    • usePreOpinionAnswers: 사전의견 답변 조회
    • useDeleteMyPreOpinionAnswer: 내 사전의견 삭제 (캐시 무효화 포함)
  • 타입 및 상수 정의 (preOpinions.types.ts, preOpinions.constants.ts)
  • 레이아웃 및 스타일 수정 (하단 여백 추가, shadow 클래스 수정)

📸 스크린샷 (선택 사항)

image image image

📄 기타

  • 디자인 문의사항의 응답에 따라 수정 가능성 있습니다.

Summary by CodeRabbit

  • 신규 기능

    • 사전 의견 전용 페이지 추가 — 참석자 목록에서 회원 선택해 상세 의견 열람 가능(초기에는 제출한 첫 회원 선택)
    • 선택한 회원의 책 리뷰(별점·키워드) 및 토픽별 의견 표시
    • 자신의 사전 의견 삭제(확인 모달·오류 처리 포함) 기능 지원
    • 사전의견 데이터 조회·캐싱 및 삭제 연동 훅 추가
  • UI 개선

    • 참석자 칩·아바타 변형으로 역할별 표시 개선(선택/비활성 상태 반영)
    • 서브 페이지 헤더에 추가 스타일 클래스 적용 가능
  • 라우팅

    • 사전 의견 페이지 접근 경로 추가

@choiyoungae choiyoungae linked an issue Feb 8, 2026 that may be closed by this pull request
2 tasks
@choiyoungae choiyoungae self-assigned this Feb 8, 2026
@choiyoungae choiyoungae added the feat 새로운 기능 추가 label Feb 8, 2026
@coderabbitai
Copy link

coderabbitai bot commented Feb 8, 2026

Walkthrough

사전 의견(pre-opinions) 기능을 새로 추가: 타입·API·엔드포인트·모크·쿼리 훅·컴포넌트·페이지·라우트 및 관련 공유 UI 확장으로 의견 조회 및 본인 삭제 흐름을 구현합니다. (50단어 이내)

Changes

Cohort / File(s) Summary
타입 정의
src/features/preOpinions/preOpinions.types.ts
Pre-opinions 도메인 타입(멤버/토픽/키워드/파라미터 등) 추가.
API / 엔드포인트 / Mock
src/features/preOpinions/preOpinions.api.ts, src/features/preOpinions/preOpinions.endpoints.ts, src/features/preOpinions/preOpinions.mock.ts
getPreOpinionAnswers, deleteMyPreOpinionAnswer 구현, 엔드포인트 빌더 및 모크 데이터와 USE_MOCK 분기 추가.
쿼리 키 & 훅
src/features/preOpinions/hooks/preOpinionQueryKeys.ts, src/features/preOpinions/hooks/usePreOpinionAnswers.ts, src/features/preOpinions/hooks/useDeleteMyPreOpinionAnswer.ts, src/features/preOpinions/hooks/index.ts
TanStack Query용 쿼리키 팩토리, 답변 조회 훅(파라미터 검증·캐시 설정), 삭제 뮤테이션(성공 시 쿼리 무효화) 및 배럴 추가.
컴포넌트
src/features/preOpinions/components/PreOpinionDetail.tsx, src/features/preOpinions/components/PreOpinionMemberList.tsx, src/features/preOpinions/components/index.ts
멤버 목록 및 상세 컴포넌트 추가. 본인 삭제 버튼 → 확인 모달 → 삭제 뮤테이션 흐름 및 오류 모달 처리 포함.
상수
src/features/preOpinions/preOpinions.constants.ts
MemberRole → Avatar variant 맵(ROLE_TO_AVATAR_VARIANT) 추가.
Feature 배럴
src/features/preOpinions/index.ts
components/hooks/API/types 등 재수출하는 feature 엔트리 배럴 추가.
페이지 및 라우트
src/pages/PreOpinions/PreOpinionListPage.tsx, src/pages/PreOpinions/index.ts, src/pages/index.ts, src/routes/index.tsx, src/shared/constants/routes.ts
PreOpinionListPage(멤버 리스트 + 상세 2열 레이아웃) 추가, 페이지 배럴·최상위 export, PRE_OPINIONS 라우트 등록.
공유 UI 확장
src/shared/components/SubPageHeader.tsx, src/shared/ui/UserChip.tsx
SubPageHeader에 optional className prop 추가; UserChip에 variant prop 추가 및 Avatar로 전달.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Page as PreOpinionListPage
    participant Hook as usePreOpinionAnswers
    participant Query as QueryClient
    participant API as API_Layer

    User->>Page: 진입(gatheringId, meetingId)
    Page->>Hook: usePreOpinionAnswers 호출
    Hook->>Query: queryKey 확인
    alt 캐시 없음
        Query->>API: getPreOpinionAnswers 요청
        API-->>Query: PreOpinionAnswersData 반환
    else 캐시 있음
        Query-->>Hook: 캐시 데이터 반환
    end
    Hook-->>Page: data, isLoading 반환
    Page->>Page: activeMember 결정 및 렌더링(PreOpinionMemberList, PreOpinionDetail)
Loading
sequenceDiagram
    participant User
    participant Detail as PreOpinionDetail
    participant Modal as GlobalModalStore
    participant Mutate as useDeleteMyPreOpinionAnswer
    participant API as API_Layer
    participant Query as QueryClient

    User->>Detail: 삭제 버튼 클릭
    Detail->>Modal: openConfirm 호출
    Modal-->>User: 확인 모달 표시
    User->>Modal: 확인 선택
    Modal-->>Detail: onConfirm 콜백
    Detail->>Mutate: mutate(delete params)
    Mutate->>API: deleteMyPreOpinionAnswer 요청
    alt 성공
        API-->>Mutate: 200 OK
        Mutate->>Query: invalidateQueries(preOpinionQueryKeys.answers())
        Query-->>Page: refetch 트리거
    else 실패
        API-->>Mutate: Error
        Mutate-->>Detail: error 반환
        Detail->>Modal: openError 호출(에러 표시)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • mgYang53
  • haruyam15
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 사전의견 조회 페이지 구현이라는 주요 변경사항을 명확하게 설명하고 있으며, raw_summary의 모든 파일 변경사항들이 이 목표를 지원합니다.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/pre-opinions-63

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
src/features/preOpinions/preOpinions.mock.ts (1)

100-102: 동일 객체 참조 반환 — mock 데이터 오염 가능성

getMockPreOpinionAnswers가 매번 같은 mockPreOpinionAnswers 참조를 반환합니다. 소비 측에서 데이터를 변형(mutate)하면 이후 호출에도 변경이 반영됩니다.

mock 전용이라 실제 영향은 낮지만, 디버깅 시 혼동을 줄 수 있습니다.

♻️ structuredClone으로 복사 반환
 export const getMockPreOpinionAnswers = (): PreOpinionAnswersData => {
-  return mockPreOpinionAnswers
+  return structuredClone(mockPreOpinionAnswers)
 }

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/pages/PreOpinions/PreOpinionListPage.tsx`:
- Around line 26-30: The useEffect is being retriggered because you destructure
the whole modal store causing openError's reference to change; change the
subscription to only the openError function by selecting it directly from
useGlobalModalStore (e.g., useGlobalModalStore(state => state.openError)) so
openError's identity is stable and the effect depending on openError does not
rerun unnecessarily; update the existing destructuring at the top of
PreOpinionListPage (replace const { openError } = useGlobalModalStore() or
similar) and keep the useEffect as-is to rely on the stable openError reference.
🧹 Nitpick comments (10)
src/shared/ui/UserChip.tsx (1)

10-10: variant 타입을 AvatarVariant에서 가져오는 것을 고려해보세요.

'leader' | 'host' | 'member'를 직접 정의하면 AvatarAvatarVariant 타입이 변경될 때 동기화가 깨질 수 있습니다. Avatar.tsx에서 AvatarVariant를 export하고 재사용하면 유지보수가 편해집니다.

Also applies to: 34-34, 70-70

src/features/preOpinions/preOpinions.mock.ts (1)

100-102: 목 데이터 참조 공유 주의

getMockPreOpinionAnswers가 동일 객체 참조를 반환합니다. 테스트나 mock 환경에서 반환값을 변형(mutate)하면 이후 호출에도 영향을 줍니다. 현재 사용처가 읽기 전용이면 괜찮지만, 안전하게 하려면 structuredClone을 고려해보세요.

src/features/preOpinions/preOpinions.types.ts (1)

63-73: GetPreOpinionAnswersParamsDeleteMyPreOpinionAnswerParams의 구조가 동일합니다.

현재는 { gatheringId: number; meetingId: number }로 동일하지만, 의미적 분리를 위해 별도 유지하는 것도 합리적입니다. 향후 파라미터가 분기될 가능성이 없다면 공통 타입 추출도 고려해보세요.

src/features/preOpinions/components/PreOpinionMemberList.tsx (1)

43-47: onClick 내부의 isSubmitted 체크가 중복입니다.

UserChipdisabled 상태일 때 내부적으로 onClick을 호출하지 않습니다(Line 49 of UserChip.tsx). disabled={!member.isSubmitted}를 이미 전달하고 있으므로 여기서 다시 체크할 필요는 없습니다.

제안
-          onClick={() => {
-            if (member.isSubmitted) {
-              onSelectMember(member.memberInfo.userId)
-            }
-          }}
+          onClick={() => onSelectMember(member.memberInfo.userId)}
src/routes/index.tsx (1)

104-107: 라우트 정의 방식이 기존과 다릅니다.

기존 라우트들은 `${ROUTES.GATHERINGS}/:gatheringId/meetings/:meetingId` 형태의 템플릿 리터럴을 사용하는데(Line 101), 여기서는 ROUTES.PRE_OPINIONS(':gatheringId', ':meetingId') 헬퍼 함수를 사용합니다. 기능적으로 동일하지만, 코드베이스 내 일관성 측면에서 한 가지 방식으로 통일하면 좋겠습니다.

src/features/preOpinions/index.ts (1)

10-10: mock 모듈을 배럴에서 re-export하는 것은 불필요한 코드 구조입니다.

현재 코드에서는 preOpinions.api.ts가 mock을 직접 import하고, 프로덕션 코드는 배럴에서 mock을 import하지 않습니다. 다만 배럴에서 export하면 번들 분석이 복잡해지고, 의도하지 않은 import 경로가 열립니다. Vite의 tree-shaking이 처리하더라도, 명시적으로 배럴에서 제외하는 것이 명확합니다.

 // API
 export * from './preOpinions.api'
 export * from './preOpinions.endpoints'
-export * from './preOpinions.mock'
 
 // Types
 export * from './preOpinions.types'

Mock이 필요한 내부 로직에서는 이미 직접 import 중이므로, 배럴 export는 제거해도 문제없습니다.

src/pages/PreOpinions/PreOpinionListPage.tsx (2)

21-24: Number() 변환이 중복 수행됩니다.

Number(gatheringId), Number(meetingId)가 라인 22-23과 78-79에서 반복됩니다. → params가 undefined일 때 매번 NaN을 생성하며, 값 불일치 가능성도 생깁니다.

컴포넌트 상단에서 한 번만 변환하세요.

♻️ 제안
 export default function PreOpinionListPage() {
   const { gatheringId, meetingId } = useParams<{ gatheringId: string; meetingId: string }>()
+  const numericGatheringId = Number(gatheringId)
+  const numericMeetingId = Number(meetingId)
   const navigate = useNavigate()
   const { openError } = useGlobalModalStore()
   ...

   const { data, isLoading, error } = usePreOpinionAnswers({
-    gatheringId: Number(gatheringId),
-    meetingId: Number(meetingId),
+    gatheringId: numericGatheringId,
+    meetingId: numericMeetingId,
   })

그리고 라인 78-79에서도 동일하게 사용:

           <PreOpinionDetail
             member={selectedMember}
             topics={data.topics}
-            gatheringId={Number(gatheringId)}
-            meetingId={Number(meetingId)}
+            gatheringId={numericGatheringId}
+            meetingId={numericMeetingId}
           />

Also applies to: 78-79


32-45: IntersectionObserver의 [isLoading] 의존성 — 의도는 맞지만 주석이 있으면 좋겠습니다.

isLoadingtrue일 때 라인 56에서 early return하므로 sentinel이 DOM에 없고, false가 된 후에야 observer를 연결하는 구조입니다. 의존성 배열에 isLoading을 넣은 이유가 자명하지 않으니, 간단한 주석을 추가하면 이후 유지보수에 도움이 됩니다.

src/features/preOpinions/preOpinions.api.ts (1)

54-58: deleteMyPreOpinionAnswer에 mock 분기가 없습니다.

getPreOpinionAnswers에는 USE_MOCK 분기가 있지만, 삭제 함수에는 없습니다. → mock 모드 개발 시 삭제 호출이 실제 API로 나가거나 실패합니다.

의도적이라면 무시해도 되지만, mock 모드에서의 개발 편의를 위해 동일하게 mock 분기를 추가하는 것을 권장합니다.

src/features/preOpinions/components/PreOpinionDetail.tsx (1)

77-106: 키워드 필터링이 각 타입별로 두 번씩 수행됩니다.

filter(k => k.type === 'BOOK')이 조건 확인(라인 77)과 렌더링(라인 82)에서 중복 호출됩니다. 'IMPRESSION'도 동일(라인 93, 98).

→ 리스트가 작아 성능 문제는 아니지만, 변수로 추출하면 가독성과 유지보수성이 좋아집니다.

♻️ 제안
+      const bookKeywords = bookReview.keywords.filter((k) => k.type === 'BOOK')
+      const impressionKeywords = bookReview.keywords.filter((k) => k.type === 'IMPRESSION')

이후 조건과 렌더링에서 bookKeywords, impressionKeywords를 재사용하세요.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/pages/PreOpinions/PreOpinionListPage.tsx`:
- Around line 26-30: The useEffect only handles the specific
ErrorCode.BOOK_REVIEW_ACCESS_DENIED_NOT_WRITTEN case so other failures leave
isLoading/data undefined and the UI shows "멤버를 선택해주세요"; update the error
handling in PreOpinionListPage by adding a branch in the existing useEffect (or
a separate effect) that catches all other errors (error != null &&
!error.is(ErrorCode.BOOK_REVIEW_ACCESS_DENIED_NOT_WRITTEN)) and calls openError
with a generic title and error.userMessage (or a fallback message), ensures any
loading flags are cleared, and/or sets a local error state to render a fallback
error UI instead of the empty member prompt; reference the existing useEffect,
error, ErrorCode.BOOK_REVIEW_ACCESS_DENIED_NOT_WRITTEN, openError, navigate, and
the component render path that uses isLoading/data to implement the fix.
🧹 Nitpick comments (3)
src/pages/PreOpinions/PreOpinionListPage.tsx (3)

21-24: Number(gatheringId) 중복 파싱 → 변수로 추출 권장

Number(gatheringId)/Number(meetingId)가 Line 22-23과 Line 78-79에서 반복됩니다. 상단에서 한 번만 파싱하면 중복을 제거하고, NaN 전파 위험도 한 곳에서 관리할 수 있습니다.

♻️ 제안
+ const numGatheringId = Number(gatheringId)
+ const numMeetingId = Number(meetingId)
+
  const { data, isLoading, error } = usePreOpinionAnswers({
-   gatheringId: Number(gatheringId),
-   meetingId: Number(meetingId),
+   gatheringId: numGatheringId,
+   meetingId: numMeetingId,
  })

Line 78-79도 동일하게 교체:

-          gatheringId={Number(gatheringId)}
-          meetingId={Number(meetingId)}
+          gatheringId={numGatheringId}
+          meetingId={numMeetingId}

32-45: IntersectionObserver 의존성이 간접적

isLoading이 의존성으로 들어가 있지만, 실제 의도는 sentinel DOM 노드의 마운트 여부입니다. 현재 동작은 정상이지만, 향후 로딩 UI 변경(Suspense 전환 등) 시 observer가 의도대로 붙지 않을 수 있습니다.

sentinelRef를 callback ref로 전환하면 DOM 존재 여부에 직접 반응할 수 있어 더 견고합니다. 다만 현재 구조에서는 큰 문제가 아니므로 참고 수준입니다.


48-54: 삭제 후 selectedMemberId 상태 불일치 가능성

본인 의견 삭제 → 캐시 무효화 → data.members에서 해당 멤버가 사라질 경우, selectedMemberId는 여전히 이전 값을 보유합니다. selectedMemberundefined가 되어 placeholder가 표시되므로 크래시는 없지만, 삭제 성공 시 setSelectedMemberId(null)로 리셋하면 자연스럽게 첫 번째 제출 멤버로 전환되어 UX가 개선됩니다.

PreOpinionDetail 내부의 삭제 콜백에서 처리하거나, data 변경 시 유효성을 검사하는 effect를 추가하는 방법이 있습니다.

@choiyoungae choiyoungae merged commit 953a6aa into develop Feb 8, 2026
2 checks passed
@choiyoungae choiyoungae deleted the feat/pre-opinions-63 branch February 8, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat 새로운 기능 추가

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] 사전의견 조회 페이지 구현

2 participants