Skip to content

관리자 모임 관리 API 최신화#224

Merged
shinwokkang merged 7 commits intomainfrom
dev
Mar 21, 2026
Merged

관리자 모임 관리 API 최신화#224
shinwokkang merged 7 commits intomainfrom
dev

Conversation

@shinwokkang
Copy link
Contributor

@shinwokkang shinwokkang commented Mar 20, 2026

📌 개요 (Summary)

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

🛠️ 변경 사항 (Changes)

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

📸 스크린샷 (Screenshots)

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

✅ 체크리스트 (Checklist)

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

Summary by CodeRabbit

  • New Features

    • Server-backed loading for admin groups and members with real pagination and search.
    • Interactive group detail editing with validation, save flow, and success/error feedback.
  • Improvements

    • Loading / error / empty-state UIs and disabled pagination controls during loading.
    • Member table: clearer columns, normalized date and role display.
    • Safer cover-image fallback and updated social login icons.

@coderabbitai
Copy link

coderabbitai bot commented Mar 20, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ae65aefc-1a22-4f10-879a-762a38aad823

📥 Commits

Reviewing files that changed from the base of the PR and between 8c6aa68 and adaf3b2.

📒 Files selected for processing (1)
  • src/components/base-ui/Settings/SocialLogin/SocialLoginCard.tsx
✅ Files skipped from review due to trivial changes (1)
  • src/components/base-ui/Settings/SocialLogin/SocialLoginCard.tsx

📝 Walkthrough

Walkthrough

Replaces client-side dummy admin group/member pages with server-driven implementations, adds typed admin clubs API and endpoint builders, updates UI components to accept structured items and toggle callbacks, and implements loading/error states, validation, and server-backed pagination and detail editing.

Changes

Cohort / File(s) Summary
API Layer
src/lib/api/admin/endpoints/clubs.ts, src/lib/api/admin/clubs.ts
Added ADMIN_CLUBS endpoint builders and typed admin clubs service functions/types: list, detail, update, and active-members. Uses fetch with credentials: "include", cache: "no-store", and unified error handling.
Admin Group Pages
src/app/(admin)/admin/(app)/groups/page.tsx, src/app/(admin)/admin/(app)/groups/[id]/page.tsx, src/app/(admin)/admin/(app)/groups/[id]/members/page.tsx
Converted listing, detail, and members pages from static/dummy server components into client components that fetch backend data, manage form state/validation, show loading/error/empty UIs, and use server-side pagination and detail APIs.
Shared UI Components
src/components/base-ui/Admin/groups/Category.tsx, src/components/base-ui/Admin/groups/Target.tsx
Component props changed from string[]{label:string, selected:boolean}[]; added optional onToggle?: (label)=>void; chips now reflect selected and call onToggle on click.
Top Section UI
src/components/base-ui/Admin/groups/TopItem.tsx
Cover image handling changed: trims coverImgSrc, conditionally renders Image only if present, otherwise shows fallback “이미지 없음”.
Social Login Icons
src/components/base-ui/Settings/SocialLogin/SocialLoginCard.tsx
Updated provider icon paths: google/googleLogo.svg, naver/naverLogo.svg.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Client as Admin Client
    participant API as Admin API
    participant DB as Database

    rect rgba(100,150,200,0.5)
    Note over User,API: Group Detail load & edit
    User->>Client: Navigate to /admin/groups/:id
    Client->>Client: useParams() & useEffect
    Client->>API: fetchAdminClubDetail(clubId)
    API->>DB: query club detail
    DB-->>API: return club detail
    API-->>Client: respond with AdminClubDetail
    Client->>Client: populate form state
    User->>Client: edit & click save
    Client->>API: updateAdminClub(clubId, payload)
    API->>DB: persist updates
    DB-->>API: confirm
    API-->>Client: success
    Client->>Client: router.refresh() / show success
    end

    rect rgba(150,200,100,0.5)
    Note over User,API: Members pagination
    User->>Client: open Members tab / change page
    Client->>API: fetchAdminClubActiveMembers(clubId, page, size)
    API->>DB: query members w/ pagination
    DB-->>API: return members + totalPages
    API-->>Client: respond with members
    Client->>Client: formatDate/mapRole & render table
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

  • 02.28 최신화 #154: Overlaps code-level changes to admin groups pages and members handling — strong relation.
  • 배포 최신화 #141: Introduced prior dummy client-side admin group/member pages that this PR replaces.

Suggested labels

✨ feat

Poem

🐰 I hopped through code to fetch each name,

No more mock lists — real members came,
Pages turn, forms trim and save,
APIs hum under data's wave,
— a small rabbit celebrates the change 🐇✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main changeset: connecting admin group management pages to real APIs, replacing dummy data with server-driven fetching.

✏️ 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.

@vercel
Copy link

vercel bot commented Mar 20, 2026

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

Project Deployment Actions Updated (UTC)
check-mo Ready Ready Preview, Comment Mar 21, 2026 7:27am
checkmo Ready Ready Preview, Comment Mar 21, 2026 7:27am

@gemini-code-assist
Copy link
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!

이 풀 리퀘스트는 관리자 페이지의 모임 관리 기능을 백엔드 API와 연동하여 실제 데이터를 기반으로 동작하도록 업데이트합니다. 기존에 사용되던 더미 데이터를 제거하고, 모임 목록 조회, 상세 정보 조회 및 수정, 그리고 모임 회원 목록 조회 기능을 API 기반으로 전환하여 관리자 기능의 실제 운영 가능성을 확보했습니다. 이를 통해 관리자는 모임 정보를 효율적으로 관리하고 최신 상태를 유지할 수 있게 됩니다.

