[Feat] WTH-410: 어드민 회비 등록 온보딩 UI 구현#133
Hidden character warning
Conversation
📝 WalkthroughWalkthrough회비 설정 5단계 화면, 공통 UI, 설정 스토어/라우팅 헬퍼, 대시보드 튜토리얼·납부 현황 연결이 추가되었습니다. 세션 로그 문서와 랜딩 로그인 테스트 대기 로직도 함께 변경되었습니다. Changes회비 설정 및 납부 화면
세션 로그 문서
랜딩 테스트 대기
Sequence Diagram(s)sequenceDiagram
participant Admin
participant DuesPageContent
participant DuesTutorialModal
participant useDuesSetupNavigation
participant DuesSetupStep1
participant useDuesSetupStore
participant DuesSetupStep5
Admin->>DuesPageContent: 총 회비 정보 입력 시작 클릭
DuesPageContent->>DuesTutorialModal: open=true
Admin->>DuesTutorialModal: 총 회비 정보 입력 시작하기 클릭
DuesTutorialModal->>DuesPageContent: onStart()
DuesPageContent->>useDuesSetupNavigation: goToStep(1)
Admin->>DuesSetupStep1: 기본 정보 입력
DuesSetupStep1->>useDuesSetupStore: setField(...)
Admin->>DuesSetupStep5: 저장하고 완료하기 클릭
DuesSetupStep5->>useDuesSetupStore: reset()
DuesSetupStep5->>useDuesSetupNavigation: goToDues()
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
PR 테스트 결과✅ Jest: 통과 🎉 모든 테스트를 통과했습니다! |
PR 검증 결과❌ TypeScript: 실패 |
PR E2E 테스트 결과❌ Playwright: 실패 |
PR 테스트 결과✅ Jest: 통과 🎉 모든 테스트를 통과했습니다! |
PR E2E 테스트 결과✅ Playwright: 통과 🎉 E2E 테스트를 통과했습니다! |
PR 검증 결과✅ TypeScript: 통과 |
|
구현한 기능 Preview: https://weeth-hfpu5gb9v-weethsite-4975s-projects.vercel.app |
PR 테스트 결과✅ Jest: 통과 🎉 모든 테스트를 통과했습니다! |
PR E2E 테스트 결과✅ Playwright: 통과 🎉 E2E 테스트를 통과했습니다! |
PR 검증 결과✅ TypeScript: 통과 🎉 모든 검증을 통과했습니다! |
|
구현한 기능 Preview: https://weeth-q5x9ps4ft-weethsite-4975s-projects.vercel.app |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
d7538ae to
8f4611e
Compare
PR 테스트 결과✅ Jest: 통과 🎉 모든 테스트를 통과했습니다! |
PR E2E 테스트 결과✅ Playwright: 통과 🎉 E2E 테스트를 통과했습니다! |
|
구현한 기능 Preview: https://weeth-a46kw4i7m-weethsite-4975s-projects.vercel.app |
PR 검증 결과✅ TypeScript: 통과 🎉 모든 검증을 통과했습니다! |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR 테스트 결과✅ Jest: 통과 🎉 모든 테스트를 통과했습니다! |
|
구현한 기능 Preview: https://weeth-p4bq8rn34-weethsite-4975s-projects.vercel.app |
PR 검증 결과✅ TypeScript: 통과 🎉 모든 검증을 통과했습니다! |
PR E2E 테스트 결과❌ Playwright: 실패 |
framer-motion 헤더 애니메이션이 hydration 전에 시작되어 클릭 시점에 요소가 이동 중일 수 있는 race condition 수정 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR 테스트 결과✅ Jest: 통과 🎉 모든 테스트를 통과했습니다! |
PR 검증 결과✅ TypeScript: 통과 🎉 모든 검증을 통과했습니다! |
|
구현한 기능 Preview: https://weeth-7p6dqj7qy-weethsite-4975s-projects.vercel.app |
PR E2E 테스트 결과❌ Playwright: 실패 |
There was a problem hiding this comment.
Actionable comments posted: 11
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/components/admin/schedule/general/ScheduleTextField.tsx (1)
26-46: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win오류 상태가 보조기술에 전달되지 않고 기본 포커스 표시도 사라집니다.
Line 33에서
focus:outline-none만 적용돼 정상 상태의 키보드 포커스가 보이지 않고,error가 있어도aria-invalid/aria-describedby가 없어 스크린리더는 실패한 필드로 인지하지 못합니다. 이 컴포넌트가 Step 4 검증 입력에 바로 재사용되므로 접근성 처리를 여기서 같이 넣는 편이 안전합니다.🔧 수정 예시
+import { useId } from 'react'; import { cn } from '`@/lib/cn`'; function ScheduleTextField({ label, value, onChange, placeholder, maxLength, className, error, }: ScheduleTextFieldProps) { + const errorId = useId(); + return ( <ScheduleFormField label={label}> <input type="text" value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} maxLength={maxLength} + aria-invalid={!!error} + aria-describedby={error ? errorId : undefined} className={cn( - 'bg-container-neutral typo-body1 placeholder:text-text-alternative text-text-normal h-12 w-full rounded-sm px-400 py-300 focus:outline-none', + 'bg-container-neutral typo-body1 placeholder:text-text-alternative text-text-normal h-12 w-full rounded-sm px-400 py-300 focus:outline-none focus-visible:ring-1 focus-visible:ring-brand-primary', error && 'ring-state-error ring-1', className, )} /> {(error || maxLength !== undefined) && ( <div className="mt-100 flex items-center justify-between px-100"> - {error ? <span className="typo-caption2 text-state-error">{error}</span> : <span />} + {error ? ( + <span id={errorId} className="typo-caption2 text-state-error"> + {error} + </span> + ) : ( + <span /> + )} {maxLength !== undefined && ( <span className="typo-caption2 text-text-alternative"> {value.length}/{maxLength} </span> )} </div> )}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/admin/schedule/general/ScheduleTextField.tsx` around lines 26 - 46, The ScheduleTextField input is missing accessibility state and hides the default keyboard focus. In ScheduleTextField, keep a visible focus treatment instead of relying on focus:outline-none alone, and add aria-invalid plus an appropriate aria-describedby link when error text is rendered so assistive tech can announce the invalid state. Use the input element and the error/message block in ScheduleTextField to wire this up, and preserve the existing maxLength counter behavior.
🧹 Nitpick comments (8)
docs/로그/세션로그-JIN921-2026-06-25.md (1)
72-80: 🔒 Security & Privacy | 🔵 TriviallocalStorage 지속화된 계좌 정보의 보안 고려사항
useDuesSetupStore의persist로 인해 계좌번호, 은행, 예금주 등의 금융 계좌 정보가 localStorage에 평문으로 저장됩니다. 브라우저 공유 환경에서 민감 데이터 노출 위험이 있으므로, 온보딩 완료 후reset()호출뿐 아니라 저장 시점 자체를 최소화하거나 세션 스토리지 대안을 검토하세요. 또한reset()이 실제로 호출되는지 Step 5 구현에서 반드시 검증해야 합니다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/로그/세션로그-JIN921-2026-06-25.md` around lines 72 - 80, useDuesSetupStore의 persist로 인해 계좌번호/은행/예금주 같은 민감 정보가 localStorage에 평문 저장되는 문제가 있습니다. useDuesSetupStore와 Step 5 완료 흐름을 점검해 저장 시점을 최소화하고, 가능하면 sessionStorage 대안이나 민감 필드 비지속화 방식으로 바꾸세요. 또한 온보딩 완료 시 reset()이 실제로 호출되는지 최종 확인 로직에서 반드시 검증해, 저장된 금융 정보가 남지 않도록 처리하세요.src/components/admin/dues/modal/PaymentTargetModal.tsx (1)
50-51: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value렌더 시
page를totalPages범위로 클램프하는 것을 고려해주세요.
page는 탭/검색 변경 시에만 1로 초기화되므로, 모달이 열린 상태에서selectedMemberIdsprop이 줄어들면page가totalPages를 초과해 빈 페이지가 노출될 수 있습니다.♻️ 제안
const totalPages = Math.max(1, Math.ceil(filteredTargets.length / PAGE_SIZE)); - const pagedTargets = filteredTargets.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + const safePage = Math.min(page, totalPages); + const pagedTargets = filteredTargets.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/admin/dues/modal/PaymentTargetModal.tsx` around lines 50 - 51, Clamp the current page to the valid range before slicing targets, because `page` can become larger than `totalPages` when `selectedMemberIds` shrinks while the modal stays open. Update `PaymentTargetModal` where `totalPages` and `pagedTargets` are derived so `page` is bounded to 1..`totalPages` on render, and use that safe page value for the slice and any pagination UI state.src/components/admin/dues/DuesPageContent.tsx (1)
141-142: 📐 Maintainability & Code Quality | 🔵 TrivialTODO: 튜토리얼 모달 표시 조건 처리.
현재
tutorialOpen이useState(true)라 페이지 진입 시마다 모달이 항상 표시됩니다. 총 회비 미설정 상태에서만 노출되도록 게이팅이 필요합니다.회비 설정 여부에 따라 모달 초기 상태를 결정하는 로직 구현을 도와드릴까요? 원하시면 추적용 이슈를 열어드리겠습니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/admin/dues/DuesPageContent.tsx` around lines 141 - 142, The tutorial modal is always opening because tutorialOpen is initialized to true in DuesPageContent. Update the initial state and/or add a conditional gate so the modal only opens when the total dues information is not configured, using the existing state/control flow around tutorialOpen and setTutorialOpen. Make the visibility decision based on the dues setup status before rendering or on mount, rather than unconditionally showing it on page entry.src/components/admin/dues/setup/DuesSetupStep4.tsx (1)
84-84: 📐 Maintainability & Code Quality | 🔵 Trivial은행 선택 드롭다운 TODO.
bankName이 자유 입력 텍스트라 오타/표기 불일치로 이후 계좌 검증·매칭에 영향이 있을 수 있습니다. 드롭다운 구현을 도와드릴까요? 원하시면 이슈를 생성하겠습니다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/admin/dues/setup/DuesSetupStep4.tsx` at line 84, Replace the free-text bankName input in DuesSetupStep4 with a bank selection dropdown so values are standardized and less error-prone. Update the DuesSetupStep4 component’s bankName handling to use a predefined list of banks instead of arbitrary text entry, and keep the existing form state/validation flow intact when the selected value changes.src/components/admin/dues/setup/useDuesSetupNavigation.ts (1)
7-10: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
step을 1~5로 제한해 잘못된 setup 경로 생성을 막아주세요.지금
goToStep(step: number)는0이나6도 그대로 허용해서 존재하지 않는/setup/{step}로 push할 수 있습니다. 이 훅이 공용 진입점이라 여기서 막아두는 편이 안전합니다.제안 코드
+const DUES_SETUP_STEPS = [1, 2, 3, 4, 5] as const; +type DuesSetupStep = (typeof DUES_SETUP_STEPS)[number]; + function useDuesSetupNavigation() { const router = useRouter(); const { clubId } = useParams<{ clubId: string }>(); - const goToStep = (step: number) => router.push(`/${clubId}/admin/dues/setup/${step}`); + const goToStep = (step: DuesSetupStep) => + router.push(`/${clubId}/admin/dues/setup/${step}`); const goToDues = () => router.push(`/${clubId}/admin/dues`);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/admin/dues/setup/useDuesSetupNavigation.ts` around lines 7 - 10, `useDuesSetupNavigation`의 `goToStep`가 모든 숫자를 그대로 `router.push`해서 존재하지 않는 setup 경로를 만들 수 있습니다. `goToStep(step: number)`에서 1~5 범위만 허용하도록 검증하고, 범위를 벗어나면 이동하지 않거나 안전한 기본 경로로 처리하세요. `goToStep`와 `goToDues`는 그대로 두되, 공용 진입점인 이 훅 내부에서 잘못된 `/${clubId}/admin/dues/setup/${step}` 생성이 차단되도록 수정하세요.src/components/admin/dues/setup/components/DuesSetupStepIndicator.tsx (1)
6-19: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
currentStep도 실제 단계 값으로 고정해 주세요.지금은
number라0이나6이 들어와도 전부 비활성 상태로 렌더링됩니다.STEPS에서 타입을 파생시키면 호출부 실수를 컴파일 타임에 막을 수 있습니다.제안 코드
-const STEPS = [ +const STEPS = [ { step: 1, label: '기본 정보' }, { step: 2, label: '납부 대상' }, { step: 3, label: '이월 설정' }, { step: 4, label: '계좌 공개' }, { step: 5, label: '최종 확인' }, -]; +] as const; + +type DuesSetupStep = (typeof STEPS)[number]['step']; interface DuesSetupStepIndicatorProps { - currentStep: number; + currentStep: DuesSetupStep; className?: string; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/admin/dues/setup/components/DuesSetupStepIndicator.tsx` around lines 6 - 19, `DuesSetupStepIndicator`의 `currentStep` 타입이 너무 넓어서 `0`이나 `6` 같은 잘못된 값이 들어와도 컴파일에서 잡히지 않습니다. `STEPS` 배열에서 단계 값 타입을 파생해 `DuesSetupStepIndicatorProps.currentStep`을 실제 허용 단계로 제한하고, 컴포넌트와 호출부가 그 타입을 따르도록 정리해 주세요.src/app/globals.css (1)
559-564: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win공용 유틸에 새 px 상수를 직접 넣지 않는 편이 좋겠습니다.
tag-base가 공용 배지 스타일로 재사용되기 시작했는데24px와5px를 여기서 직접 고정하면 토큰 체계를 우회하게 됩니다.globals.css에 의미 있는 CSS 변수를 추가하고 이 유틸은 그 변수를 참조하도록 맞춰두는 편이 안전합니다.As per coding guidelines,
**/*.{ts,tsx,css}: Never hardcode design token values; always use token classes from design tokens, andsrc/app/globals.css: Define design tokens as CSS variables insrc/app/globals.css.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/globals.css` around lines 559 - 564, The tag-base utility in globals.css is hardcoding design values, so update it to use CSS variables instead of direct px values. Add meaningful design token variables in globals.css for the badge height and border radius, then change the tag-base styles to reference those variables. Keep the change localized to the shared utility and preserve the reusable styling through the existing tag-base selector.Source: Coding guidelines
src/components/ui/table.tsx (1)
81-81: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
bg-white대신 토큰 배경 클래스를 쓰는 편이 좋겠습니다.이 셀은 공용 table primitive라서 이후 테마 변경 영향을 크게 받습니다.
bg-white는 디자인 토큰 체계를 우회하니,globals.css에 매핑된 배경 토큰 클래스 중 하나로 맞춰두는 편이 안전합니다.As per coding guidelines,
**/*.{ts,tsx,css}: Never hardcode design token values; always use token classes from design tokens.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/ui/table.tsx` at line 81, The table cell styling in the shared table primitive currently hardcodes a white background, which bypasses the design token system. Update the class list in the table component to use the existing background token class from globals.css instead of bg-white, keeping the same component structure and styling intent while aligning with the design token guidelines.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@e2e/specs/landing.spec.ts`:
- Around line 13-22: The landing spec is waiting on
page.waitForLoadState('networkidle'), which is not a reliable signal for
framer-motion or UI readiness. Remove that wait in landing.spec.ts and instead
wait for the actual interactive element to be visible/ready before clicking,
using the existing getByRole selectors around the login flow (and the mobile
menu button when isMobile is true). Keep the Promise.all navigation pattern with
loginLink and page.waitForURL, but base readiness on UI visibility rather than
network idleness.
In `@src/app/globals.css`:
- Around line 557-570: The new `@utility` rule in globals.css is being flagged as
an unknown at-rule by Stylelint, so update the Stylelint configuration to allow
Tailwind v4’s `@utility` syntax. Adjust the relevant config entry that controls
at-rule validation, such as at-rule-no-unknown or its ignoreAtRules list, so
globals.css passes lint without treating `@utility` as an error.
In `@src/components/admin/dues/DuesSearchBar.tsx`:
- Around line 15-21: The search input in DuesSearchBar lacks an accessible name
because placeholder text is not reliable for assistive technologies. Update the
input element in DuesSearchBar to include an aria-label that describes the
search purpose, keeping the existing placeholder if desired so screen readers
can announce the field clearly.
In `@src/components/admin/dues/setup/components/CarryOverCard.tsx`:
- Around line 12-34: The carry-over choice UI is using plain buttons for
mutually exclusive selection, so the selected state is not exposed to assistive
tech. Update CarryOverCard to announce its state via the appropriate selection
semantics on the interactive element, and make the parent Step3 wrapper use a
radiogroup role so screen readers understand the relationship between options.
Use the existing selected, onClick, and title/description rendering in
CarryOverCard and the surrounding container to wire this up consistently.
In `@src/components/admin/dues/setup/components/DuesMemberTable.tsx`:
- Around line 65-80: The checkbox click in DuesMemberTable is triggering
toggleMember twice because both TableRow’s onClick and Checkbox’s
onCheckedChange fire for the same interaction. Update the DuesMemberTable row so
the checkbox interaction does not bubble to the row handler, using the existing
toggleMember and Checkbox/TableRow structure to locate the fix. Keep the row
click behavior for non-checkbox clicks, but stop event propagation from the
Checkbox cell so selection only toggles once.
In `@src/components/admin/dues/setup/components/DuesSearchBar.tsx`:
- Around line 11-20: Replace the hardcoded sizing classes in DuesSearchBar with
design token classes or CSS variables defined in src/app/globals.css, since
h-[48px], max-w-[339px], and pl-[52px] violate the token-based styling rule.
Update the wrapper div and input in DuesSearchBar to use existing spacing/sizing
tokens where possible; if no token exists, add the needed token in globals.css
first and then reference it from the TSX. Keep the same layout behavior while
removing all arbitrary px values.
- Around line 15-20: The search input in DuesSearchBar currently relies only on
placeholder text and removes the default focus ring with outline-none, so update
the input to include an accessible name via aria-label or a connected label and
restore a visible focus indicator using focus-visible styles while keeping the
existing search behavior intact.
In `@src/components/admin/dues/setup/DuesSetupStep2.tsx`:
- Around line 55-56: The pagination state in DuesSetupStep2 can drift past the
available results when filteredTargets shrinks, causing pagedTargets to become
empty and the table to look blank. Clamp page to the computed totalPages before
slicing, and use the normalized value consistently in DuesSetupStep2 so
DuesPagination receives the same currentPage value.
In `@src/components/admin/dues/setup/DuesSetupStep3.tsx`:
- Around line 35-42: The default carry-over selection in DuesSetupStep3’s
useEffect is inverted: it currently sets carryOverOption to 'none' when
hasPreviousBalance is true and 'carry' otherwise. Update the conditional in the
DuesSetupStep3 component so that a previous balance defaults to 'carry' and no
previous balance defaults to 'none', while keeping carryOverInitialized handling
unchanged.
In `@src/components/ui/pagination.tsx`:
- Around line 54-67: `PaginationPrevious`/`PaginationNext`가 시각적으로만 비활성화되고 키보드
포커스는 계속 가능한 상태입니다. `PaginationLink` 계층에서 `disabled`를 받을 수 있게 확장하고, 해당 상태일 때
`aria-disabled`와 `tabIndex={-1}`를 함께 적용하도록 처리하세요. `PaginationPrevious`,
`PaginationNext`, and `PaginationLink`의 props 전달 흐름을 정리해 재사용 가능한 비활성 링크 계약으로 맞추면
됩니다.
In `@src/stores/useDuesSetupStore.ts`:
- Around line 29-35: The persisted dues setup state is shared across clubs
because the store uses a fixed persist key in useDuesSetupStore, so draft
selections and initialization flags can leak between different clubId flows.
Update the persist configuration to scope the storage key by clubId (or another
club-specific identifier) so each club has isolated setup state, and ensure
transient fields like selectedMemberIds, account inputs, memberIdsInitialized,
and carryOverInitialized are either separated per club or excluded from
persistence as needed. Keep the fix within the persist/combine setup in
useDuesSetupStore so the Step 3 initialization logic remains correct per club.
---
Outside diff comments:
In `@src/components/admin/schedule/general/ScheduleTextField.tsx`:
- Around line 26-46: The ScheduleTextField input is missing accessibility state
and hides the default keyboard focus. In ScheduleTextField, keep a visible focus
treatment instead of relying on focus:outline-none alone, and add aria-invalid
plus an appropriate aria-describedby link when error text is rendered so
assistive tech can announce the invalid state. Use the input element and the
error/message block in ScheduleTextField to wire this up, and preserve the
existing maxLength counter behavior.
---
Nitpick comments:
In `@docs/로그/세션로그-JIN921-2026-06-25.md`:
- Around line 72-80: useDuesSetupStore의 persist로 인해 계좌번호/은행/예금주 같은 민감 정보가
localStorage에 평문 저장되는 문제가 있습니다. useDuesSetupStore와 Step 5 완료 흐름을 점검해 저장 시점을
최소화하고, 가능하면 sessionStorage 대안이나 민감 필드 비지속화 방식으로 바꾸세요. 또한 온보딩 완료 시 reset()이 실제로
호출되는지 최종 확인 로직에서 반드시 검증해, 저장된 금융 정보가 남지 않도록 처리하세요.
In `@src/app/globals.css`:
- Around line 559-564: The tag-base utility in globals.css is hardcoding design
values, so update it to use CSS variables instead of direct px values. Add
meaningful design token variables in globals.css for the badge height and border
radius, then change the tag-base styles to reference those variables. Keep the
change localized to the shared utility and preserve the reusable styling through
the existing tag-base selector.
In `@src/components/admin/dues/DuesPageContent.tsx`:
- Around line 141-142: The tutorial modal is always opening because tutorialOpen
is initialized to true in DuesPageContent. Update the initial state and/or add a
conditional gate so the modal only opens when the total dues information is not
configured, using the existing state/control flow around tutorialOpen and
setTutorialOpen. Make the visibility decision based on the dues setup status
before rendering or on mount, rather than unconditionally showing it on page
entry.
In `@src/components/admin/dues/modal/PaymentTargetModal.tsx`:
- Around line 50-51: Clamp the current page to the valid range before slicing
targets, because `page` can become larger than `totalPages` when
`selectedMemberIds` shrinks while the modal stays open. Update
`PaymentTargetModal` where `totalPages` and `pagedTargets` are derived so `page`
is bounded to 1..`totalPages` on render, and use that safe page value for the
slice and any pagination UI state.
In `@src/components/admin/dues/setup/components/DuesSetupStepIndicator.tsx`:
- Around line 6-19: `DuesSetupStepIndicator`의 `currentStep` 타입이 너무 넓어서 `0`이나 `6`
같은 잘못된 값이 들어와도 컴파일에서 잡히지 않습니다. `STEPS` 배열에서 단계 값 타입을 파생해
`DuesSetupStepIndicatorProps.currentStep`을 실제 허용 단계로 제한하고, 컴포넌트와 호출부가 그 타입을 따르도록
정리해 주세요.
In `@src/components/admin/dues/setup/DuesSetupStep4.tsx`:
- Line 84: Replace the free-text bankName input in DuesSetupStep4 with a bank
selection dropdown so values are standardized and less error-prone. Update the
DuesSetupStep4 component’s bankName handling to use a predefined list of banks
instead of arbitrary text entry, and keep the existing form state/validation
flow intact when the selected value changes.
In `@src/components/admin/dues/setup/useDuesSetupNavigation.ts`:
- Around line 7-10: `useDuesSetupNavigation`의 `goToStep`가 모든 숫자를 그대로
`router.push`해서 존재하지 않는 setup 경로를 만들 수 있습니다. `goToStep(step: number)`에서 1~5 범위만
허용하도록 검증하고, 범위를 벗어나면 이동하지 않거나 안전한 기본 경로로 처리하세요. `goToStep`와 `goToDues`는 그대로 두되,
공용 진입점인 이 훅 내부에서 잘못된 `/${clubId}/admin/dues/setup/${step}` 생성이 차단되도록 수정하세요.
In `@src/components/ui/table.tsx`:
- Line 81: The table cell styling in the shared table primitive currently
hardcodes a white background, which bypasses the design token system. Update the
class list in the table component to use the existing background token class
from globals.css instead of bg-white, keeping the same component structure and
styling intent while aligning with the design token guidelines.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: d729dd7e-5107-482d-9e16-3ea8a6f0617c
⛔ Files ignored due to path filters (1)
src/assets/image/dues_tutorial.pngis excluded by!**/*.png
📒 Files selected for processing (48)
docs/.obsidian/app.jsondocs/.obsidian/appearance.jsondocs/.obsidian/core-plugins.jsondocs/.obsidian/graph.jsondocs/.obsidian/workspace.jsondocs/로그/세션로그-JIN921-2026-06-25.mde2e/specs/landing.spec.tssrc/app/(private)/[clubId]/admin/dues/setup/1/page.tsxsrc/app/(private)/[clubId]/admin/dues/setup/2/page.tsxsrc/app/(private)/[clubId]/admin/dues/setup/3/page.tsxsrc/app/(private)/[clubId]/admin/dues/setup/4/page.tsxsrc/app/(private)/[clubId]/admin/dues/setup/5/page.tsxsrc/app/globals.csssrc/components/admin/dues/BackButton.tsxsrc/components/admin/dues/DuesMemberPaymentTable.tsxsrc/components/admin/dues/DuesPageContent.tsxsrc/components/admin/dues/DuesPaymentStatusPageContent.tsxsrc/components/admin/dues/DuesSearchBar.tsxsrc/components/admin/dues/DuesTransactionTable.tsxsrc/components/admin/dues/index.tssrc/components/admin/dues/modal/DuesTutorialModal.tsxsrc/components/admin/dues/modal/PaymentTargetModal.tsxsrc/components/admin/dues/setup/DuesSetupStep1.tsxsrc/components/admin/dues/setup/DuesSetupStep2.tsxsrc/components/admin/dues/setup/DuesSetupStep3.tsxsrc/components/admin/dues/setup/DuesSetupStep4.tsxsrc/components/admin/dues/setup/DuesSetupStep5.tsxsrc/components/admin/dues/setup/components/CarryOverCard.tsxsrc/components/admin/dues/setup/components/DuesMemberTable.tsxsrc/components/admin/dues/setup/components/DuesPagination.tsxsrc/components/admin/dues/setup/components/DuesSearchBar.tsxsrc/components/admin/dues/setup/components/DuesSetupStepIndicator.tsxsrc/components/admin/dues/setup/components/DuesTabs.tsxsrc/components/admin/dues/setup/components/FormCard.tsxsrc/components/admin/dues/setup/components/NextButton.tsxsrc/components/admin/dues/setup/components/PrevButton.tsxsrc/components/admin/dues/setup/components/SettingResultCardGrid.tsxsrc/components/admin/dues/setup/components/index.tssrc/components/admin/dues/setup/index.tssrc/components/admin/dues/setup/useDuesSetupNavigation.tssrc/components/admin/schedule/general/ScheduleTextField.tsxsrc/components/ui/Checkbox.tsxsrc/components/ui/index.tssrc/components/ui/pagination.tsxsrc/components/ui/table.tsxsrc/constants/mock.tssrc/stores/index.tssrc/stores/useDuesSetupStore.ts
💤 Files with no reviewable changes (5)
- docs/.obsidian/app.json
- docs/.obsidian/appearance.json
- docs/.obsidian/graph.json
- docs/.obsidian/workspace.json
- docs/.obsidian/core-plugins.json
| // hydration 완료 후 framer-motion 헤더 애니메이션이 안정화될 때까지 대기 | ||
| await page.waitForLoadState('networkidle'); | ||
|
|
||
| if (isMobile) { | ||
| // 모바일: 로그인 링크가 Sheet 안에 있으므로 햄버거 메뉴 먼저 오픈 | ||
| await page.getByRole('button', { name: '메뉴 열기' }).click(); | ||
| } | ||
|
|
||
| const loginLink = page.getByRole('link', { name: '로그인' }); | ||
| await loginLink.waitFor({ state: 'visible' }); | ||
| await Promise.all([page.waitForURL(/\/login/), loginLink.click()]); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major
waitForLoadState('networkidle') 제거하고 UI 가시성 대기로 전환 필요
Line 14 의 waitForLoadState('networkidle')는 네트워크 유휴 상태만 판단할 뿐, 주석의 의도인 framer-motion 애니메이션 완료나 UI 상호작용 준비와는 직접적인 연관성이 없습니다. 네트워크 요청이 계속되거나 prefetch 등으로 인해 불필요한 대기가 발생하면 테스트가 불안정해질 수 있습니다. Playwright 의 자동 대기 기능을 활용하여 실제 조작 대상의 가시성을 기다리는 방식이 더 안정적입니다.
제안된 수정
- // hydration 완료 후 framer-motion 헤더 애니메이션이 안정화될 때까지 대기
- await page.waitForLoadState('networkidle');
-
- if (isMobile) {
+ const loginLink = page.getByRole('link', { name: '로그인' });
+
+ if (isMobile) {
// 모바일: 로그인 링크가 Sheet 안에 있으므로 햄버거 메뉴 먼저 오픈
- await page.getByRole('button', { name: '메뉴 열기' }).click();
+ const menuButton = page.getByRole('button', { name: '메뉴 열기' });
+ await expect(menuButton).toBeVisible();
+ await menuButton.click();
}
- const loginLink = page.getByRole('link', { name: '로그인' });
+ await expect(loginLink).toBeVisible();
await Promise.all([page.waitForURL(/\/login/), loginLink.click()]);📝 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.
| // hydration 완료 후 framer-motion 헤더 애니메이션이 안정화될 때까지 대기 | |
| await page.waitForLoadState('networkidle'); | |
| if (isMobile) { | |
| // 모바일: 로그인 링크가 Sheet 안에 있으므로 햄버거 메뉴 먼저 오픈 | |
| await page.getByRole('button', { name: '메뉴 열기' }).click(); | |
| } | |
| const loginLink = page.getByRole('link', { name: '로그인' }); | |
| await loginLink.waitFor({ state: 'visible' }); | |
| await Promise.all([page.waitForURL(/\/login/), loginLink.click()]); | |
| const loginLink = page.getByRole('link', { name: '로그인' }); | |
| if (isMobile) { | |
| // 모바일: 로그인 링크가 Sheet 안에 있으므로 햄버거 메뉴 먼저 오픈 | |
| const menuButton = page.getByRole('button', { name: '메뉴 열기' }); | |
| await expect(menuButton).toBeVisible(); | |
| await menuButton.click(); | |
| } | |
| await expect(loginLink).toBeVisible(); | |
| await Promise.all([page.waitForURL(/\/login/), loginLink.click()]); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@e2e/specs/landing.spec.ts` around lines 13 - 22, The landing spec is waiting
on page.waitForLoadState('networkidle'), which is not a reliable signal for
framer-motion or UI readiness. Remove that wait in landing.spec.ts and instead
wait for the actual interactive element to be visible/ready before clicking,
using the existing getByRole selectors around the login flow (and the mobile
menu button when isMobile is true). Keep the Promise.all navigation pattern with
loginLink and page.waitForURL, but base readiness on UI visibility rather than
network idleness.
| @utility tag-base { | ||
| display: inline-flex; | ||
| height: 24px; | ||
| align-items: center; | ||
| justify-content: center; | ||
| border-radius: 5px; | ||
| padding-inline: var(--spacing-200); | ||
| padding-block: var(--spacing-100); | ||
| white-space: nowrap; | ||
| font-size: var(--caption1-size); | ||
| line-height: var(--caption1-line-height); | ||
| font-weight: var(--font-weight-semibold); | ||
| letter-spacing: var(--letter-spacing); | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
@utility 추가만으로는 이 파일이 현재 lint-broken 상태입니다.
Tailwind v4 문법 의도는 이해되지만, 현재 정적 분석이 Line 557의 @utility를 unknown at-rule로 실패시키고 있습니다. Stylelint 설정에서 이 at-rule을 허용하지 않으면 PR이 계속 검사 단계에서 막힙니다.
#!/bin/bash
fd -HI 'stylelint*' . -x sed -n '1,220p' {}
rg -n '`@utility`|at-rule-no-unknown|scss/at-rule-no-unknown|ignoreAtRules' .🧰 Tools
🪛 Stylelint (17.13.0)
[error] 557-557: Unexpected unknown at-rule "@utility" (scss/at-rule-no-unknown)
(scss/at-rule-no-unknown)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/globals.css` around lines 557 - 570, The new `@utility` rule in
globals.css is being flagged as an unknown at-rule by Stylelint, so update the
Stylelint configuration to allow Tailwind v4’s `@utility` syntax. Adjust the
relevant config entry that controls at-rule validation, such as
at-rule-no-unknown or its ignoreAtRules list, so globals.css passes lint without
treating `@utility` as an error.
| <input | ||
| type="text" | ||
| value={searchQuery} | ||
| onChange={(e) => setSearchQuery(e.target.value)} | ||
| placeholder="이름으로 검색하기" | ||
| className="typo-body2 placeholder:text-text-alternative text-text-strong h-full w-full bg-transparent pr-400 pl-[52px] outline-none" | ||
| /> |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
검색 입력에 접근 가능한 레이블이 없습니다.
placeholder는 보조기기에서 신뢰할 수 있는 접근성 이름으로 취급되지 않습니다. 스크린리더 사용자를 위해 aria-label을 추가하세요.
♻️ 제안 수정
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="이름으로 검색하기"
+ aria-label="이름으로 검색하기"
className="typo-body2 placeholder:text-text-alternative text-text-strong h-full w-full bg-transparent pr-400 pl-[52px] outline-none"
/>📝 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.
| <input | |
| type="text" | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| placeholder="이름으로 검색하기" | |
| className="typo-body2 placeholder:text-text-alternative text-text-strong h-full w-full bg-transparent pr-400 pl-[52px] outline-none" | |
| /> | |
| <input | |
| type="text" | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| placeholder="이름으로 검색하기" | |
| aria-label="이름으로 검색하기" | |
| className="typo-body2 placeholder:text-text-alternative text-text-strong h-full w-full bg-transparent pr-400 pl-[52px] outline-none" | |
| /> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/admin/dues/DuesSearchBar.tsx` around lines 15 - 21, The search
input in DuesSearchBar lacks an accessible name because placeholder text is not
reliable for assistive technologies. Update the input element in DuesSearchBar
to include an aria-label that describes the search purpose, keeping the existing
placeholder if desired so screen readers can announce the field clearly.
| <button | ||
| type="button" | ||
| onClick={onClick} | ||
| className={cn( | ||
| 'flex flex-1 cursor-pointer items-center justify-between rounded-lg border p-400 text-left transition-colors', | ||
| selected ? 'border-brand-primary' : 'border-border', | ||
| )} | ||
| > | ||
| <div className="flex flex-col gap-100"> | ||
| <span className={cn('typo-sub3', selected ? 'text-brand-primary' : 'text-text-normal')}> | ||
| {title} | ||
| </span> | ||
| {selected && <span className="typo-caption2 text-text-alternative">{description}</span>} | ||
| </div> | ||
| <div | ||
| className={cn( | ||
| 'flex size-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors', | ||
| selected ? 'border-brand-primary' : 'border-border', | ||
| )} | ||
| > | ||
| {selected && <div className="bg-brand-primary size-2.5 rounded-full" />} | ||
| </div> | ||
| </button> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
선택 상태를 보조기기에 전달해 주세요.
이 컴포넌트는 Step3에서 상호배타 선택지로 쓰이는데 지금은 단순 button이라 스크린리더가 어떤 항목이 선택됐는지 알 수 없습니다. 최소한 선택 상태를 노출하고, 상위 컨테이너도 radiogroup으로 맞춰주는 게 좋습니다.
제안 코드
<button
type="button"
onClick={onClick}
+ role="radio"
+ aria-checked={selected}
className={cn(
'flex flex-1 cursor-pointer items-center justify-between rounded-lg border p-400 text-left transition-colors',
selected ? 'border-brand-primary' : 'border-border',
)}상위 래퍼에는 role="radiogroup"도 함께 추가해 주세요.
📝 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.
| <button | |
| type="button" | |
| onClick={onClick} | |
| className={cn( | |
| 'flex flex-1 cursor-pointer items-center justify-between rounded-lg border p-400 text-left transition-colors', | |
| selected ? 'border-brand-primary' : 'border-border', | |
| )} | |
| > | |
| <div className="flex flex-col gap-100"> | |
| <span className={cn('typo-sub3', selected ? 'text-brand-primary' : 'text-text-normal')}> | |
| {title} | |
| </span> | |
| {selected && <span className="typo-caption2 text-text-alternative">{description}</span>} | |
| </div> | |
| <div | |
| className={cn( | |
| 'flex size-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors', | |
| selected ? 'border-brand-primary' : 'border-border', | |
| )} | |
| > | |
| {selected && <div className="bg-brand-primary size-2.5 rounded-full" />} | |
| </div> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={onClick} | |
| role="radio" | |
| aria-checked={selected} | |
| className={cn( | |
| 'flex flex-1 cursor-pointer items-center justify-between rounded-lg border p-400 text-left transition-colors', | |
| selected ? 'border-brand-primary' : 'border-border', | |
| )} | |
| > | |
| <div className="flex flex-col gap-100"> | |
| <span className={cn('typo-sub3', selected ? 'text-brand-primary' : 'text-text-normal')}> | |
| {title} | |
| </span> | |
| {selected && <span className="typo-caption2 text-text-alternative">{description}</span>} | |
| </div> | |
| <div | |
| className={cn( | |
| 'flex size-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors', | |
| selected ? 'border-brand-primary' : 'border-border', | |
| )} | |
| > | |
| {selected && <div className="bg-brand-primary size-2.5 rounded-full" />} | |
| </div> | |
| </button> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/admin/dues/setup/components/CarryOverCard.tsx` around lines 12
- 34, The carry-over choice UI is using plain buttons for mutually exclusive
selection, so the selected state is not exposed to assistive tech. Update
CarryOverCard to announce its state via the appropriate selection semantics on
the interactive element, and make the parent Step3 wrapper use a radiogroup role
so screen readers understand the relationship between options. Use the existing
selected, onClick, and title/description rendering in CarryOverCard and the
surrounding container to wire this up consistently.
| <TableRow | ||
| key={targetId} | ||
| className={readOnly ? undefined : 'cursor-pointer'} | ||
| onClick={readOnly ? undefined : () => toggleMember?.(clubMemberId)} | ||
| > | ||
| {!readOnly && ( | ||
| <TableCell className="text-center"> | ||
| <Checkbox | ||
| color="primary" | ||
| id={`select-member-${clubMemberId}`} | ||
| name={`select-member-${clubMemberId}`} | ||
| checked={isSelected} | ||
| onCheckedChange={() => toggleMember?.(clubMemberId)} | ||
| /> | ||
| </TableCell> | ||
| )} |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
fd -t f 'Checkbox.tsx' src/components/ui --exec cat -n {}Repository: Team-Weeth/weeth-client
Length of output: 1997
체크박스 클릭 시 토글이 두 번 실행되어 선택 상태가 변경되지 않습니다.
TableRow의 onClick과 Checkbox의 onCheckedChange가 동일한 toggleMember를 호출합니다. 체크박스를 클릭하면 이벤트가 셀을 거쳐 TableRow로 전파되므로 핸들러가 두 번 실행되어 선택 상태가 반전되다가 다시 원상복구됩니다.
src/components/ui/Checkbox.tsx는 Radix UI 위rapper로 내부에서 이벤트 전파를 차단하지 않습니다. 따라서 체크박스 셀 단에서 이벤트 전파를 막아야 합니다.
🐛 제안 수정
{!readOnly && (
- <TableCell className="text-center">
+ <TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
color="primary"
id={`select-member-${clubMemberId}`}
name={`select-member-${clubMemberId}`}
checked={isSelected}
onCheckedChange={() => toggleMember?.(clubMemberId)}
/>
</TableCell>
)}📝 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.
| <TableRow | |
| key={targetId} | |
| className={readOnly ? undefined : 'cursor-pointer'} | |
| onClick={readOnly ? undefined : () => toggleMember?.(clubMemberId)} | |
| > | |
| {!readOnly && ( | |
| <TableCell className="text-center"> | |
| <Checkbox | |
| color="primary" | |
| id={`select-member-${clubMemberId}`} | |
| name={`select-member-${clubMemberId}`} | |
| checked={isSelected} | |
| onCheckedChange={() => toggleMember?.(clubMemberId)} | |
| /> | |
| </TableCell> | |
| )} | |
| <TableRow | |
| key={targetId} | |
| className={readOnly ? undefined : 'cursor-pointer'} | |
| onClick={readOnly ? undefined : () => toggleMember?.(clubMemberId)} | |
| > | |
| {!readOnly && ( | |
| <TableCell className="text-center" onClick={(e) => e.stopPropagation()}> | |
| <Checkbox | |
| color="primary" | |
| id={`select-member-${clubMemberId}`} | |
| name={`select-member-${clubMemberId}`} | |
| checked={isSelected} | |
| onCheckedChange={() => toggleMember?.(clubMemberId)} | |
| /> | |
| </TableCell> | |
| )} |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/admin/dues/setup/components/DuesMemberTable.tsx` around lines
65 - 80, The checkbox click in DuesMemberTable is triggering toggleMember twice
because both TableRow’s onClick and Checkbox’s onCheckedChange fire for the same
interaction. Update the DuesMemberTable row so the checkbox interaction does not
bubble to the row handler, using the existing toggleMember and Checkbox/TableRow
structure to locate the fix. Keep the row click behavior for non-checkbox
clicks, but stop event propagation from the Checkbox cell so selection only
toggles once.
| <input | ||
| type="text" | ||
| value={searchQuery} | ||
| onChange={(e) => setSearchQuery(e.target.value)} | ||
| placeholder="이름으로 검색하기" | ||
| className="typo-body2 placeholder:text-text-alternative text-text-strong h-full w-full bg-transparent pr-400 pl-[52px] outline-none" |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
검색 입력의 접근성 이름과 포커스 표시가 없습니다.
현재 입력은 placeholder만 있고 outline-none으로 기본 포커스 표시까지 제거해서, 스크린리더와 키보드 사용자 모두 탐색성이 떨어집니다. aria-label(또는 연결된 <label>)을 추가하고, 토큰 기반의 focus-visible 스타일을 남겨 주세요.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/admin/dues/setup/components/DuesSearchBar.tsx` around lines 15
- 20, The search input in DuesSearchBar currently relies only on placeholder
text and removes the default focus ring with outline-none, so update the input
to include an accessible name via aria-label or a connected label and restore a
visible focus indicator using focus-visible styles while keeping the existing
search behavior intact.
| const totalPages = Math.max(1, Math.ceil(filteredTargets.length / PAGE_SIZE)); | ||
| const pagedTargets = filteredTargets.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
필터 결과가 줄어들면 빈 페이지가 표시될 수 있습니다.
page가 totalPages로 제한되지 않습니다. 예를 들어 '선택됨' 탭에서 멤버를 해제하면 filteredTargets가 줄어들어 현재 page가 totalPages를 초과할 수 있고, 이때 pagedTargets가 빈 배열이 되어 테이블이 비어 보입니다.
page를 totalPages 범위로 보정하세요.
🐛 제안 수정
const totalPages = Math.max(1, Math.ceil(filteredTargets.length / PAGE_SIZE));
- const pagedTargets = filteredTargets.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
+ const currentPage = Math.min(page, totalPages);
+ const pagedTargets = filteredTargets.slice(
+ (currentPage - 1) * PAGE_SIZE,
+ currentPage * PAGE_SIZE,
+ );DuesPagination에 전달하는 page 값도 currentPage로 맞추는 것을 함께 고려하세요.
📝 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.
| const totalPages = Math.max(1, Math.ceil(filteredTargets.length / PAGE_SIZE)); | |
| const pagedTargets = filteredTargets.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); | |
| const totalPages = Math.max(1, Math.ceil(filteredTargets.length / PAGE_SIZE)); | |
| const currentPage = Math.min(page, totalPages); | |
| const pagedTargets = filteredTargets.slice( | |
| (currentPage - 1) * PAGE_SIZE, | |
| currentPage * PAGE_SIZE, | |
| ); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/admin/dues/setup/DuesSetupStep2.tsx` around lines 55 - 56, The
pagination state in DuesSetupStep2 can drift past the available results when
filteredTargets shrinks, causing pagedTargets to become empty and the table to
look blank. Clamp page to the computed totalPages before slicing, and use the
normalized value consistently in DuesSetupStep2 so DuesPagination receives the
same currentPage value.
| useEffect(() => { | ||
| if (!carryOverInitialized) { | ||
| setField({ | ||
| carryOverOption: hasPreviousBalance ? 'none' : 'carry', | ||
| carryOverInitialized: true, | ||
| }); | ||
| } | ||
| }, [carryOverInitialized, hasPreviousBalance, setField]); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor
이월 기본값 로직이 반전되어 있어 수정이 필요합니다.
현재 useEffect 는 잔액이 있을 때 (hasPreviousBalance === true) 기본값을 'none'(이월하지 않음) 으로, 잔액이 없을 때 'carry'(이월함) 로 설정합니다.
잔액이 존재하는 상황에서 이월을 기본값으로 두는 것이 일반적인 비즈니스 로직이며, 잔액이 없는 경우 이월은 무의미합니다. 따라서 조건식을 반전시켜야 합니다.
- carryOverOption: hasPreviousBalance ? 'none' : 'carry',
+ carryOverOption: hasPreviousBalance ? 'carry' : 'none',📝 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.
| useEffect(() => { | |
| if (!carryOverInitialized) { | |
| setField({ | |
| carryOverOption: hasPreviousBalance ? 'none' : 'carry', | |
| carryOverInitialized: true, | |
| }); | |
| } | |
| }, [carryOverInitialized, hasPreviousBalance, setField]); | |
| useEffect(() => { | |
| if (!carryOverInitialized) { | |
| setField({ | |
| carryOverOption: hasPreviousBalance ? 'carry' : 'none', | |
| carryOverInitialized: true, | |
| }); | |
| } | |
| }, [carryOverInitialized, hasPreviousBalance, setField]); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/admin/dues/setup/DuesSetupStep3.tsx` around lines 35 - 42, The
default carry-over selection in DuesSetupStep3’s useEffect is inverted: it
currently sets carryOverOption to 'none' when hasPreviousBalance is true and
'carry' otherwise. Update the conditional in the DuesSetupStep3 component so
that a previous balance defaults to 'carry' and no previous balance defaults to
'none', while keeping carryOverInitialized handling unchanged.
| function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) { | ||
| return ( | ||
| <PaginationLink aria-label="이전 페이지" className={cn('size-8', className)} {...props}> | ||
| <ChevronLeftIcon className="size-4" /> | ||
| </PaginationLink> | ||
| ); | ||
| } | ||
|
|
||
| function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) { | ||
| return ( | ||
| <PaginationLink aria-label="다음 페이지" className={cn('size-8', className)} {...props}> | ||
| <ChevronRightIcon className="size-4" /> | ||
| </PaginationLink> | ||
| ); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
비활성 페이지 링크가 여전히 포커스 가능한 링크로 남습니다.
현재 src/components/admin/dues/setup/components/DuesPagination.tsx는 첫/마지막 페이지에서 pointer-events-none opacity-40만 넘겨 이전/다음 버튼을 막고 있습니다. 그래서 이 버튼들은 시각적으로만 비활성화되고, 키보드 포커스와 스크린리더에는 계속 활성 링크로 노출됩니다. PaginationLink 계층에서 disabled를 받아 aria-disabled와 tabIndex={-1}까지 같이 처리하는 쪽이 재사용 컴포넌트 계약에 맞습니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/ui/pagination.tsx` around lines 54 - 67,
`PaginationPrevious`/`PaginationNext`가 시각적으로만 비활성화되고 키보드 포커스는 계속 가능한 상태입니다.
`PaginationLink` 계층에서 `disabled`를 받을 수 있게 확장하고, 해당 상태일 때 `aria-disabled`와
`tabIndex={-1}`를 함께 적용하도록 처리하세요. `PaginationPrevious`, `PaginationNext`, and
`PaginationLink`의 props 전달 흐름을 정리해 재사용 가능한 비활성 링크 계약으로 맞추면 됩니다.
| devtools( | ||
| persist( | ||
| combine(initialState, (set) => ({ | ||
| setField: (field: Partial<DuesSetupState>) => set(field, false, 'setField'), | ||
| reset: () => set(initialState, false, 'reset'), | ||
| })), | ||
| { name: 'duesSetup' }, |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
클럽 간에 설정 초안이 섞일 수 있습니다.
persist 키가 고정(duesSetup)이라 /[clubId]/admin/dues/setup/*의 진행 상태가 클럽별로 분리되지 않습니다. 지금처럼 selectedMemberIds, 계좌 정보, memberIdsInitialized, carryOverInitialized까지 함께 저장하면 다른 clubId로 들어가도 이전 클럽의 선택값이 재사용되고, Step 3의 초기화 가드도 다시 돌지 않습니다. 이 플로우는 clubId 기준으로 저장 키를 분리하거나, 최소한 초기화 플래그/임시 입력값은 persist 대상에서 제외해야 합니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/stores/useDuesSetupStore.ts` around lines 29 - 35, The persisted dues
setup state is shared across clubs because the store uses a fixed persist key in
useDuesSetupStore, so draft selections and initialization flags can leak between
different clubId flows. Update the persist configuration to scope the storage
key by clubId (or another club-specific identifier) so each club has isolated
setup state, and ensure transient fields like selectedMemberIds, account inputs,
memberIdsInitialized, and carryOverInitialized are either separated per club or
excluded from persistence as needed. Keep the fix within the persist/combine
setup in useDuesSetupStore so the Step 3 initialization logic remains correct
per club.
nabbang6
left a comment
There was a problem hiding this comment.
확인했습니다~~! 고생하셨어용 👍👍
회비 페이지 온보딩까지 넘 깔끔하고 예뿌네요 짱짱....
| ); | ||
|
|
||
| const displayedAvatars = selectedTargets.slice(0, MAX_AVATAR_DISPLAY); | ||
| const remainingCount = selectedTargets.length - MAX_AVATAR_DISPLAY; |
There was a problem hiding this comment.
요기 remainingCount에 음수는 들어가지 못하게 Math.max(0, ...) 처리해주면 쪼금 더 안전하게 쓸 수 있을 것 같습니당!
| } from '@/components/admin/dues/setup/components'; | ||
| import { useDuesSetupNavigation } from '@/components/admin/dues/setup/useDuesSetupNavigation'; | ||
|
|
||
| import { ScheduleTextareaField } from '@/components/admin/schedule/general/ScheduleTextareaField'; |
There was a problem hiding this comment.
여기 미사용된 임포트는 제거해주셔도 될 것 같아요~!
| import { | ||
| Avatar, | ||
| AvatarFallback, | ||
| Table, | ||
| TableBody, | ||
| TableCell, | ||
| TableHead, | ||
| TableHeader, | ||
| TableRow, | ||
| } from '@/components/ui'; | ||
| import { Checkbox } from '@/components/ui'; |
There was a problem hiding this comment.
별건 아니지만,,, 여기 Checkbox 임포트 윗줄이랑 합쳐서 적어주면 좋을 것 같아용!
| const byTab = | ||
| tab === 'selected' | ||
| ? MOCK_PAYMENT_TARGETS.filter((t) => selectedSet.has(t.paymentTargetInfo.clubMemberId)) | ||
| : MOCK_PAYMENT_TARGETS.filter((t) => !selectedSet.has(t.paymentTargetInfo.clubMemberId)); | ||
| const filteredTargets = search.trim() | ||
| ? byTab.filter((t) => t.paymentTargetInfo.name.includes(search.trim())) | ||
| : byTab; | ||
|
|
||
| const totalPages = Math.max(1, Math.ceil(filteredTargets.length / PAGE_SIZE)); | ||
| const pagedTargets = filteredTargets.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); | ||
|
|
||
| const handleTabChange = (next: TabType) => { | ||
| setTab(next); | ||
| setPage(1); | ||
| }; | ||
|
|
||
| const handleSearch = (value: string) => { | ||
| setSearch(value); | ||
| setPage(1); | ||
| }; |
There was a problem hiding this comment.
해당 코드에서 탭 필터링 + 검색 + 페이지네이션 로직은 DuesSetupStep2에서도 유사하게 사용되고 잇는 것 같습니다! usePaymentTargetFilter 같은 훅으로 추출해서 사용하게 해주면 더 깔끔할 것 같아용,, b
There was a problem hiding this comment.
온보딩 스텝 상태 관리 로직인 만큼 중요도가 높은 것 같아서,, 요 부분은 테스트 작성해두면 쪼금 더 안전하게 사용할 수 있을 것 같아요!
다만 추후 api 연동하시묜서 변동 잇을 수도 잇으니,,, 그때 작성해주셔도 괜찮을 것 같긴 합니당 ,,👍
| className={cn( | ||
| 'typo-body2 cursor-pointer rounded-sm border px-300 py-200 transition-colors', | ||
| activeTab === key | ||
| ? 'bg-container-neutral-alternative text-text-strong border-transparent' | ||
| : 'border-border text-text-alternative bg-transparent', | ||
| )} |
There was a problem hiding this comment.
요기 피그마에선 타이포가 button2로 되어 잇는 것 같아요!
| <ScheduleTextField | ||
| label="계좌번호" | ||
| value={accountNumber} | ||
| onChange={(value) => { | ||
| setField({ accountNumber: value }); | ||
| if (errors.accountNumber) | ||
| setErrors((prev) => ({ ...prev, accountNumber: undefined })); | ||
| }} | ||
| placeholder="계좌번호를 입력해주세요" | ||
| maxLength={20} | ||
| error={errors.accountNumber} | ||
| className="bg-container-neutral-alternative" | ||
| /> |
There was a problem hiding this comment.
요기 계좌번호 입력란에 숫자랑 '-' 기호만 입력 가능하도록 필터링 추가해주심 좋을 것 같아요!
| return ( | ||
| <Card className="shadow-none"> | ||
| <div className="flex items-center justify-between"> | ||
| <span className="typo-sub3 text-text-strong">{title}</span> |
There was a problem hiding this comment.
여기 인포카드 타이틀도 typo-sub1, text-normal 색상으로 바꿔주셔야 될 것 같습니당!
| <span className="typo-body2 text-text-alternative">{label}</span> | ||
| <span className={cn('typo-body2 text-text-strong', valueClassName)}>{value}</span> | ||
| </div> |
There was a problem hiding this comment.
label 타이포는 sub3으로, value 타이포도 sub3 + text-alternative로 바꿔주심 좋을 것 같아용!
| {remainingCount > 0 && ( | ||
| <AvatarGroupCount className="size-6 text-xs">+{remainingCount}</AvatarGroupCount> | ||
| )} |
✅ PR 유형
어떤 변경 사항이 있었나요?
📌 관련 이슈번호
✅ Key Changes
회비 설정 온보딩 5단계 (
/[clubId]/admin/dues/setup/1~5/)DuesMemberTable+PaymentTargetModal(멤버 선택)SettingResultCardGrid로 결과 요약 표시설정 공통 컴포넌트
DuesSetupStepIndicator— 현재 진행 단계 시각 표시기DuesMemberTable— 멤버 선택 테이블 (검색, 페이지네이션, 체크박스)NextButton/PrevButton— 단계 이동 버튼FormCard/CarryOverCard— 입력 폼 카드DuesPagination— 멤버 테이블 페이지네이션DuesTabs— 납부 대상 탭 컴포넌트SettingResultCardGrid— 설정 결과 요약 그리드BackButton— 회비 메인으로 돌아가는 뒤로가기 버튼모달
DuesTutorialModal— 회비 최초 진입 시 설정 안내 모달PaymentTargetModal— Step2에서 납부 대상 멤버를 선택하는 모달공유 UI 컴포넌트 추가
Checkbox(src/components/ui/checkbox.tsx) — shadcn/ui 기반 체크박스Pagination(src/components/ui/pagination.tsx) — 공용 페이지네이션상태 관리
useDuesSetupStore(Zustand) — 설정 전체 상태 전역 관리useDuesSetupNavigation— 설정 단계 간 라우팅 훅📸 스크린샷 or 실행영상
🎸 기타 사항 or 추가 코멘트
제가 또 실수를 했습니다.... 납부 현황 브랜치에서 작업을 하고 push 한 다음에 알아차려서 복구를 열심히 했습니다...... 그래서 이게 #131 PR을 베이스로 만들어졋습니다.. 걔가 머지되어야 file changed가 저렇게 날뛰지 않을 것 입니다.. ㅜㅜㅜ #131 PR 머지가 끝나면 리뷰 부탁드려용...머지 완료.. 온보딩 페이지가 많아서 변경사항이 많앗던 것이엇네요... 쪼개서 작업하기두 애매해 가지고... ㅜㅜ 지송합니다
Summary by CodeRabbit
New Features
Bug Fixes