Skip to content

3_22 최신화 - #232

Merged
hongik-luke merged 16 commits into
mainfrom
dev
Mar 22, 2026
Merged

hongik-luke merged 16 commits into
mainfrom
dev

Conversation

@hongik-luke

@hongik-luke hongik-luke commented Mar 22, 2026 •

Copy link
Copy Markdown
Contributor

📌 개요 (Summary)

  • 변경 사항에 대한 간략한 요약을 적어주세요.
  • 관련 이슈가 있다면 링크를 걸어주세요 (예: [fix] cicd / build 문제 #123).

🛠️ 변경 사항 (Changes)

  • 새로운 기능 추가
  • 버그 수정
  • 코드 리팩토링
  • 문서 업데이트
  • 기타 (설명: )

📸 스크린샷 (Screenshots)

(UI 변경 사항이 있다면 첨부해주세요)

✅ 체크리스트 (Checklist)

  • 빌드가 성공적으로 수행되었나요? (pnpm build)
  • 린트 에러가 없나요? (pnpm lint)
  • 불필요한 콘솔 로그나 주석을 제거했나요?

Summary by CodeRabbit

  • New Features

    • Added server-side search functionality to admin news, stories, and user management pages for improved performance.
    • Introduced infinite scroll/load-more for debate and review sections.
    • Added meeting chat modal with team selection and message history.
    • New team member information popover in meeting bookcase.
  • Bug Fixes

    • Fixed news deletion when publish end date has passed.
    • Improved user data loading and error handling in admin detail pages.
  • Enhancements

    • Enhanced admin navigation with real user detail pages.
    • Improved meeting interface with better topic and team management.

@vercel

vercel Bot commented Mar 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
check-mo Ready Ready Preview, Comment Mar 23, 2026 1:44am
checkmo Ready Ready Preview, Comment Mar 23, 2026 1:44am

@coderabbitai

coderabbitai Bot commented Mar 22, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The PR migrates admin pages from dummy data to server-fetched content, implements server-side filtering and pagination for news/stories/members, refactors meeting pages with real data queries and infinite-scroll pagination, updates navigation to use nickname-based routing, and introduces new components and services for meeting chat and team member management.

Changes

Cohort / File(s) Summary
Admin Navigation & Data Migration
src/app/(admin)/admin/(app)/groups/[id]/members/page.tsx, src/app/(admin)/admin/(app)/users/page.tsx, src/app/(admin)/admin/(app)/users/[id]/page.tsx
Replaced dummy user data with API-driven state using fetchAdminMemberDetail and fetchAdminMembers. Updated navigation links to use nickname-based routing instead of userId. Added loading/error states and derived props from real member data.
Admin News & Stories Filtering
src/app/(admin)/admin/(app)/news/page.tsx, src/app/(admin)/admin/(app)/news/[id]/edit/page.tsx, src/app/(admin)/admin/(app)/stories/page.tsx
Moved client-side filtering to server-side via fetchAdminNews and fetchAdminBookStories with keyword parameters. Added delete functionality in news edit page with date validation. Updated useEffect dependencies to include searchKeyword state.
Bookcase & Meeting Pages Refactor
src/app/groups/[id]/bookcase/[bookId]/page.tsx, src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx, src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx
Refactored meeting page with useMeetingDetailQuery/useMeetingTopicsQuery, client-side route params via useParams, team selection with router.replace, and chat modal integration. Added infinite-scroll props to debate/review sections. Removed type casts in admin bookcase page.
Infinite Scroll Components
src/app/groups/[id]/bookcase/[bookId]/DebateSection.tsx, src/app/groups/[id]/bookcase/[bookId]/ReviewSection.tsx
Added useInView intersection observer with rootMargin: "300px 0px" to trigger onLoadMore() callback. Extended props with hasNextPage, isFetchingNextPage, and onLoadMore. Added conditional loading sentinel UI.
Admin Member Detail Components
src/components/base-ui/Admin/users/BookStoryList.tsx, src/components/base-ui/Admin/users/MeetingList.tsx, src/components/base-ui/Admin/users/NewsList.tsx, src/components/base-ui/Admin/users/ReportList.tsx
Updated all components to accept memberNickname instead of userId. Replaced local query hooks with imperative fetchAdminMember* calls. Added loading/error state handling and safety checks for data transformations (e.g., thumbnail URL fallback).
New Meeting Chat & Team Components
src/components/base-ui/Bookcase/MeetingChatModal.tsx, src/components/base-ui/Bookcase/MeetingTeamMemberPopover.tsx, src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx
Added MeetingChatModal with infinite-scroll chat history, draggable positioning, and team selection. Added MeetingTeamMemberPopover for member list display with navigation to profile. Simplified ChatTeamSelectModal to remove view switching and dragging, exposing onSelectTeam callback.
Admin News & Member Services
src/services/meetingTopicService.ts, src/services/meetingChatService.ts
New services for fetching meeting topics and chat messages with typed responses (GetMeetingTopicsResponseResult, GetMeetingChatsResponseResult).
Query Hooks
src/hooks/queries/useMeetingTopicQueries.ts, src/hooks/queries/useMeetingChatQueries.ts
New hooks for TanStack React Query integration. useMeetingTopicsQuery for single-page topics, useMeetingChatsInfiniteQuery for paginated chat history with cursor-based navigation.
Mutation Hooks & Utilities
src/hooks/mutations/useMeetingMutations.ts, src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx
Updated useUpdateMeetingTeamsMutation to await query invalidation and prefetch. Updated MemberPool.onSubmit prop to async with submission state tracking and disabled button during save.
API Endpoints
src/lib/api/endpoints/Clubs.ts, src/lib/api/admin/endpoints/member.ts, src/lib/api/admin/endpoints/news.ts, src/lib/api/admin/endpoints/stories.ts
Added new endpoint builders: meetingTeamTopics, meetingTeamChatMessages. Updated admin endpoints for members (list, detail, reports, clubs, stories, news). Parametrized news and stories list endpoints with page/keyword. Added delete endpoint for news.
Admin API Functions
src/lib/api/admin/member.ts, src/lib/api/admin/news.ts, src/lib/api/admin/stories.ts
Added comprehensive admin member API: fetchAdminMembers, fetchAdminMemberDetail, fetchAdminMemberReports, fetchAdminMemberClubs, fetchAdminMemberBookStories, fetchAdminMemberNews. Updated fetchAdminNews with keyword parameter. Added deleteAdminNews. Enhanced error handling with response isSuccess checks.
Type Definitions
src/types/groups/meetingChats.ts, src/types/groups/meetingTopics.ts, src/types/groups/meetingDetail.ts
New types for meeting chat messages and topics. Updated MeetingMemberInfo.profileImageUrl to nullable. Defined response wrappers and extraction types for API integration.
Minor Component Updates
src/components/base-ui/Admin/users/items/AdminNewsItem.tsx
Added sizes="100px" prop to next/image for news thumbnail.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • 관리자 모임 관리 API 최신화 #224: Modifies the same members page file with nickname-based member routing that directly supports this PR's navigation refactor.
  • 03/17 최신화 #205: Overlaps with admin news/member API changes, including fetchAdminNews, deleteAdminNews, and member endpoint updates.
  • 03.05 최신화 #172: Shares API endpoint file modifications (src/lib/api/endpoints/Clubs.ts) with meeting-related endpoint builders.

Suggested labels

✨ feat, 🔄 refactor, 🐛 admin


🐰 Hops with glee across the admin pages,
Server-side filters span all the stages,
Chat modals dance, infinite scrolls swirl,
A meeting refactor that makes data unfurl! ✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.32% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title '3_22 최신화' is vague and non-descriptive, using only a date reference and a generic term meaning 'update' without conveying the actual nature or scope of the changes. Replace with a specific, descriptive title that summarizes the main change, such as 'Add API integrations for admin member management and meeting features' or similar.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev

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.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이 PR은 여러 관리자 및 사용자 페이지에 백엔드 API를 통합하여 더미 데이터를 실제 정보로 대체하는 데 중점을 둡니다. 또한 게시 종료일에 따른 소식 자동 삭제와 그룹 모임 페이지의 대화형 채팅 및 개선된 콘텐츠 로딩과 같은 새로운 기능을 도입하여 사용자 경험을 향상시켰습니다.

Highlights

  • 관리자 페이지 API 연동: 사용자, 소식, 책 이야기 관리자 페이지가 더미 데이터 대신 실제 API 데이터를 사용하도록 업데이트되었습니다.
  • 소식 게시 종료일 자동 삭제: 소식 편집 페이지에 게시 종료일이 지난 소식을 자동으로 삭제하는 로직이 추가되었습니다.
  • 모임 페이지 채팅 기능 개선: 모임 페이지에 드래그 가능한 채팅 모달과 무한 스크롤을 지원하는 실시간 채팅 기능이 도입되었습니다.
  • 토론 및 한줄평 무한 스크롤: 그룹 책 상세 페이지의 토론 및 한줄평 섹션에 무한 스크롤 기능이 추가되어 사용자 경험이 향상되었습니다.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

이 PR은 관리자 페이지의 여러 기능을 실제 API 데이터와 연동하고, 검색 및 페이지네이션 기능을 개선하는 등 많은 업데이트를 포함하고 있습니다. 특히 회원 상세 정보 페이지가 실제 데이터를 사용하도록 변경되었고, 모임 페이지는 실시간 데이터 조회, 팀 관리, 채팅 기능 등 대대적인 리팩토링이 이루어졌습니다. 코드 구조가 개선되고 타입 안전성이 향상된 점이 인상적입니다. 몇 가지 개선점을 제안드렸으니 확인 부탁드립니다.

date: "2025.01.01",
},
];
export default function NewsList({ memberNickname }: Props) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