Highlights

  • 관리자 모임 목록 API 연동: 관리자 페이지의 모임 목록이 더미 데이터 대신 실제 API를 통해 데이터를 불러오도록 업데이트되었습니다. 검색 및 페이지네이션 기능도 API 연동에 맞춰 수정되었습니다.
  • 관리자 모임 상세 및 수정 API 연동: 관리자 페이지의 모임 상세 정보 페이지가 API를 통해 데이터를 불러오고, 모임 정보를 수정(이름, 설명, 이미지, 지역, 카테고리, 대상, 링크)하고 저장할 수 있도록 API 연동 및 폼 관리 로직이 추가되었습니다.
  • 관리자 모임 회원 목록 API 연동: 특정 모임의 회원 목록을 더미 데이터 대신 실제 API를 통해 불러오도록 업데이트되었습니다. 로딩 및 에러 처리, 페이지네이션 기능이 추가되었습니다.
  • API 모듈 추가: 관리자 모임 관련 API 호출을 위한 새로운 API 모듈(src/lib/api/admin/clubs.ts)과 엔드포인트 정의(src/lib/api/admin/endpoints/clubs.ts) 파일이 추가되었습니다.
  • UI 컴포넌트 개선: Category 및 Target 컴포넌트가 선택 상태를 관리하고 토글할 수 있도록 개선되었으며, TopSection 컴포넌트는 커버 이미지가 없을 경우 대체 텍스트를 표시하도록 수정되었습니다.

🧠 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.

Copy link
Contributor

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

Choose a reason for hiding this comment

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

Code Review

전반적으로 관리자 모임 관리 페이지를 더미 데이터에서 실제 API 연동으로 전환하는 훌륭한 개선입니다. 그룹 목록, 상세 정보, 회원 목록을 불러오고 그룹 정보를 수정하는 기능이 추가되었네요. 코드 구조도 API, 컴포넌트, 페이지로 잘 분리되어 있습니다.

다만, 리뷰 과정에서 몇 가지 개선점을 발견했습니다.

  • 모임 목록 페이지의 검색 기능이 의도와 다르게 동작하는 심각한 문제가 있습니다.
  • 회원 목록 페이지의 '상세보기' 링크가 동작하지 않습니다.
  • 비동기 작업을 처리하는 useEffect의 일관성이 부족한 부분이 있습니다.
  • 코드 일관성 및 가독성을 위한 작은 개선 사항들이 있습니다.

각 항목에 대한 구체적인 의견은 코드에 직접 남겼으니 확인 부탁드립니다.

Comment on lines +206 to +211
<Link
href="#"
className="text-Gray-7 underline underline-offset-2 hover:opacity-70"
>
상세보기
</Link>
Copy link
Contributor

Choose a reason for hiding this comment

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

high

'상세보기' 링크의 href 속성이 "#"로 되어 있어, 클릭해도 아무런 동작을 하지 않습니다. 사용자 상세 페이지로 이동해야 하지만, 현재 API 응답에 사용자 ID가 없어 링크를 구성할 수 없는 것으로 보입니다.

사용자 상세 페이지로 올바르게 라우팅하기 위해서는 API 응답에 사용자 ID와 같은 고유 식별자가 포함되어야 합니다. 백엔드 팀과 협의하여 AdminClubActiveMember 타입에 사용자 ID를 추가하는 것을 강력히 권장합니다.

Comment on lines +91 to +119
useEffect(() => {
if (Number.isNaN(groupId)) {
setError("올바르지 않은 모임 ID입니다.");
setLoading(false);
return;
}

const loadGroupDetail = async () => {
try {
setLoading(true);
setError("");
setSuccessMessage("");

const data = await fetchAdminClubDetail(groupId);
setGroup(data);
setForm(normalizeClubToForm(data));
} catch (err) {
setError(
err instanceof Error
? err.message
: "모임 상세 정보를 불러오지 못했습니다.",
);
} finally {
setLoading(false);
}
};

loadGroupDetail();
}, [groupId]);
Copy link
Contributor

Choose a reason for hiding this comment

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

high

useEffect 훅에는 비동기 작업에 대한 정리(cleanup) 로직이 없습니다. 사용자가 데이터를 로드하는 동안 다른 페이지로 빠르게 이동하면, 컴포넌트가 언마운트된 후에도 상태 업데이트가 시도되어 메모리 누수 및 예기치 않은 오류를 유발할 수 있습니다.