현재 이 컴포넌트는 사용자가 등록한 소식의 첫 페이지만 불러오고 있습니다. fetchAdminMemberNews API는 커서 기반 페이지네이션을 지원하므로, BookStoryList.tsx 컴포넌트처럼 무한 스크롤을 구현하여 사용자가 모든 소식을 볼 수 있도록 개선하면 좋을 것 같습니다.

category={report.reportType}
reporterName={report.reportedMemberNickname}
content={report.content}
date={new Date(report.createdAt).toLocaleDateString()}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

toLocaleDateString()을 사용하여 날짜를 포맷하면 사용자의 로케일 설정에 따라 날짜 형식이 다르게 표시될 수 있습니다. 애플리케이션 전체의 날짜 표시 일관성을 위해 YYYY.MM.DD와 같은 특정 형식으로 포맷하는 것을 권장합니다. 예를 들어, new Date(report.createdAt).toLocaleDateString('ko-KR')와 같이 로케일을 명시하거나 날짜 포맷 라이브러리를 사용하는 것을 고려해볼 수 있습니다.

Suggested change
date={new Date(report.createdAt).toLocaleDateString()}
date={new Date(report.createdAt).toLocaleDateString('ko-KR')}

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx (1)

43-76: ⚠️ Potential issue | 🟠 Major

isInitialized is never set to true, causing stale loading behavior.

The isInitialized state (line 45) is declared but never updated. After removing the initialization logic from useEffect, the variable became dead code. However, it's still used at line 183:

const showLoading = isLoading || (!isInitialized && isFetching);

Since isInitialized is always false, this reduces to isLoading || isFetching, which will show the loading UI on every background refetch—not just the initial load.

Either:

  1. Remove isInitialized entirely if you want loading on every refetch, or
  2. Set isInitialized to true after the first successful data load to preserve the original intended behavior.
Option 1: Remove isInitialized (loading shown on every refetch)
  const [teams, setTeams] = useState<number[]>([1]);
  const [members, setMembers] = useState<TeamMember[]>([]);
- const [isInitialized, setIsInitialized] = useState(false);

  // ... later at line 183 ...
- const showLoading = isLoading || (!isInitialized && isFetching);
+ const showLoading = isLoading || isFetching;
Option 2: Restore initialization tracking (loading only on first fetch)
  useEffect(() => {
    if (!data) return;

    const existingTeamNumbers =
      data.existingTeams?.map((t) => Number(t.teamNumber)).filter(Number.isFinite) ?? [];

    const normalized = normalizeTeams(existingTeamNumbers);
    setTeams(normalized);

    const mappedMembers: TeamMember[] =
      data.clubMembers?.map((cm) => ({
        clubMemberId: cm.clubMemberId,
        memberInfo: {
          nickname: cm.memberInfo?.nickname ?? "",
          profileImageUrl: cm.memberInfo?.profileImageUrl ?? "",
        },
        teamNumber: cm.teamKey?.teamNumber ?? null,
      })) ?? [];

    setMembers(mappedMembers);
+   setIsInitialized(true);
  }, [data]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/admin/bookcase/[meetingId]/page.tsx around lines 43 -
76, The isInitialized flag is declared but never updated, so showLoading (used
where showLoading = isLoading || (!isInitialized && isFetching)) always treats
isInitialized as false; either remove isInitialized and the (!isInitialized &&
isFetching) clause, or set it to true after the first successful data load
inside the existing useEffect: call setIsInitialized(true) once when data is
present (use the same useEffect that maps existingTeamNumbers/mappedMembers) so
subsequent background refetches won't trigger the initial-loading UI;
references: isInitialized, setIsInitialized, showLoading, useEffect, data.
src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx (1)

171-174: ⚠️ Potential issue | 🟡 Minor

Modal can be dismissed while submission is in progress.

The backdrop click (line 173) and "아니오" button (line 195) can close the modal during submission. If onSubmit() succeeds after the modal is closed, router.back() will still execute, causing unexpected navigation.

Consider disabling these interactions while isSubmitting is true for consistency.

🛠️ Suggested fix
          <div
            className="fixed inset-0 z-40 bg-black/50 backdrop-blur-xs px-4 py-4"
-           onClick={() => setIsConfirmOpen(false)}
+           onClick={() => !isSubmitting && setIsConfirmOpen(false)}
          />
                <button
                  type="button"
-                 onClick={() => setIsConfirmOpen(false)}
+                 onClick={() => setIsConfirmOpen(false)}
+                 disabled={isSubmitting}
                  className={[
                    "flex-1 h-[44px] rounded-[8px]",
                    "border border-Subbrown-4",
                    "text-Gray-7 body_1_2",
-                   "cursor-pointer hover:brightness-90 active:brightness-90",
+                   "cursor-pointer hover:brightness-90 active:brightness-90",
+                   isSubmitting ? "opacity-60 cursor-not-allowed" : "",
                  ].join(" ")}
                >

Also applies to: 193-204

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx`
around lines 171 - 174, Prevent the modal from being closed while submission is
in progress by guarding any close handlers with the isSubmitting flag: update
the backdrop onClick (currently calling setIsConfirmOpen(false)) and the "아니오"
button click handler to be no-ops when isSubmitting is true, and also render the
"아니오" button with disabled state when isSubmitting to prevent interaction; keep
the onSubmit/router.back flow unchanged but ensure setIsConfirmOpen(false)
cannot be called during submission so router.back() cannot run after the modal
was closed prematurely.
src/app/(admin)/admin/(app)/groups/[id]/members/page.tsx (1)

206-211: ⚠️ Potential issue | 🟡 Minor

URL-encode the nickname to prevent routing issues with special characters.

Nicknames may contain special characters that break URL parsing in Next.js dynamic routes. The codebase consistently uses encodeURIComponent for nickname URLs in API calls and public routes; apply the same pattern here for consistency and correctness.

Proposed fix
                      <Link
-                        href={`/admin/users/${u.nickname}`}
+                        href={`/admin/users/${encodeURIComponent(u.nickname)}`}
                        className="text-Gray-7 underline underline-offset-2 hover:opacity-70"
                      >
                        상세보기
                      </Link>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/groups/[id]/members/page.tsx around lines 206 -
211, The Link to the user detail page uses raw u.nickname which can break
Next.js dynamic routes for nicknames with special characters; update the Link
href generation in page.tsx (the Link JSX where u.nickname is used) to wrap the
nickname with encodeURIComponent (e.g., use encodeURIComponent(u.nickname)) so
the URL is properly escaped and consistent with other API/public route usages.
🧹 Nitpick comments (11)
src/app/(admin)/admin/(app)/stories/page.tsx (1)

58-60: Remove redundant useMemo.

Since filtering is now server-side, pageItems simply returns stories unchanged. This useMemo is no longer necessary.

♻️ Suggested simplification
-  const pageItems = useMemo(() => {
-    return stories;
-  }, [stories]);

Then use stories directly instead of pageItems at line 137.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/stories/page.tsx around lines 58 - 60, Remove
the redundant useMemo that defines pageItems; replace its usage with the stories
variable directly. Specifically, delete the const pageItems = useMemo(() => {
return stories; }, [stories]); declaration and update any references to
pageItems (e.g., at the place currently using pageItems) to use stories instead
so the component uses the server-side filtered data directly.
src/app/(admin)/admin/(app)/news/page.tsx (1)

74-76: Remove redundant useMemo.

Since filtering is now done server-side, filtered simply returns newsList unchanged. This useMemo adds unnecessary overhead without providing any benefit.

♻️ Suggested simplification
-  const filtered = useMemo(() => {
-    return newsList;
-  }, [newsList]);

Then replace usages of filtered with newsList directly (e.g., at lines 159 and 184).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/news/page.tsx around lines 74 - 76, The useMemo
wrapper around filtered is redundant because filtering is server-side; remove
the const filtered = useMemo(...) declaration (the useMemo call and the filtered
variable) and replace all usages of filtered with newsList (e.g., where filtered
is referenced at the render points). Ensure imports remain correct and no other
logic depended on filtered before deleting it.
src/app/(admin)/admin/(app)/news/[id]/edit/page.tsx (1)

165-199: Consider adding a confirmation before auto-deleting expired news.

When a user opens the edit form and submits, the news is silently deleted if the end date has passed. This could be surprising if the user intended to extend the date range but accidentally submitted without changing the dates first.

Consider either:

  1. Showing a confirmation dialog before deletion
  2. Validating the date range before submission and prompting the user to update it
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/news/[id]/edit/page.tsx around lines 165 - 199,
Current logic in the submit handler compares parsed.publishEndAt (end) to today
and calls deleteAdminNews(newsId) silently; instead, add an explicit user
confirmation or validation step: before calling deleteAdminNews in the block
that checks if (end < today), invoke a confirmation modal/dialog (or show a
validation error) and only proceed with deleteAdminNews(newsId) and the
subsequent router.push/router.refresh calls if the user confirms (or after the
user updates parsed.publishEndAt), ensuring you still call
setSubmitting(true/false) appropriately and handle deleteAdminNews response
errors as currently implemented.
src/lib/api/admin/news.ts (1)

251-267: Consider adding type annotation to deleteAdminNews.

The function returns the untyped data from res.json(). For consistency with other API functions and better type safety, consider defining and using a response type.

♻️ Suggested type annotation
+export type DeleteAdminNewsResponse = ApiResponse<number>;
+
-export async function deleteAdminNews(newsId: number) {
+export async function deleteAdminNews(newsId: number): Promise<DeleteAdminNewsResponse> {
   const res = await fetch(
     ADMIN_NEWS_ENDPOINTS.DELETE_ADMIN_NEWS(newsId),
     {
       method: "DELETE",
       credentials: "include",
     }
   );

-  const data = await res.json();
+  const data: DeleteAdminNewsResponse = await res.json();

   if (!res.ok || !data.isSuccess) {
     throw new Error(data.message || "소식 삭제 실패");
   }

   return data;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/api/admin/news.ts` around lines 251 - 267, The deleteAdminNews
function returns untyped JSON from res.json(); add a concrete response type
(e.g., AdminNewsDeleteResponse) and update the function signature to return
Promise<AdminNewsDeleteResponse> to match other API helpers; cast/annotate the
parsed data (const data = await res.json() as AdminNewsDeleteResponse), keep the
same error check using data.isSuccess and data.message, and either define/export
the AdminNewsDeleteResponse type near other API types or import it where
deleteAdminNews is declared; reference deleteAdminNews and
ADMIN_NEWS_ENDPOINTS.DELETE_ADMIN_NEWS when locating the change.
src/components/base-ui/Admin/users/MeetingList.tsx (1)

28-29: Consider removing console.error before merging.

The PR checklist mentions removing console logs. If this is intentional for debugging, consider using a proper logging utility or removing it for production.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Admin/users/MeetingList.tsx` around lines 28 - 29, The
catch block in MeetingList.tsx currently calls console.error("가입 모임 조회 실패:",
error); remove this console.error before merging and either delete the line or
replace it with the project logging utility (e.g., processLogger.error or
useErrorLogger) so errors are recorded via the app's logger; update the catch in
the same try/catch that surrounds the meeting fetch logic to log through the
centralized logger or silently handle the error as per project conventions.
src/hooks/mutations/useMeetingMutations.ts (1)

18-39: Redundant prefetch after invalidation.

invalidateQueries already triggers an automatic refetch for any active queries with matching keys. Calling prefetchQuery immediately after doubles the network requests for the same data.

If the goal is to ensure fresh data is available immediately, use only invalidateQueries (for mounted components) or fetchQuery to await the result. If you need to warm the cache for unmounted components, skip the invalidation and use only prefetchQuery.

♻️ Simplified approach using only invalidation
     onSuccess: async (_, vars) => {
       const detailKey = meetingQueryKeys.detail(vars.clubId, vars.meetingId);
       const membersKey = meetingQueryKeys.members(vars.clubId, vars.meetingId);

       await Promise.all([
         qc.invalidateQueries({ queryKey: detailKey }),
         qc.invalidateQueries({ queryKey: membersKey }),
-
-        qc.prefetchQuery({
-          queryKey: detailKey,
-          queryFn: () =>
-            meetingService.getMeetingDetail({
-              clubId: vars.clubId,
-              meetingId: vars.meetingId,
-            }),
-        }),
-
-        qc.prefetchQuery({
-          queryKey: membersKey,
-          queryFn: () =>
-            meetingService.getMeetingMembers({
-              clubId: vars.clubId,
-              meetingId: vars.meetingId,
-            }),
-        }),
       ]);
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/mutations/useMeetingMutations.ts` around lines 18 - 39, The code is
redundantly calling qc.prefetchQuery immediately after qc.invalidateQueries for
detailKey and membersKey, causing duplicate network requests; either remove the
prefetchQuery calls and keep the two qc.invalidateQueries (to let active
components refetch), or replace both invalidateQueries+prefetchQuery sequences
with qc.fetchQuery(...) using the same queryFn (meetingService.getMeetingDetail
/ meetingService.getMeetingMembers with vars.clubId and vars.meetingId) if you
need to await fresh data, or alternatively skip invalidateQueries and keep only
prefetchQuery when you only want to warm cache for unmounted components; update
the code around qc.invalidateQueries, qc.prefetchQuery, detailKey, membersKey,
and the queryFns that call meetingService.getMeetingDetail /
meetingService.getMeetingMembers accordingly.
src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx (1)

28-71: Consider adding keyboard accessibility for the modal.

The modal lacks focus trapping and ESC key handling, which are important for keyboard navigation and screen reader users. Users should be able to close the modal with the Escape key.

♻️ Suggested enhancement for ESC key handling
 export default function ChatTeamSelectModal({
   isOpen,
   teams,
   onClose,
   onSelectTeam,
   title = "채팅 조 선택",
 }: Props) {
+  React.useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === "Escape") onClose();
+    };
+    if (isOpen) {
+      document.addEventListener("keydown", handleKeyDown);
+      return () => document.removeEventListener("keydown", handleKeyDown);
+    }
+  }, [isOpen, onClose]);
+
   if (!isOpen) return null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx` around lines 28 -
71, Add keyboard accessibility to ChatTeamSelectModal by implementing
ESC-to-close and focus trapping: in the ChatTeamSelectModal component, add a
useEffect that adds a keydown listener which calls the existing onClose when
event.key === 'Escape' and cleans up on unmount; also set initial focus to a
sensible element (e.g., the close button or first team button) when the modal
opens and implement simple tab-trap behavior inside the modal container (or
integrate a tested utility like focus-trap or focus-trap-react) so Tab/Shift+Tab
loop through modal focusable elements; ensure the listener and focus logic
reference the modal container ref and the onClose prop (and are removed on
unmount) to avoid memory leaks.
src/lib/api/admin/endpoints/member.ts (1)

12-15: Minor: Inconsistent indentation for template string expressions.

Lines 13 and 15 use 2-space indentation for the template string while lines 6, 8, and 11 use 4-space indentation. Consider aligning for consistency.

🧹 Consistent indentation
   GET_MEMBER_BOOK_STORIES: (nickname: string) =>
-  `${API_BASE_URL}/admin/book-stories/members/${nickname}`,
+    `${API_BASE_URL}/admin/book-stories/members/${nickname}`,
   GET_MEMBER_NEWS: (nickname: string) =>
-  `${API_BASE_URL}/admin/news/members/${nickname}`,
+    `${API_BASE_URL}/admin/news/members/${nickname}`,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/api/admin/endpoints/member.ts` around lines 12 - 15, The template
string indentation for the constants GET_MEMBER_BOOK_STORIES and GET_MEMBER_NEWS
is inconsistent with the rest of the file; update those lines so the template
strings align with the 4-space indentation style used elsewhere (e.g., match the
indentation of other entries that reference API_BASE_URL) by adjusting the
leading spaces before the template literal expressions for
GET_MEMBER_BOOK_STORIES and GET_MEMBER_NEWS to be consistent.
src/components/base-ui/Admin/users/NewsList.tsx (1)