members/page.tsx에서 사용된 것처럼 mounted 플래그나 AbortController를 사용하여 컴포넌트가 언마운트될 때 비동기 작업을 취소하는 정리 함수를 추가해주세요.

) : (
users.map((u) => (
<tr
key={`${u.nickname}-${u.email}`}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

map 함수에서 keyu.nicknameu.email의 조합을 사용하고 있습니다. 이메일(u.email)이 고유하다면, key={u.email}과 같이 더 간단하고 명확하게 키를 설정할 수 있습니다. 만약 API 응답에 사용자 ID와 같은 고유한 값이 있다면 그것을 사용하는 것이 가장 좋습니다.

Suggested change
key={`${u.nickname}-${u.email}`}
key={u.email}

{links.length > 0 ? (
<div className="flex flex-col gap-4">
{links.map((item, index) => (
<div key={index} className="flex gap-4">
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

links.map에서 keyindex를 사용하고 있습니다. 링크 목록이 정적이고 순서가 변경되지 않는다면 문제가 되지 않을 수 있지만, 향후 목록에서 아이템을 추가, 삭제, 또는 재정렬하는 기능이 추가될 경우 React의 재조정(reconciliation) 과정에서 예기치 않은 동작이나 성능 저하를 유발할 수 있습니다.

각 링크에 고유한 ID를 부여하고 그것을 key로 사용하는 것이 가장 이상적입니다. API에서 고유 ID를 제공하지 않는다면, 클라이언트 측에서 임시 고유 ID를 생성하여 사용하는 방법도 고려해볼 수 있습니다.

{g.ownerEmail}
</td>
<td className="pl-[12px] py-0 body_1_2 text-Gray-7">
{g.createdAt.slice(0, 10).replace(/-/g, ".")}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

날짜 형식을 인라인으로 처리하고 있습니다 (g.createdAt.slice(0, 10).replace(/-/g, ".")). 이는 코드의 가독성을 해치고, 다른 곳에서 날짜 형식이 변경될 경우 여러 곳을 수정해야 하는 문제를 야기합니다.

members/page.tsx에 있는 formatDate와 같은 유틸리티 함수를 만들어 재사용하는 것을 권장합니다. 이를 통해 코드의 일관성을 유지하고 유지보수성을 높일 수 있습니다.

key={`${item.label}-${idx}`}
label={item.label}
selected={item.selected}
onClick={() => onToggle?.(item.label)} // 👈 핵심
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

코드 내에 개발 중에 사용된 것으로 보이는 주석(// 👈 핵심)이 남아있습니다. 최종 코드에는 이러한 주석을 제거하여 코드를 깔끔하게 유지하는 것이 좋습니다.

Suggested change
onClick={() => onToggle?.(item.label)} // 👈 핵심
onClick={() => onToggle?.(item.label)}

title?: string;
items: string[];
items: TargetItem[];
onToggle?: (label: string) => void; // 👈 추가
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

코드 내에 개발 중에 사용된 것으로 보이는 주석(// 👈 추가)이 남아있습니다. 최종 코드에는 이러한 주석을 제거하여 코드를 깔끔하게 유지하는 것이 좋습니다.

Suggested change
onToggle?: (label: string) => void; // 👈 추가
onToggle?: (label: string) => void;

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: 4

🧹 Nitpick comments (3)
src/app/(admin)/admin/(app)/groups/page.tsx (1)

44-46: Missing loadGroups in useEffect dependency array.

The loadGroups function is defined outside the effect but not included in the dependency array. This can cause stale closure issues. Consider either moving loadGroups inside the effect or using useCallback.

♻️ Proposed fix using inline async function
   useEffect(() => {
-    loadGroups(page);
-  }, [page]);
+    const loadData = async () => {
+      try {
+        setLoading(true);
+        setError("");
+        const data = await fetchAdminClubs(page - 1, pageSize);
+        setGroups(data.clubs ?? []);
+        setTotalPages(Math.max(1, data.totalPages ?? 1));
+      } catch (err) {
+        setError(
+          err instanceof Error
+            ? err.message
+            : "모임 목록을 불러오지 못했습니다.",
+        );
+        setGroups([]);
+        setTotalPages(1);
+      } finally {
+        setLoading(false);
+      }
+    };
+    loadData();
+  }, [page, pageSize]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/groups/page.tsx around lines 44 - 46, The
useEffect currently calls loadGroups(page) but omits loadGroups from the
dependency array, risking a stale closure; fix by either moving the loadGroups
implementation into the useEffect as an inline async function that uses page, or
memoize loadGroups with useCallback and include loadGroups in the dependency
array (e.g., ensure loadGroups and page are listed in useEffect dependencies).
Update the file so the effect depends on the stable loadGroups reference or uses
the inline loader to preserve correct behavior.
src/app/(admin)/admin/(app)/groups/[id]/members/page.tsx (1)

69-72: Club detail is re-fetched on every page change.

fetchAdminClubDetail is called on every pagination change just to get groupName, which is unlikely to change. Consider fetching it once on mount or separating the concerns.

♻️ Suggested optimization
+  // Fetch club detail once on mount
+  useEffect(() => {
+    if (!clubId || Number.isNaN(clubId)) return;
+    fetchAdminClubDetail(clubId).then((detail) => setGroupName(detail.name));
+  }, [clubId]);

   useEffect(() => {
     if (!clubId || Number.isNaN(clubId)) return;

     let mounted = true;

     async function loadData() {
       try {
         setLoading(true);
         setError(null);

-        const [clubDetail, membersResult] = await Promise.all([
-          fetchAdminClubDetail(clubId),
-          fetchAdminClubActiveMembers(clubId, page - 1, pageSize),
-        ]);
+        const membersResult = await fetchAdminClubActiveMembers(clubId, page - 1, pageSize);

         if (!mounted) return;

-        setGroupName(clubDetail.name);
         // ... rest of mapping
🤖 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 69 -
72, Currently fetchAdminClubDetail(clubId) is being called inside the
Promise.all together with fetchAdminClubActiveMembers on every pagination
change; move the club detail fetch out so it runs only once (or only when clubId
changes) and keep the paginated member fetch for page changes. Concretely, call
fetchAdminClubDetail(clubId) separately (e.g., in an effect or a higher-level
loader) to set clubDetail/groupName, and then call
fetchAdminClubActiveMembers(clubId, page - 1, pageSize) on pagination; update
the code that currently destructures const [clubDetail, membersResult] = await
Promise.all([...]) to two distinct calls so groupName is not re-fetched on each
page.
src/app/(admin)/admin/(app)/groups/[id]/page.tsx (1)

18-43: Consider importing from existing constants instead of duplicating.

CATEGORY_OPTIONS duplicates src/constants/categories.ts and TARGET_OPTIONS duplicates src/types/groups/groups.ts. This creates a maintenance burden and risk of divergence when backend codes change.

♻️ Suggested refactor
+import { CATEGORIES } from "@/constants/categories";
+import { PARTICIPANTS, PARTICIPANT_LABEL_TO_TYPE } from "@/types/groups/groups";

-const CATEGORY_OPTIONS = [
-  { label: "여행", value: "TRAVEL" },
-  // ... rest of options
-];
+const CATEGORY_OPTIONS = CATEGORIES;

-const TARGET_OPTIONS = [
-  { label: "대학생", value: "STUDENT" },
-  // ... rest of options
-];
+const TARGET_OPTIONS = PARTICIPANTS.map((label) => ({
+  label,
+  value: PARTICIPANT_LABEL_TO_TYPE[label],
+}));
🤖 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]/page.tsx around lines 18 - 43,
CATEGORY_OPTIONS and TARGET_OPTIONS are duplicated here; replace them by
importing the canonical constants from the existing modules and remove the local
arrays. Locate the local symbols CATEGORY_OPTIONS and TARGET_OPTIONS in
page.tsx, import the matching exports from the shared constants (the categories
constant and the group target constant exported by your constants/modules), and
use those imports in place of the local arrays; if the shared values use
different shape, map/transform them to { label, value } at the import site so
all existing consumers of CATEGORY_OPTIONS and TARGET_OPTIONS keep the same
shape.
🤖 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)/groups/[id]/members/page.tsx:
- Around line 206-211: The Link element rendering the "상세보기" text currently uses
a placeholder href="#" which doesn't navigate; update the Link in
src/app/(admin)/admin/(app)/groups/[id]/members/page.tsx (the Link with
className "text-Gray-7 underline underline-offset-2 hover:opacity-70" and label
"상세보기") to either point to the actual member detail route (e.g., a dynamic path
using the member id) or remove/replace it with a non-interactive element (like a
span or a disabled button) until the detail page is implemented so it doesn't
act as a dead link.
- Around line 59-61: The effect that begins with useEffect(() => { if (!clubId
|| Number.isNaN(clubId)) return; } ) returns early on an invalid clubId but
leaves loading true and no error set; update that early-return branch to call
setError with a helpful message (e.g., "Invalid club ID") and setLoading(false)
before returning so the UI stops showing the perpetual loading state; adjust
references in the patch to the existing setError and setLoading state setters
used in this component.

In `@src/app/`(admin)/admin/(app)/groups/[id]/page.tsx:
- Around line 239-271: handleSave currently calls updateAdminClub and
router.refresh() but doesn't update the local group/form state, leaving `group`
stale and causing isChanged to be incorrect; after a successful update (inside
the try block, after updateAdminClub and before setting success message) fetch
or use the new data to update the local state by calling the setter(s) that hold
the group and form (e.g., setGroup(...) and setForm(...)) with the updated
payload or the server response so subsequent edits compare against the latest
values; ensure you preserve any server-normalized fields (IDs, timestamps,
profileImageUrl) if you use the payload rather than re-fetching.

In `@src/app/`(admin)/admin/(app)/groups/page.tsx:
- Around line 57-68: filteredGroups currently filters the groups array in
useMemo (function filteredGroups) which only contains the current page of
results, so searches miss items on other pages; update the data flow so the
backend performs the search instead of filtering only the current page: modify
the groups data fetch to accept the keyword (e.g., add a query param in the API
call used to populate groups), pass keyword from the page component to that
fetch, and remove or limit the client-side filtering in useMemo (or apply it
only to the full dataset once server returns matching results); ensure the
components that call the fetch (the hook or loader that provides groups) are
updated to accept and use keyword and that useMemo’s dependency remains [groups,
keyword] if you keep client-side filtering as a fallback.

---

Nitpick comments:
In `@src/app/`(admin)/admin/(app)/groups/[id]/members/page.tsx:
- Around line 69-72: Currently fetchAdminClubDetail(clubId) is being called
inside the Promise.all together with fetchAdminClubActiveMembers on every
pagination change; move the club detail fetch out so it runs only once (or only
when clubId changes) and keep the paginated member fetch for page changes.
Concretely, call fetchAdminClubDetail(clubId) separately (e.g., in an effect or
a higher-level loader) to set clubDetail/groupName, and then call
fetchAdminClubActiveMembers(clubId, page - 1, pageSize) on pagination; update
the code that currently destructures const [clubDetail, membersResult] = await
Promise.all([...]) to two distinct calls so groupName is not re-fetched on each
page.

In `@src/app/`(admin)/admin/(app)/groups/[id]/page.tsx:
- Around line 18-43: CATEGORY_OPTIONS and TARGET_OPTIONS are duplicated here;
replace them by importing the canonical constants from the existing modules and
remove the local arrays. Locate the local symbols CATEGORY_OPTIONS and
TARGET_OPTIONS in page.tsx, import the matching exports from the shared
constants (the categories constant and the group target constant exported by
your constants/modules), and use those imports in place of the local arrays; if
the shared values use different shape, map/transform them to { label, value } at
the import site so all existing consumers of CATEGORY_OPTIONS and TARGET_OPTIONS
keep the same shape.

In `@src/app/`(admin)/admin/(app)/groups/page.tsx:
- Around line 44-46: The useEffect currently calls loadGroups(page) but omits
loadGroups from the dependency array, risking a stale closure; fix by either
moving the loadGroups implementation into the useEffect as an inline async
function that uses page, or memoize loadGroups with useCallback and include
loadGroups in the dependency array (e.g., ensure loadGroups and page are listed
in useEffect dependencies). Update the file so the effect depends on the stable
loadGroups reference or uses the inline loader to preserve correct behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f5e6f689-2cda-49e0-8386-3e0db1adc3fc

📥 Commits

Reviewing files that changed from the base of the PR and between ab5a52a and 0b77601.

📒 Files selected for processing (8)
  • src/app/(admin)/admin/(app)/groups/[id]/members/page.tsx
  • src/app/(admin)/admin/(app)/groups/[id]/page.tsx
  • src/app/(admin)/admin/(app)/groups/page.tsx
  • src/components/base-ui/Admin/groups/Category.tsx
  • src/components/base-ui/Admin/groups/Target.tsx
  • src/components/base-ui/Admin/groups/TopItem.tsx
  • src/lib/api/admin/clubs.ts
  • src/lib/api/admin/endpoints/clubs.ts

Comment on lines +59 to +61
useEffect(() => {
if (!clubId || Number.isNaN(clubId)) return;

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing error state when clubId is invalid.

When clubId is NaN, the effect returns early but loading remains true and no error is set. The user will see a perpetual "불러오는 중..." state.

🔧 Proposed fix
   useEffect(() => {
-    if (!clubId || Number.isNaN(clubId)) return;
+    if (!clubId || Number.isNaN(clubId)) {
+      setError("올바르지 않은 모임 ID입니다.");
+      setLoading(false);
+      return;
+    }

     let mounted = true;
🤖 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 59 -
61, The effect that begins with useEffect(() => { if (!clubId ||
Number.isNaN(clubId)) return; } ) returns early on an invalid clubId but leaves
loading true and no error set; update that early-return branch to call setError
with a helpful message (e.g., "Invalid club ID") and setLoading(false) before
returning so the UI stops showing the perpetual loading state; adjust references
in the patch to the existing setError and setLoading state setters used in this
component.

Comment on lines +206 to +211
<Link
href="#"
className="text-Gray-7 underline underline-offset-2 hover:opacity-70"
>
상세보기
</Link>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Placeholder link with href="#".

The "상세보기" link uses href="#" which doesn't navigate anywhere meaningful. If member detail pages aren't implemented yet, consider disabling the link or removing it temporarily.

🤖 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 element rendering the "상세보기" text currently uses a placeholder
href="#" which doesn't navigate; update the Link in
src/app/(admin)/admin/(app)/groups/[id]/members/page.tsx (the Link with
className "text-Gray-7 underline underline-offset-2 hover:opacity-70" and label
"상세보기") to either point to the actual member detail route (e.g., a dynamic path
using the member id) or remove/replace it with a non-interactive element (like a
span or a disabled button) until the detail page is implemented so it doesn't
act as a dead link.

Comment on lines +239 to +271
const handleSave = async () => {
if (!form || !isValid || !isChanged) return;

try {
setSaving(true);
setError("");
setSuccessMessage("");

const payload: UpdateAdminClubRequest = {
...form,
name: form.name.trim(),
description: form.description.trim(),
profileImageUrl: form.profileImageUrl.trim(),
region: form.region.trim(),
links: form.links
.map((item) => ({
label: item.label.trim(),
link: item.link.trim(),
}))
.filter((item) => item.label !== "" && item.link !== ""),
};

await updateAdminClub(groupId, payload);
router.refresh();
setSuccessMessage("모임 정보가 저장되었습니다.");
} catch (err) {
setError(
err instanceof Error ? err.message : "모임 수정에 실패했습니다.",
);
} finally {
setSaving(false);
}
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Form state not updated after successful save.

After updateAdminClub succeeds, router.refresh() is called but the local group state is not updated with the new server data. This means subsequent edits will compare against the stale initial group state, causing isChanged to behave incorrectly.

🔧 Proposed fix
       const payload: UpdateAdminClubRequest = {
         // ...
       };

-      await updateAdminClub(groupId, payload);
+      const updatedClub = await updateAdminClub(groupId, payload);
+      setGroup(updatedClub);
+      setForm(normalizeClubToForm(updatedClub));
       router.refresh();
       setSuccessMessage("모임 정보가 저장되었습니다.");
🤖 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]/page.tsx around lines 239 - 271,
handleSave currently calls updateAdminClub and router.refresh() but doesn't
update the local group/form state, leaving `group` stale and causing isChanged
to be incorrect; after a successful update (inside the try block, after
updateAdminClub and before setting success message) fetch or use the new data to
update the local state by calling the setter(s) that hold the group and form
(e.g., setGroup(...) and setForm(...)) with the updated payload or the server
response so subsequent edits compare against the latest values; ensure you
preserve any server-normalized fields (IDs, timestamps, profileImageUrl) if you
use the payload rather than re-fetching.

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: 4

♻️ Duplicate comments (3)
src/app/(admin)/admin/(app)/groups/[id]/page.tsx (1)

261-263: ⚠️ Potential issue | 🟠 Major

Update local state with the saved club.

The saved response is ignored here, so group stays as the pre-save snapshot and isChanged stays true after a successful save.

🛠️ Proposed fix
-      await updateAdminClub(groupId, payload);
+      const updatedClub = await updateAdminClub(groupId, payload);
+      setGroup(updatedClub);
+      setForm(normalizeClubToForm(updatedClub));
       router.refresh();
       setSuccessMessage("모임 정보가 저장되었습니다.");
🤖 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]/page.tsx around lines 261 - 263, The
save response from updateAdminClub is ignored so local state remains stale;
capture the returned saved club (e.g., const saved = await
updateAdminClub(groupId, payload)), then update the local group state (call
setGroup(saved) or merge saved into current group) and reset the change flag
(setIsChanged(false)) before/after calling router.refresh() and
setSuccessMessage so the UI reflects the saved data and isChanged becomes false.
src/app/(admin)/admin/(app)/groups/[id]/members/page.tsx (2)

59-61: ⚠️ Potential issue | 🟡 Minor

Handle invalid route params before returning.

This branch exits without clearing loading, so the page can stay on "불러오는 중..." forever.

🛠️ Proposed fix
   useEffect(() => {
-    if (!clubId || Number.isNaN(clubId)) return;
+    if (!clubId || Number.isNaN(clubId)) {
+      setError("올바르지 않은 모임 ID입니다.");
+      setLoading(false);
+      return;
+    }
🤖 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 59 -
61, The useEffect that checks route param clubId returns early on invalid values
but never updates the loading state; update the invalid-params branch in the
useEffect (the block surrounding clubId and Number.isNaN(clubId)) to handle
invalid route params by clearing loading and handling the error path (e.g., call
setLoading(false) and set an error state or redirect) so the page doesn't remain
stuck on "불러오는 중...". Ensure you modify the same useEffect where clubId is
referenced so the early-return path updates loading via setLoading (and/or
setError or router.push).

206-211: ⚠️ Potential issue | 🟡 Minor

Replace the placeholder member link.

href="#" still renders as an interactive link with no destination. Use the real member detail route or render plain text until that route exists.

🤖 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 component rendering "상세보기" uses a placeholder href="#" which
produces an interactive but non-functional link; update the Link in the members
list (the element with text "상세보기" and className "text-Gray-7 underline
underline-offset-2 hover:opacity-70") to use the real member detail route (e.g.,
replace href="#" with the actual route that includes the group and member IDs
such as `/groups/${groupId}/members/${memberId}` or the app's equivalent), or if
the route does not yet exist, replace the Link with a non-interactive element
(e.g., a span/button styled identically) so it no longer behaves like a dead
link.
🧹 Nitpick comments (1)
src/app/(admin)/admin/(app)/groups/[id]/members/page.tsx (1)

69-72: Stop refetching club detail on every page change.

groupName only depends on clubId, but pagination currently waits on a fresh fetchAdminClubDetail call every time. Splitting that request out prevents redundant traffic and avoids breaking member pagination because of an unrelated detail fetch failure.

🤖 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 69 -
72, The current Promise.all refetches fetchAdminClubDetail on every page change;
instead call fetchAdminClubDetail(clubId) once and reuse its result (clubDetail)
while only refetching fetchAdminClubActiveMembers(clubId, page - 1, pageSize)
when pagination changes—i.e., remove fetchAdminClubDetail from the Promise.all,
fetch and assign clubDetail separately (once) using the existing
fetchAdminClubDetail symbol, then await only fetchAdminClubActiveMembers for
page updates (using membersResult) so pagination no longer depends on the club
detail request.
🤖 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)/groups/[id]/page.tsx:
- Around line 346-350: The region and link inputs lack accessible names; add
explicit labels or aria attributes and unique ids so screen readers can identify
them. For the region input (where value={form.region} and onChange calls
handleRegionChange), add an id (e.g., id="region-input") and either a visible
<label htmlFor="region-input">Region</label> or aria-label/aria-labelledby on
the input; do the same for both link inputs referenced in the block around lines
375-391 (give each input a unique id and associate a <label> or aria-label, or
use aria-labelledby that points to a descriptive text element). Ensure the label
text is meaningful and update any CSS/layout so the label can be hidden visually
if needed but remains accessible.

In `@src/app/`(admin)/admin/(app)/groups/page.tsx:
- Around line 23-47: The loadGroups function can apply stale async responses
(from fetchAdminClubs) and overwrite state after a newer request; prevent this
by tracking and validating a per-call token or AbortController: when initiating
loadGroups (called by the useEffect watching page and searchKeyword) create a
local requestId or AbortController, store the current token in a ref (e.g.,
currentRequestIdRef) or attach the controller to cancel previous requests, pass
the controller.signal or capture the id in the async closure, and only call
setGroups/setTotalPages/setLoading when the response matches the latest token
(or when the request isn't aborted). Ensure the previous request is cancelled or
ignored in the finally block so stale responses do not update state.

In `@src/lib/api/admin/clubs.ts`:
- Around line 88-93: fetchAdminClubs uses a 1-based page default but calls
ADMIN_CLUBS.list which expects a 0-based page, causing an off-by-one on first
load; update fetchAdminClubs to convert the incoming page to the 0-based
convention before calling ADMIN_CLUBS.list (e.g. compute zeroBased = Math.max(0,
page - 1) and pass that to ADMIN_CLUBS.list) so UI page state and backend page
indexing are consistent.

In `@src/lib/api/admin/endpoints/clubs.ts`:
- Around line 4-5: The URL builder for the list endpoint doesn't encode the
keyword, so special characters like & or = break the query; update the list
function to pass keyword through encodeURIComponent (e.g.,
encodeURIComponent(keyword ?? "")) when constructing the query string so the
keyword is safely encoded in
`${API_BASE_URL}/admin/clubs?page=${page}&size=${size}&keyword=${encodedKeyword}`;
ensure you reference and update the list property used to build the URL.

---

Duplicate comments:
In `@src/app/`(admin)/admin/(app)/groups/[id]/members/page.tsx:
- Around line 59-61: The useEffect that checks route param clubId returns early
on invalid values but never updates the loading state; update the invalid-params
branch in the useEffect (the block surrounding clubId and Number.isNaN(clubId))
to handle invalid route params by clearing loading and handling the error path
(e.g., call setLoading(false) and set an error state or redirect) so the page
doesn't remain stuck on "불러오는 중...". Ensure you modify the same useEffect where
clubId is referenced so the early-return path updates loading via setLoading
(and/or setError or router.push).
- Around line 206-211: The Link component rendering "상세보기" uses a placeholder
href="#" which produces an interactive but non-functional link; update the Link
in the members list (the element with text "상세보기" and className "text-Gray-7
underline underline-offset-2 hover:opacity-70") to use the real member detail
route (e.g., replace href="#" with the actual route that includes the group and
member IDs such as `/groups/${groupId}/members/${memberId}` or the app's
equivalent), or if the route does not yet exist, replace the Link with a
non-interactive element (e.g., a span/button styled identically) so it no longer
behaves like a dead link.

In `@src/app/`(admin)/admin/(app)/groups/[id]/page.tsx:
- Around line 261-263: The save response from updateAdminClub is ignored so
local state remains stale; capture the returned saved club (e.g., const saved =
await updateAdminClub(groupId, payload)), then update the local group state
(call setGroup(saved) or merge saved into current group) and reset the change
flag (setIsChanged(false)) before/after calling router.refresh() and
setSuccessMessage so the UI reflects the saved data and isChanged becomes false.

---

Nitpick comments:
In `@src/app/`(admin)/admin/(app)/groups/[id]/members/page.tsx:
- Around line 69-72: The current Promise.all refetches fetchAdminClubDetail on
every page change; instead call fetchAdminClubDetail(clubId) once and reuse its
result (clubDetail) while only refetching fetchAdminClubActiveMembers(clubId,
page - 1, pageSize) when pagination changes—i.e., remove fetchAdminClubDetail
from the Promise.all, fetch and assign clubDetail separately (once) using the
existing fetchAdminClubDetail symbol, then await only
fetchAdminClubActiveMembers for page updates (using membersResult) so pagination
no longer depends on the club detail request.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e15c89db-2dab-430d-9f3d-e5a57ae84d42

📥 Commits

Reviewing files that changed from the base of the PR and between 0b77601 and 8c6aa68.

📒 Files selected for processing (5)
  • src/app/(admin)/admin/(app)/groups/[id]/members/page.tsx
  • src/app/(admin)/admin/(app)/groups/[id]/page.tsx
  • src/app/(admin)/admin/(app)/groups/page.tsx
  • src/lib/api/admin/clubs.ts
  • src/lib/api/admin/endpoints/clubs.ts

Comment on lines +346 to +350
<input
type="text"
value={form.region}
onChange={(e) => handleRegionChange(e.target.value)}
className="w-full text-Gray-7 text-[18px] font-normal leading-[135%] tracking-[-0.018px] outline-none bg-transparent"
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Give these inputs an accessible name.

The region field and both link fields rely on nearby text or placeholders only, so screen readers do not get a stable label for the controls.

🛠️ Proposed fix
           <div className="w-full h-[48px] rounded-[8px] border border-Subbrown-4 bg-White px-4 flex items-center">
             <input
               type="text"
+              aria-label="활동 지역"
               value={form.region}
               onChange={(e) => handleRegionChange(e.target.value)}
               className="w-full text-Gray-7 text-[18px] font-normal leading-[135%] tracking-[-0.018px] outline-none bg-transparent"
             />
@@
                   <input
                     type="text"
+                    aria-label={`링크 대체 텍스트 ${index + 1}`}
                     value={item.label ?? ""}
                     onChange={(e) =>
                       handleLinkChange(index, "label", e.target.value)
                     }
                     placeholder="링크 대체 텍스트 입력(최대 20자)"
                     className="flex-1 h-[56px] rounded-[8px] border border-Subbrown-4 bg-White px-4 text-[18px] outline-none"
                   />
                   <input
                     type="text"
+                    aria-label={`링크 URL ${index + 1}`}
                     value={item.link}
                     onChange={(e) =>
                       handleLinkChange(index, "link", e.target.value)
                     }
                     placeholder="링크 입력(최대 100자)"
                     className="flex-1 h-[56px] rounded-[8px] border border-Subbrown-4 bg-White px-4 text-[18px] outline-none"
                   />

Also applies to: 375-391

🤖 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]/page.tsx around lines 346 - 350, The
region and link inputs lack accessible names; add explicit labels or aria
attributes and unique ids so screen readers can identify them. For the region
input (where value={form.region} and onChange calls handleRegionChange), add an
id (e.g., id="region-input") and either a visible <label
htmlFor="region-input">Region</label> or aria-label/aria-labelledby on the
input; do the same for both link inputs referenced in the block around lines
375-391 (give each input a unique id and associate a <label> or aria-label, or
use aria-labelledby that points to a descriptive text element). Ensure the label
text is meaningful and update any CSS/layout so the label can be hidden visually
if needed but remains accessible.

Comment on lines +23 to +47
const loadGroups = async (targetPage: number, targetKeyword: string) => {
try {
setLoading(true);
setError("");

return groups.filter((g) => {
return (
String(g.id).includes(q) ||
g.name.toLowerCase().includes(q) ||
g.ownerEmail.toLowerCase().includes(q)
const data = await fetchAdminClubs(targetPage, pageSize, targetKeyword);

setGroups(data.clubs ?? []);
setTotalPages(Math.max(1, data.totalPages ?? 1));
} catch (err) {
setError(
err instanceof Error
? err.message
: "모임 목록을 불러오지 못했습니다.",
);
});
}, [groups, keyword]);
setGroups([]);
setTotalPages(1);
} finally {
setLoading(false);
}
};

const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
useEffect(() => {
loadGroups(page, searchKeyword);
}, [page, searchKeyword]);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Discard stale list responses.

A slower earlier request can resolve after a later search or page request and overwrite groups, totalPages, and loading with stale state.

🛠️ Proposed fix
-  const loadGroups = async (targetPage: number, targetKeyword: string) => {
-    try {
-      setLoading(true);
-      setError("");
-
-      const data = await fetchAdminClubs(targetPage, pageSize, targetKeyword);
-
-      setGroups(data.clubs ?? []);
-      setTotalPages(Math.max(1, data.totalPages ?? 1));
-    } catch (err) {
-      setError(
-        err instanceof Error
-          ? err.message
-          : "모임 목록을 불러오지 못했습니다.",
-      );
-      setGroups([]);
-      setTotalPages(1);
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  useEffect(() => {
-    loadGroups(page, searchKeyword);
-  }, [page, searchKeyword]);
+  useEffect(() => {
+    let cancelled = false;
+
+    async function loadGroups() {
+      try {
+        setLoading(true);
+        setError("");
+
+        const data = await fetchAdminClubs(page, pageSize, searchKeyword);
+        if (cancelled) return;
+
+        setGroups(data.clubs ?? []);
+        setTotalPages(Math.max(1, data.totalPages ?? 1));
+      } catch (err) {
+        if (cancelled) return;
+
+        setError(
+          err instanceof Error
+            ? err.message
+            : "모임 목록을 불러오지 못했습니다.",
+        );
+        setGroups([]);
+        setTotalPages(1);
+      } finally {
+        if (!cancelled) setLoading(false);
+      }
+    }
+
+    loadGroups();
+
+    return () => {
+      cancelled = true;
+    };
+  }, [page, searchKeyword]);
📝 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
const loadGroups = async (targetPage: number, targetKeyword: string) => {
try {
setLoading(true);
setError("");
return groups.filter((g) => {
return (
String(g.id).includes(q) ||
g.name.toLowerCase().includes(q) ||
g.ownerEmail.toLowerCase().includes(q)
const data = await fetchAdminClubs(targetPage, pageSize, targetKeyword);
setGroups(data.clubs ?? []);
setTotalPages(Math.max(1, data.totalPages ?? 1));
} catch (err) {
setError(
err instanceof Error
? err.message
: "모임 목록을 불러오지 못했습니다.",
);
});
}, [groups, keyword]);
setGroups([]);
setTotalPages(1);
} finally {
setLoading(false);
}
};
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
useEffect(() => {
loadGroups(page, searchKeyword);
}, [page, searchKeyword]);
useEffect(() => {
let cancelled = false;
async function loadGroups() {
try {
setLoading(true);
setError("");
const data = await fetchAdminClubs(page, pageSize, searchKeyword);
if (cancelled) return;
setGroups(data.clubs ?? []);
setTotalPages(Math.max(1, data.totalPages ?? 1));
} catch (err) {
if (cancelled) return;
setError(
err instanceof Error
? err.message
: "모임 목록을 불러오지 못했습니다.",
);
setGroups([]);
setTotalPages(1);
} finally {
if (!cancelled) setLoading(false);
}
}
loadGroups();
return () => {
cancelled = true;
};
}, [page, searchKeyword]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/groups/page.tsx around lines 23 - 47, The
loadGroups function can apply stale async responses (from fetchAdminClubs) and
overwrite state after a newer request; prevent this by tracking and validating a
per-call token or AbortController: when initiating loadGroups (called by the
useEffect watching page and searchKeyword) create a local requestId or
AbortController, store the current token in a ref (e.g., currentRequestIdRef) or
attach the controller to cancel previous requests, pass the controller.signal or
capture the id in the async closure, and only call
setGroups/setTotalPages/setLoading when the response matches the latest token
(or when the request isn't aborted). Ensure the previous request is cancelled or
ignored in the finally block so stale responses do not update state.

Comment on lines +88 to +93
export async function fetchAdminClubs(
page = 1,
size = 20,
keyword = "",
) {
const res = await fetch(ADMIN_CLUBS.list(page, size, keyword), {
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Make fetchAdminClubs use one page-number convention.

This helper defaults to a 1-based page but forwards it to a 0-based endpoint builder. The new groups page passes UI page state straight in here, so the first load can miss the first backend page.

🛠️ One safe fix
 export async function fetchAdminClubs(
-  page = 1,
+  uiPage = 1,
   size = 20,
   keyword = "",
 ) {
-  const res = await fetch(ADMIN_CLUBS.list(page, size, keyword), {
+  const page = Math.max(0, uiPage - 1);
+
+  const res = await fetch(ADMIN_CLUBS.list(page, size, keyword), {
     method: "GET",
     credentials: "include",
     cache: "no-store",
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/api/admin/clubs.ts` around lines 88 - 93, fetchAdminClubs uses a
1-based page default but calls ADMIN_CLUBS.list which expects a 0-based page,
causing an off-by-one on first load; update fetchAdminClubs to convert the
incoming page to the 0-based convention before calling ADMIN_CLUBS.list (e.g.
compute zeroBased = Math.max(0, page - 1) and pass that to ADMIN_CLUBS.list) so
UI page state and backend page indexing are consistent.

Comment on lines +4 to +5
list: (page = 0, size = 20, keyword = "") =>
`${API_BASE_URL}/admin/clubs?page=${page}&size=${size}&keyword=${keyword}`,
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Encode keyword before building the list URL.

Raw search text can split this query at &/=, so terms like R&D stop matching the intended keyword and can mutate the rest of the request.

🛠️ Proposed fix
 export const ADMIN_CLUBS = {
-  list: (page = 0, size = 20, keyword = "") =>
-    `${API_BASE_URL}/admin/clubs?page=${page}&size=${size}&keyword=${keyword}`,
+  list: (page = 0, size = 20, keyword = "") => {
+    const params = new URLSearchParams({
+      page: String(page),
+      size: String(size),
+      keyword,
+    });
+
+    return `${API_BASE_URL}/admin/clubs?${params.toString()}`;
+  },
📝 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
list: (page = 0, size = 20, keyword = "") =>
`${API_BASE_URL}/admin/clubs?page=${page}&size=${size}&keyword=${keyword}`,
list: (page = 0, size = 20, keyword = "") => {
const params = new URLSearchParams({
page: String(page),
size: String(size),
keyword,
});
return `${API_BASE_URL}/admin/clubs?${params.toString()}`;
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/api/admin/endpoints/clubs.ts` around lines 4 - 5, The URL builder for
the list endpoint doesn't encode the keyword, so special characters like & or =
break the query; update the list function to pass keyword through
encodeURIComponent (e.g., encodeURIComponent(keyword ?? "")) when constructing
the query string so the keyword is safely encoded in
`${API_BASE_URL}/admin/clubs?page=${page}&size=${size}&keyword=${encodedKeyword}`;
ensure you reference and update the list property used to build the URL.

@shinwokkang shinwokkang merged commit 406d889 into main Mar 21, 2026
4 checks passed
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.

2 participants