28-31: Minor: error variable in catch block shadows state variable name.

The catch parameter error shadows the component's error state variable. Consider renaming to err or e for clarity.

🧹 Rename catch parameter
-      } catch (error) {
-        console.error("회원 등록 소식 조회 실패:", error);
+      } catch (err) {
+        console.error("회원 등록 소식 조회 실패:", err);
         setPosts([]);
         setError(true);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Admin/users/NewsList.tsx` around lines 28 - 31, The
catch block in NewsList.tsx uses a parameter named error which shadows the
component state variable error; rename the catch parameter to a different
identifier (e.g., err or e) in the try/catch surrounding the fetch so the call
to console.error and subsequent setPosts([]) and setError(true) remain
unambiguous—update the catch signature and any uses inside that catch
(console.error("회원 등록 소식 조회 실패:", err)) while leaving setPosts and setError
unchanged.
src/services/meetingChatService.ts (1)

19-23: Simplify: the ?? null coalescing is redundant.

Per the apiClient implementation (lines 28-31 in src/lib/api/client.ts), both null and undefined values are filtered out from query params. The expression cursorId ?? null converts undefined to null, but both are excluded anyway.

♻️ Suggested simplification
       {
         params: {
-          cursorId: cursorId ?? null,
+          cursorId,
         },
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/meetingChatService.ts` around lines 19 - 23, The params object
sets cursorId using the redundant coalescing expression "cursorId ?? null";
remove the "?? null" and pass cursorId directly (i.e., use cursorId in the
params mapping) so the apiClient's built-in filtering can remove falsy/undefined
values; update the params block within the function/method that constructs the
request in meetingChatService (the params: { cursorId: ... } entry).
src/hooks/queries/useMeetingTopicQueries.ts (1)

5-8: Consider exporting query keys for mutation cache invalidation.

Per the context in useMeetingMutations.ts, meeting team mutations don't currently invalidate topic queries. If topics are team-specific, mutations that change team assignments may leave topic data stale. Ensure related mutations invalidate meetingTopicQueryKeys.list when appropriate.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/queries/useMeetingTopicQueries.ts` around lines 5 - 8, The
meetingTopicQueryKeys.list query key isn't being invalidated by meeting team
mutations; import meetingTopicQueryKeys into useMeetingMutations.ts and, in the
onSuccess handlers of mutations that change team assignments (e.g., functions
that move or update a team's meeting assignment), call
queryClient.invalidateQueries(...) using meetingTopicQueryKeys.list(...) for the
affected clubId, meetingId and teamId (or use a predicate/wildcard invalidation
for all teamIds when multiple teams may be affected) so topic caches are
refreshed after those mutations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app/`(admin)/admin/(app)/users/[id]/page.tsx:
- Around line 24-46: The page currently reuses previous member/tab state when
params.id changes, causing old profile and member-scoped lists to flash; update
the Page component to key the member-detail subtree by the route param (id) or
reset state when id changes: wrap the rendered member-specific UI (the subtree
that consumes activeTab, member, and any child components that call
fetchAdminMemberDetail or use cursors) in an element with key={id} or clear
member/activeTab state inside the useEffect when id changes so the UI fully
remounts and state is reset while loadMemberDetail fetches the new data.

In `@src/app/`(admin)/admin/(app)/users/page.tsx:
- Around line 128-135: The Link currently interpolates u.nickname directly into
the href which can break routing for nicknames with spaces or special
characters; update the Link href generation (the JSX using Link and u.nickname)
to URL-encode the nickname (e.g., via encodeURIComponent) before inserting it
into the path so the route becomes safe for all characters, and ensure any
corresponding server/router decoding expects the encoded value.

In `@src/app/groups/`[id]/bookcase/[bookId]/meeting/page.tsx:
- Around line 77-78: The initial chatPosition state is hardcoded and can render
the 426px panel off-screen; update the initialization and lifecycle to compute
and clamp the default based on the current viewport and re-clamp on resize/open.
Replace the static useState({ x: 980, y: 56 }) for chatPosition by computing a
clamped default using window.innerWidth/innerHeight (and the panel width 426 and
any margins) before first render, and add an effect that listens for window
resize and for the panel open action to recompute and setChatPosition with the
same clamping logic; ensure you reuse the existing clamp/drag calculations used
around the drag handlers (see the drag clamp logic in the code near lines
265-285) so the same constraints apply.
- Around line 110-125: The computed myTeamId currently determines team-scoped
access by matching user.nickname inside meetingData.teamMembers, which is
unsafe; change the implementation of myTeamId (and any logic that consumes it
such as chatSelectableTeams and canSelectCurrentTeamTopic) to stop using
nickname as the identity key: instead, read and use a backend-provided stable
member identifier (e.g., clubMemberId) from meetingData /
group.members.memberInfo when available; until that field is present, return
null (or a clearly non-authorized value) so team-scoped selection is disabled
rather than guessed by nickname, and add a TODO comment to replace the
null-guard with the clubMemberId-based lookup once the backend includes it.

In `@src/components/base-ui/Admin/users/MeetingList.tsx`:
- Around line 20-38: The current useEffect + loadClubs can race when
memberNickname changes; update loadClubs to support cancellation (use an
AbortController for fetchAdminMemberClubs or a local "mounted" boolean) and only
call setClubs/setError/setLoading if the request was not aborted and matches the
latest memberNickname. Specifically, inside the useEffect create an
AbortController (or let isMounted = true), pass its signal to
fetchAdminMemberClubs (or check isMounted) and in the cleanup return abort() (or
set isMounted = false) so stale responses from loadClubs do not overwrite state
in setClubs/setError/setLoading. Ensure identifiers referenced: useEffect,
loadClubs, fetchAdminMemberClubs, setClubs, setLoading, setError.

In `@src/components/base-ui/Admin/users/ReportList.tsx`:
- Around line 58-64: The prop name reporterName is misleading because it
receives reportedMemberNickname (the member being reported); update the prop to
reportedMemberName across the call site and component definition: change the
ReportList usage to pass reportedMemberName={report.reportedMemberNickname} and
update the ReportItem component prop/interface (and any places that consume the
prop) to expect reportedMemberName instead of reporterName so the prop
accurately reflects AdminMemberReportItem data; if other components/types
reference reporterName, rename them or add a clear alias to maintain
consistency.

In `@src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx`:
- Around line 95-108: The catch block in handleConfirmYes currently only logs
errors from onSubmit() so users get no feedback; update handleConfirmYes to
display a user-facing error (e.g. call your toast utility or set an inline error
state) when onSubmit() throws, ensure you do NOT call router.back() on failure,
still reset setIsSubmitting(false) in finally, and close the confirm modal only
on success (i.e., move setIsConfirmOpen(false) and router.back() inside the try
after await onSubmit()) so failures surface to the user.

In `@src/components/base-ui/Bookcase/MeetingChatModal.tsx`:
- Around line 95-149: When loading older pages via chatsQuery.fetchNextPage(),
capture the scroll container's scrollHeight before initiating the fetch and
after the new page merges into messages, then add the height delta back to
scrollTop so the viewport stays anchored. Concretely, in the effect that creates
the IntersectionObserver (the block referencing topSentinelRef, scrollRef, and
chatsQuery.fetchNextPage), wrap the fetchNextPage trigger to record const
prevHeight = scrollRef.current?.scrollHeight, call chatsQuery.fetchNextPage(),
and then on the query's settled/update (or by watching chatsQuery.data/messages
change) compute delta = scrollRef.current.scrollHeight - prevHeight and do
scrollRef.current.scrollTop += delta (skip if autoScrolledRef.current is false
or if no change). Ensure this adjustment runs only when older pages are appended
(not on initial open), and keep existing guards involving isOpen,
autoScrolledRef, and selectedTeamId.
- Around line 153-168: The modal panel rendered by MeetingChatModal lacks proper
dialog semantics and focus management; add role="dialog" and aria-modal="true"
to the panel element, give it an accessible label (aria-labelledby or
aria-label), and implement focus handling in the MeetingChatModal component:
create a ref for the panel, on open move focus into the panel (e.g., to the
first focusable element or the panel itself), trap keyboard focus while open
(tab/shift+tab) and close/restore focus on Escape or when the modal unmounts by
saving the previously focused element and returning focus to it. Use the
existing panel element (the div using classes and style with
position/position.x/position.y and isTabletUp) as the target for these changes
and add the effect hooks to manage focus lifecycle.

---

Outside diff comments:
In `@src/app/`(admin)/admin/(app)/groups/[id]/members/page.tsx:
- Around line 206-211: The Link to the user detail page uses raw u.nickname
which can break Next.js dynamic routes for nicknames with special characters;
update the Link href generation in page.tsx (the Link JSX where u.nickname is
used) to wrap the nickname with encodeURIComponent (e.g., use
encodeURIComponent(u.nickname)) so the URL is properly escaped and consistent
with other API/public route usages.

In `@src/app/groups/`[id]/admin/bookcase/[meetingId]/page.tsx:
- Around line 43-76: The isInitialized flag is declared but never updated, so
showLoading (used where showLoading = isLoading || (!isInitialized &&
isFetching)) always treats isInitialized as false; either remove isInitialized
and the (!isInitialized && isFetching) clause, or set it to true after the first
successful data load inside the existing useEffect: call setIsInitialized(true)
once when data is present (use the same useEffect that maps
existingTeamNumbers/mappedMembers) so subsequent background refetches won't
trigger the initial-loading UI; references: isInitialized, setIsInitialized,
showLoading, useEffect, data.

In `@src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx`:
- Around line 171-174: Prevent the modal from being closed while submission is
in progress by guarding any close handlers with the isSubmitting flag: update
the backdrop onClick (currently calling setIsConfirmOpen(false)) and the "아니오"
button click handler to be no-ops when isSubmitting is true, and also render the
"아니오" button with disabled state when isSubmitting to prevent interaction; keep
the onSubmit/router.back flow unchanged but ensure setIsConfirmOpen(false)
cannot be called during submission so router.back() cannot run after the modal
was closed prematurely.

---

Nitpick comments:
In `@src/app/`(admin)/admin/(app)/news/[id]/edit/page.tsx:
- Around line 165-199: Current logic in the submit handler compares
parsed.publishEndAt (end) to today and calls deleteAdminNews(newsId) silently;
instead, add an explicit user confirmation or validation step: before calling
deleteAdminNews in the block that checks if (end < today), invoke a confirmation
modal/dialog (or show a validation error) and only proceed with
deleteAdminNews(newsId) and the subsequent router.push/router.refresh calls if
the user confirms (or after the user updates parsed.publishEndAt), ensuring you
still call setSubmitting(true/false) appropriately and handle deleteAdminNews
response errors as currently implemented.

In `@src/app/`(admin)/admin/(app)/news/page.tsx:
- Around line 74-76: The useMemo wrapper around filtered is redundant because
filtering is server-side; remove the const filtered = useMemo(...) declaration
(the useMemo call and the filtered variable) and replace all usages of filtered
with newsList (e.g., where filtered is referenced at the render points). Ensure
imports remain correct and no other logic depended on filtered before deleting
it.

In `@src/app/`(admin)/admin/(app)/stories/page.tsx:
- Around line 58-60: Remove the redundant useMemo that defines pageItems;
replace its usage with the stories variable directly. Specifically, delete the
const pageItems = useMemo(() => { return stories; }, [stories]); declaration and
update any references to pageItems (e.g., at the place currently using
pageItems) to use stories instead so the component uses the server-side filtered
data directly.

In `@src/components/base-ui/Admin/users/MeetingList.tsx`:
- Around line 28-29: The catch block in MeetingList.tsx currently calls
console.error("가입 모임 조회 실패:", error); remove this console.error before merging
and either delete the line or replace it with the project logging utility (e.g.,
processLogger.error or useErrorLogger) so errors are recorded via the app's
logger; update the catch in the same try/catch that surrounds the meeting fetch
logic to log through the centralized logger or silently handle the error as per
project conventions.

In `@src/components/base-ui/Admin/users/NewsList.tsx`:
- Around line 28-31: The catch block in NewsList.tsx uses a parameter named
error which shadows the component state variable error; rename the catch
parameter to a different identifier (e.g., err or e) in the try/catch
surrounding the fetch so the call to console.error and subsequent setPosts([])
and setError(true) remain unambiguous—update the catch signature and any uses
inside that catch (console.error("회원 등록 소식 조회 실패:", err)) while leaving setPosts
and setError unchanged.

In `@src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx`:
- Around line 28-71: Add keyboard accessibility to ChatTeamSelectModal by
implementing ESC-to-close and focus trapping: in the ChatTeamSelectModal
component, add a useEffect that adds a keydown listener which calls the existing
onClose when event.key === 'Escape' and cleans up on unmount; also set initial
focus to a sensible element (e.g., the close button or first team button) when
the modal opens and implement simple tab-trap behavior inside the modal
container (or integrate a tested utility like focus-trap or focus-trap-react) so
Tab/Shift+Tab loop through modal focusable elements; ensure the listener and
focus logic reference the modal container ref and the onClose prop (and are
removed on unmount) to avoid memory leaks.

In `@src/hooks/mutations/useMeetingMutations.ts`:
- Around line 18-39: The code is redundantly calling qc.prefetchQuery
immediately after qc.invalidateQueries for detailKey and membersKey, causing
duplicate network requests; either remove the prefetchQuery calls and keep the
two qc.invalidateQueries (to let active components refetch), or replace both
invalidateQueries+prefetchQuery sequences with qc.fetchQuery(...) using the same
queryFn (meetingService.getMeetingDetail / meetingService.getMeetingMembers with
vars.clubId and vars.meetingId) if you need to await fresh data, or
alternatively skip invalidateQueries and keep only prefetchQuery when you only
want to warm cache for unmounted components; update the code around
qc.invalidateQueries, qc.prefetchQuery, detailKey, membersKey, and the queryFns
that call meetingService.getMeetingDetail / meetingService.getMeetingMembers
accordingly.

In `@src/hooks/queries/useMeetingTopicQueries.ts`:
- Around line 5-8: The meetingTopicQueryKeys.list query key isn't being
invalidated by meeting team mutations; import meetingTopicQueryKeys into
useMeetingMutations.ts and, in the onSuccess handlers of mutations that change
team assignments (e.g., functions that move or update a team's meeting
assignment), call queryClient.invalidateQueries(...) using
meetingTopicQueryKeys.list(...) for the affected clubId, meetingId and teamId
(or use a predicate/wildcard invalidation for all teamIds when multiple teams
may be affected) so topic caches are refreshed after those mutations.

In `@src/lib/api/admin/endpoints/member.ts`:
- Around line 12-15: The template string indentation for the constants
GET_MEMBER_BOOK_STORIES and GET_MEMBER_NEWS is inconsistent with the rest of the
file; update those lines so the template strings align with the 4-space
indentation style used elsewhere (e.g., match the indentation of other entries
that reference API_BASE_URL) by adjusting the leading spaces before the template
literal expressions for GET_MEMBER_BOOK_STORIES and GET_MEMBER_NEWS to be
consistent.

In `@src/lib/api/admin/news.ts`:
- Around line 251-267: The deleteAdminNews function returns untyped JSON from
res.json(); add a concrete response type (e.g., AdminNewsDeleteResponse) and
update the function signature to return Promise<AdminNewsDeleteResponse> to
match other API helpers; cast/annotate the parsed data (const data = await
res.json() as AdminNewsDeleteResponse), keep the same error check using
data.isSuccess and data.message, and either define/export the
AdminNewsDeleteResponse type near other API types or import it where
deleteAdminNews is declared; reference deleteAdminNews and
ADMIN_NEWS_ENDPOINTS.DELETE_ADMIN_NEWS when locating the change.

In `@src/services/meetingChatService.ts`:
- Around line 19-23: The params object sets cursorId using the redundant
coalescing expression "cursorId ?? null"; remove the "?? null" and pass cursorId
directly (i.e., use cursorId in the params mapping) so the apiClient's built-in
filtering can remove falsy/undefined values; update the params block within the
function/method that constructs the request in meetingChatService (the params: {
cursorId: ... } entry).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 93798f14-8561-4b47-ac48-59c304be0cf3

📥 Commits

Reviewing files that changed from the base of the PR and between 406d889 and 2f33b2d.

⛔ Files ignored due to path filters (1)
  • public/placeholder.png is excluded by !**/*.png
📒 Files selected for processing (35)
  • src/app/(admin)/admin/(app)/groups/[id]/members/page.tsx
  • src/app/(admin)/admin/(app)/news/[id]/edit/page.tsx
  • src/app/(admin)/admin/(app)/news/page.tsx
  • src/app/(admin)/admin/(app)/stories/page.tsx
  • src/app/(admin)/admin/(app)/users/[id]/page.tsx
  • src/app/(admin)/admin/(app)/users/page.tsx
  • src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx
  • src/app/groups/[id]/bookcase/[bookId]/DebateSection.tsx
  • src/app/groups/[id]/bookcase/[bookId]/ReviewSection.tsx
  • src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx
  • src/app/groups/[id]/bookcase/[bookId]/page.tsx
  • src/components/base-ui/Admin/users/BookStoryList.tsx
  • src/components/base-ui/Admin/users/MeetingList.tsx
  • src/components/base-ui/Admin/users/NewsList.tsx
  • src/components/base-ui/Admin/users/ReportList.tsx
  • src/components/base-ui/Admin/users/items/AdminNewsItem.tsx
  • src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx
  • src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx
  • src/components/base-ui/Bookcase/MeetingChatModal.tsx
  • src/components/base-ui/Bookcase/MeetingTeamMemberPopover.tsx
  • src/hooks/mutations/useMeetingMutations.ts
  • src/hooks/queries/useMeetingChatQueries.ts
  • src/hooks/queries/useMeetingTopicQueries.ts
  • src/lib/api/admin/endpoints/member.ts
  • src/lib/api/admin/endpoints/news.ts
  • src/lib/api/admin/endpoints/stories.ts
  • src/lib/api/admin/member.ts
  • src/lib/api/admin/news.ts
  • src/lib/api/admin/stories.ts
  • src/lib/api/endpoints/Clubs.ts
  • src/services/meetingChatService.ts
  • src/services/meetingTopicService.ts
  • src/types/groups/meetingChats.ts
  • src/types/groups/meetingDetail.ts
  • src/types/groups/meetingTopics.ts

Comment on lines +24 to +46
export default function Page({ params }: PageProps) {
const { id } = React.use(params);

export default function Page() {
const [activeTab, setActiveTab] = useState<AdminUserTabId>("meetings");
const [member, setMember] = useState<AdminMemberDetailResult | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
const loadMemberDetail = async () => {
try {
setLoading(true);
const res = await fetchAdminMemberDetail(id);
setMember(res.result);
} catch (error) {
console.error("회원 상세 조회 실패:", error);
setMember(null);
} finally {
setLoading(false);
}
};

loadMemberDetail();
}, [id]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Key the member-detail subtree by the route param.

Without a key tied to id, this client page can reuse the previous member and tab-panel state until the next fetch settles. That lets the old profile flash under the new URL and lets member-scoped lists keep their old cursor/data. Split the fetched body into a child keyed by id, or otherwise force a full reset before rendering the new member.

Also applies to: 95-104

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/users/[id]/page.tsx around lines 24 - 46, The
page currently reuses previous member/tab state when params.id changes, causing
old profile and member-scoped lists to flash; update the Page component to key
the member-detail subtree by the route param (id) or reset state when id
changes: wrap the rendered member-specific UI (the subtree that consumes
activeTab, member, and any child components that call fetchAdminMemberDetail or
use cursors) in an element with key={id} or clear member/activeTab state inside
the useEffect when id changes so the UI fully remounts and state is reset while
loadMemberDetail fetches the new data.

Comment on lines +128 to +135
<td className="pl-[12px] py-0">
<Link
href={`/admin/users/${u.nickname}`}
className="body_1_2 text-Gray-7 underline underline-offset-2 hover:opacity-70"
>
상세보기
</Link>
</td>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Encode nickname in URL to handle special characters.

The nickname is used directly in the URL path. If nicknames can contain spaces, slashes, or other special characters, this could break routing or cause unexpected behavior.

🔧 Proposed fix
                     <Link
-                        href={`/admin/users/${u.nickname}`}
+                        href={`/admin/users/${encodeURIComponent(u.nickname)}`}
                         className="body_1_2 text-Gray-7 underline underline-offset-2 hover:opacity-70"
                       >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<td className="pl-[12px] py-0">
<Link
href={`/admin/users/${u.nickname}`}
className="body_1_2 text-Gray-7 underline underline-offset-2 hover:opacity-70"
>
상세보기
</Link>
</td>
<td className="pl-[12px] py-0">
<Link
href={`/admin/users/${encodeURIComponent(u.nickname)}`}
className="body_1_2 text-Gray-7 underline underline-offset-2 hover:opacity-70"
>
상세보기
</Link>
</td>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/users/page.tsx around lines 128 - 135, The Link
currently interpolates u.nickname directly into the href which can break routing
for nicknames with spaces or special characters; update the Link href generation
(the JSX using Link and u.nickname) to URL-encode the nickname (e.g., via
encodeURIComponent) before inserting it into the path so the route becomes safe
for all characters, and ensure any corresponding server/router decoding expects
the encoded value.

Comment on lines +77 to 78
const [chatPosition, setChatPosition] = useState({ x: 980, y: 56 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Clamp the initial chat position before the first render.

The drag logic clamps the panel only after the user moves it, but the initial state starts at { x: 980, y: 56 }. On common desktop widths that already renders the 426px panel off-screen. Compute the default from the current viewport and re-clamp on resize/open.

Also applies to: 265-285

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/bookcase/[bookId]/meeting/page.tsx around lines 77 - 78,
The initial chatPosition state is hardcoded and can render the 426px panel
off-screen; update the initialization and lifecycle to compute and clamp the
default based on the current viewport and re-clamp on resize/open. Replace the
static useState({ x: 980, y: 56 }) for chatPosition by computing a clamped
default using window.innerWidth/innerHeight (and the panel width 426 and any
margins) before first render, and add an effect that listens for window resize
and for the panel open action to recompute and setChatPosition with the same
clamping logic; ensure you reuse the existing clamp/drag calculations used
around the drag handlers (see the drag clamp logic in the code near lines
265-285) so the same constraints apply.

Comment on lines +110 to +125
const myTeamId = useMemo(() => {
// TODO:
// 현재 응답에는 "내 clubMemberId"가 없어서 nickname 기반 임시 판별만 가능.
// 추후 BE가 내 clubMemberId를 내려주면 그 값 기준으로 교체할 것.
const myNickname = user?.nickname?.trim();
if (!myNickname || !meetingData) return null;

for (const group of meetingData.teamMembers ?? []) {
const matched = group.members.some(
(member) => member.memberInfo.nickname === myNickname
);
if (matched) return group.teamKey.teamId;
}

return null;
}, [meetingData, user?.nickname]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't ship nickname-based team identity as the access gate.

nickname is mutable/display data, not a stable identity key. If two members share a nickname or someone renames, myTeamId can point at the wrong team, which then drives chatSelectableTeams and canSelectCurrentTeamTopic. This needs a backend-provided member/team identifier before it is used for team-scoped access.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/bookcase/[bookId]/meeting/page.tsx around lines 110 -
125, The computed myTeamId currently determines team-scoped access by matching
user.nickname inside meetingData.teamMembers, which is unsafe; change the
implementation of myTeamId (and any logic that consumes it such as
chatSelectableTeams and canSelectCurrentTeamTopic) to stop using nickname as the
identity key: instead, read and use a backend-provided stable member identifier
(e.g., clubMemberId) from meetingData / group.members.memberInfo when available;
until that field is present, return null (or a clearly non-authorized value) so
team-scoped selection is disabled rather than guessed by nickname, and add a
TODO comment to replace the null-guard with the clubMemberId-based lookup once
the backend includes it.

Comment on lines +20 to +38
useEffect(() => {
const loadClubs = async () => {
try {
setLoading(true);
setError(false);

if (isLoading) {
const res = await fetchAdminMemberClubs(memberNickname);
setClubs(res.result.clubList ?? []);
} catch (error) {
console.error("가입 모임 조회 실패:", error);
setClubs([]);
setError(true);
} finally {
setLoading(false);
}
};

loadClubs();
}, [memberNickname]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Race condition when memberNickname changes rapidly.

If memberNickname changes before the fetch completes, stale data from the previous request could overwrite fresh data. Add a cleanup flag or use AbortController.

🛡️ Proposed fix with cleanup flag
   useEffect(() => {
+    let cancelled = false;
+
     const loadClubs = async () => {
       try {
         setLoading(true);
         setError(false);

         const res = await fetchAdminMemberClubs(memberNickname);
+        if (cancelled) return;
         setClubs(res.result.clubList ?? []);
       } catch (error) {
+        if (cancelled) return;
         console.error("가입 모임 조회 실패:", error);
         setClubs([]);
         setError(true);
       } finally {
-        setLoading(false);
+        if (!cancelled) setLoading(false);
       }
     };

     loadClubs();
+
+    return () => {
+      cancelled = true;
+    };
   }, [memberNickname]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
const loadClubs = async () => {
try {
setLoading(true);
setError(false);
if (isLoading) {
const res = await fetchAdminMemberClubs(memberNickname);
setClubs(res.result.clubList ?? []);
} catch (error) {
console.error("가입 모임 조회 실패:", error);
setClubs([]);
setError(true);
} finally {
setLoading(false);
}
};
loadClubs();
}, [memberNickname]);
useEffect(() => {
let cancelled = false;
const loadClubs = async () => {
try {
setLoading(true);
setError(false);
const res = await fetchAdminMemberClubs(memberNickname);
if (cancelled) return;
setClubs(res.result.clubList ?? []);
} catch (error) {
if (cancelled) return;
console.error("가입 모임 조회 실패:", error);
setClubs([]);
setError(true);
} finally {
if (!cancelled) setLoading(false);
}
};
loadClubs();
return () => {
cancelled = true;
};
}, [memberNickname]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Admin/users/MeetingList.tsx` around lines 20 - 38, The
current useEffect + loadClubs can race when memberNickname changes; update
loadClubs to support cancellation (use an AbortController for
fetchAdminMemberClubs or a local "mounted" boolean) and only call
setClubs/setError/setLoading if the request was not aborted and matches the
latest memberNickname. Specifically, inside the useEffect create an
AbortController (or let isMounted = true), pass its signal to
fetchAdminMemberClubs (or check isMounted) and in the cleanup return abort() (or
set isMounted = false) so stale responses from loadClubs do not overwrite state
in setClubs/setError/setLoading. Ensure identifiers referenced: useEffect,
loadClubs, fetchAdminMemberClubs, setClubs, setLoading, setError.

Comment on lines +58 to +64
<ReportItem
key={report.reportId}
category={report.reportType}
reporterName={report.reportedMemberNickname}
content={report.content}
date={new Date(report.createdAt).toLocaleDateString()}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for reporterName usage in AdminReportItem to understand expected semantics
ast-grep --pattern $'reporterName'
# Also check the API type definition for reportedMemberNickname
rg -n -C3 'reportedMemberNickname' --type=ts

Repository: checkmo2025/FE

Length of output: 8165


🏁 Script executed:

cat -n src/components/base-ui/Admin/users/items/AdminReportItem.tsx | head -60

Repository: checkmo2025/FE

Length of output: 2067


Fix misleading prop name: reporterName should reflect the reported member, not the reporter.

The reporterName prop receives reportedMemberNickname, which represents the member being reported, not the person filing the report. The backend type AdminMemberReportItem contains no reporter identity field—only the reported member. Either rename the prop to reportedMemberName for clarity, or verify if the UI intends to display the actual reporter (which would require backend changes to track reporter identity).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Admin/users/ReportList.tsx` around lines 58 - 64, The
prop name reporterName is misleading because it receives reportedMemberNickname
(the member being reported); update the prop to reportedMemberName across the
call site and component definition: change the ReportList usage to pass
reportedMemberName={report.reportedMemberNickname} and update the ReportItem
component prop/interface (and any places that consume the prop) to expect
reportedMemberName instead of reporterName so the prop accurately reflects
AdminMemberReportItem data; if other components/types reference reporterName,
rename them or add a clear alias to maintain consistency.

Comment on lines +95 to 108
const handleConfirmYes = async () => {
if (isSubmitting) return;

try {
setIsSubmitting(true);
await onSubmit();
setIsConfirmOpen(false);
router.back();
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Silent error handling provides no user feedback on failure.

When onSubmit() throws, the error is caught and logged, but the user sees no indication that the save failed. The button simply reverts from "저장 중..." back to "예" with no explanation. This can leave users confused about whether their changes were saved.

Consider displaying an error toast or inline message to inform the user of the failure.

💡 Suggested approach
+ import { toast } from "your-toast-library"; // or use existing notification system

  const handleConfirmYes = async () => {
    if (isSubmitting) return;

    try {
      setIsSubmitting(true);
      await onSubmit();
      setIsConfirmOpen(false);
      router.back();
    } catch (error) {
      console.error(error);
+     toast.error("저장에 실패했습니다. 다시 시도해 주세요.");
    } finally {
      setIsSubmitting(false);
    }
  };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx`
around lines 95 - 108, The catch block in handleConfirmYes currently only logs
errors from onSubmit() so users get no feedback; update handleConfirmYes to
display a user-facing error (e.g. call your toast utility or set an inline error
state) when onSubmit() throws, ensure you do NOT call router.back() on failure,
still reset setIsSubmitting(false) in finally, and close the confirm modal only
on success (i.e., move setIsConfirmOpen(false) and router.back() inside the try
after await onSubmit()) so failures surface to the user.

Comment on lines +95 to +149
const messages = useMemo(() => {
const flat = chatsQuery.data?.pages.flatMap((page) => page.chats) ?? [];
return dedupeAndSortMessages(flat);
}, [chatsQuery.data]);

useEffect(() => {
if (!isOpen) {
autoScrolledRef.current = false;
return;
}

const root = scrollRef.current;
const target = topSentinelRef.current;

if (!root || !target || !chatsQuery.hasNextPage) return;

const observer = new IntersectionObserver(
([entry]) => {
if (
entry.isIntersecting &&
chatsQuery.hasNextPage &&
!chatsQuery.isFetchingNextPage
) {
chatsQuery.fetchNextPage();
}
},
{
root,
rootMargin: "120px 0px 0px 0px",
threshold: 0,
}
);

observer.observe(target);
return () => observer.disconnect();
}, [
isOpen,
chatsQuery.hasNextPage,
chatsQuery.isFetchingNextPage,
chatsQuery.fetchNextPage,
selectedTeamId,
]);

useEffect(() => {
if (!isOpen) return;
if (!scrollRef.current) return;
if (messages.length === 0) return;
if (autoScrolledRef.current) return;

requestAnimationFrame(() => {
if (!scrollRef.current) return;
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
autoScrolledRef.current = true;
});
}, [isOpen, messages.length, selectedTeamId]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keep the viewport anchored when older messages are merged in.

The list is re-sorted chronologically, so every fetchNextPage() inserts older messages above the current viewport. Because nothing restores scrollTop after messages grows, the reader gets yanked away from the message they were reading. Capture the container's scrollHeight before the fetch and add the delta back after the new page lands.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Bookcase/MeetingChatModal.tsx` around lines 95 - 149,
When loading older pages via chatsQuery.fetchNextPage(), capture the scroll
container's scrollHeight before initiating the fetch and after the new page
merges into messages, then add the height delta back to scrollTop so the
viewport stays anchored. Concretely, in the effect that creates the
IntersectionObserver (the block referencing topSentinelRef, scrollRef, and
chatsQuery.fetchNextPage), wrap the fetchNextPage trigger to record const
prevHeight = scrollRef.current?.scrollHeight, call chatsQuery.fetchNextPage(),
and then on the query's settled/update (or by watching chatsQuery.data/messages
change) compute delta = scrollRef.current.scrollHeight - prevHeight and do
scrollRef.current.scrollTop += delta (skip if autoScrolledRef.current is false
or if no change). Ensure this adjustment runs only when older pages are appended
(not on initial open), and keep existing guards involving isOpen,
autoScrolledRef, and selectedTeamId.

Comment on lines +153 to +168
return (
<div
className={[
"fixed inset-0 z-[80]",
isTabletUp ? "pointer-events-none" : "bg-black/10",
].join(" ")}
>
<div
className={[
"pointer-events-auto flex flex-col border border-Subbrown-4 bg-background shadow-[0_3px_5.1px_rgba(61,52,46,0.15)]",
isTabletUp
? "absolute w-[426px] h-[716px] rounded-[8px]"
: "h-[100dvh] w-full",
].join(" ")}
style={isTabletUp ? { left: position.x, top: position.y } : undefined}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add dialog semantics and focus handling.

This opens as a modal, but the panel never gets role="dialog"/aria-modal, focus is not moved into it, and focus is not trapped/restored. Keyboard and screen-reader users can keep interacting with the page behind the chat UI.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Bookcase/MeetingChatModal.tsx` around lines 153 - 168,
The modal panel rendered by MeetingChatModal lacks proper dialog semantics and
focus management; add role="dialog" and aria-modal="true" to the panel element,
give it an accessible label (aria-labelledby or aria-label), and implement focus
handling in the MeetingChatModal component: create a ref for the panel, on open
move focus into the panel (e.g., to the first focusable element or the panel
itself), trap keyboard focus while open (tab/shift+tab) and close/restore focus
on Escape or when the modal unmounts by saving the previously focused element
and returning focus to it. Use the existing panel element (the div using classes
and style with position/position.x/position.y and isTabletUp) as the target for
these changes and add the effect hooks to manage focus lifecycle.

@hongik-luke
hongik-luke merged commit f1ddbd7 into main Mar 22, 2026
4 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Mar 26, 2026
3 of 8 tasks
@coderabbitai coderabbitai Bot mentioned this pull request Jun 6, 2026
6 of 8 tasks
@coderabbitai coderabbitai Bot mentioned this pull request Jun 18, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Jun 27, 2026

This branch was successfully deployed

2 active deployments
Preview – check-mo — 2f33b2da Deployed Mar 22, 2026 by vercel[bot]
Preview – checkmo — 2f33b2da Deployed Mar 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants