diff --git a/.gitignore b/.gitignore
index 13cd602a..f53b81b0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,6 +51,5 @@ next-env.d.ts
# Sentry Config File
.env.sentry-build-plugin
-# Obsidian per-user state
-docs/.obsidian/workspace.json
-docs/.obsidian/workspaces.json
+# Obsidian
+docs/.obsidian/
diff --git a/docs/.obsidian/app.json b/docs/.obsidian/app.json
deleted file mode 100644
index 9e26dfee..00000000
--- a/docs/.obsidian/app.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
\ No newline at end of file
diff --git a/docs/.obsidian/appearance.json b/docs/.obsidian/appearance.json
deleted file mode 100644
index 9e26dfee..00000000
--- a/docs/.obsidian/appearance.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
\ No newline at end of file
diff --git a/docs/.obsidian/core-plugins.json b/docs/.obsidian/core-plugins.json
deleted file mode 100644
index 639b90da..00000000
--- a/docs/.obsidian/core-plugins.json
+++ /dev/null
@@ -1,33 +0,0 @@
-{
- "file-explorer": true,
- "global-search": true,
- "switcher": true,
- "graph": true,
- "backlink": true,
- "canvas": true,
- "outgoing-link": true,
- "tag-pane": true,
- "footnotes": false,
- "properties": true,
- "page-preview": true,
- "daily-notes": true,
- "templates": true,
- "note-composer": true,
- "command-palette": true,
- "slash-command": false,
- "editor-status": true,
- "bookmarks": true,
- "markdown-importer": false,
- "zk-prefixer": false,
- "random-note": false,
- "outline": true,
- "word-count": true,
- "slides": false,
- "audio-recorder": false,
- "workspaces": false,
- "file-recovery": true,
- "publish": false,
- "sync": true,
- "bases": true,
- "webviewer": false
-}
\ No newline at end of file
diff --git a/docs/.obsidian/graph.json b/docs/.obsidian/graph.json
deleted file mode 100644
index 99fcd90d..00000000
--- a/docs/.obsidian/graph.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "collapse-filter": true,
- "search": "",
- "showTags": false,
- "showAttachments": false,
- "hideUnresolved": false,
- "showOrphans": true,
- "collapse-color-groups": true,
- "colorGroups": [],
- "collapse-display": true,
- "showArrow": false,
- "textFadeMultiplier": 0,
- "nodeSizeMultiplier": 1,
- "lineSizeMultiplier": 1,
- "collapse-forces": true,
- "centerStrength": 0.518713248970312,
- "repelStrength": 10,
- "linkStrength": 1,
- "linkDistance": 250,
- "scale": 0.5676700243769868,
- "close": true
-}
diff --git a/docs/.obsidian/workspace.json b/docs/.obsidian/workspace.json
deleted file mode 100644
index 7a61dc06..00000000
--- a/docs/.obsidian/workspace.json
+++ /dev/null
@@ -1,240 +0,0 @@
-{
- "main": {
- "id": "0563b9a82ac05b0b",
- "type": "split",
- "children": [
- {
- "id": "2b30149d6d88e636",
- "type": "tabs",
- "children": [
- {
- "id": "c739667fb5d88b4c",
- "type": "leaf",
- "state": {
- "type": "markdown",
- "state": {
- "file": "로그/README.md",
- "mode": "source",
- "source": false
- },
- "icon": "lucide-file",
- "title": "README"
- }
- }
- ]
- },
- {
- "id": "8d6dd3c261880ad5",
- "type": "tabs",
- "children": [
- {
- "id": "622afa34d5cdd911",
- "type": "leaf",
- "state": {
- "type": "markdown",
- "state": {
- "file": "사용법.md",
- "mode": "source",
- "source": false
- },
- "icon": "lucide-file",
- "title": "사용법"
- }
- }
- ]
- }
- ],
- "direction": "vertical"
- },
- "left": {
- "id": "aad5c4b7f3817247",
- "type": "split",
- "children": [
- {
- "id": "b1c424a679f1a3dd",
- "type": "tabs",
- "children": [
- {
- "id": "971afc5e0e4ee211",
- "type": "leaf",
- "state": {
- "type": "file-explorer",
- "state": {
- "sortOrder": "alphabetical",
- "autoReveal": false
- },
- "icon": "lucide-folder-closed",
- "title": "파일 탐색기"
- }
- },
- {
- "id": "0a0d526cd09c289c",
- "type": "leaf",
- "state": {
- "type": "search",
- "state": {
- "query": "",
- "matchingCase": false,
- "explainSearch": false,
- "collapseAll": false,
- "extraContext": false,
- "sortOrder": "alphabetical"
- },
- "icon": "lucide-search",
- "title": "검색"
- }
- },
- {
- "id": "6eb48c367ff7edcd",
- "type": "leaf",
- "state": {
- "type": "bookmarks",
- "state": {},
- "icon": "lucide-bookmark",
- "title": "북마크"
- }
- }
- ]
- }
- ],
- "direction": "horizontal",
- "width": 300
- },
- "right": {
- "id": "d93d663e78281850",
- "type": "split",
- "children": [
- {
- "id": "523fa6ab5c495aea",
- "type": "tabs",
- "children": [
- {
- "id": "b7dda98ea5aa0090",
- "type": "leaf",
- "state": {
- "type": "backlink",
- "state": {
- "file": "사용법.md",
- "collapseAll": false,
- "extraContext": false,
- "sortOrder": "alphabetical",
- "showSearch": false,
- "searchQuery": "",
- "backlinkCollapsed": false,
- "unlinkedCollapsed": true
- },
- "icon": "links-coming-in",
- "title": "사용법 의 백링크"
- }
- },
- {
- "id": "e29038f85c190f48",
- "type": "leaf",
- "state": {
- "type": "outgoing-link",
- "state": {
- "file": "사용법.md",
- "linksCollapsed": false,
- "unlinkedCollapsed": true
- },
- "icon": "links-going-out",
- "title": "사용법 의 나가는 링크"
- }
- },
- {
- "id": "bd2aa5c94a024ac8",
- "type": "leaf",
- "state": {
- "type": "tag",
- "state": {
- "sortOrder": "frequency",
- "useHierarchy": true,
- "showSearch": false,
- "searchQuery": ""
- },
- "icon": "lucide-tags",
- "title": "태그"
- }
- },
- {
- "id": "821a21c836602891",
- "type": "leaf",
- "state": {
- "type": "all-properties",
- "state": {
- "sortOrder": "frequency",
- "showSearch": false,
- "searchQuery": ""
- },
- "icon": "lucide-archive",
- "title": "모든 속성"
- }
- },
- {
- "id": "cc40e5fc9f76235d",
- "type": "leaf",
- "state": {
- "type": "outline",
- "state": {
- "file": "사용법.md",
- "followCursor": false,
- "showSearch": false,
- "searchQuery": ""
- },
- "icon": "lucide-list",
- "title": "사용법 의 개요"
- }
- }
- ]
- }
- ],
- "direction": "horizontal",
- "width": 300,
- "collapsed": true
- },
- "left-ribbon": {
- "hiddenItems": {
- "switcher:빠른 전환기 열기": false,
- "graph:그래프 뷰 열기": false,
- "canvas:새 캔버스 만들기": false,
- "daily-notes:오늘의 일일 노트 열기": false,
- "templates:템플릿 삽입": false,
- "command-palette:명령어 팔레트 열기": false,
- "bases:새 베이스 생성하기": false
- }
- },
- "active": "622afa34d5cdd911",
- "lastOpenFiles": [
- "로그/README.md",
- "홈.md.tmp.18544.76f6608d2b49",
- "사용법.md",
- "사용법.md.tmp.18544.ac6f20d61fa8",
- "홈.md.tmp.18544.6284b08ae902",
- "홈.md.tmp.18544.fed3a66062de",
- "온보딩/체크리스트.md",
- "온보딩/체크리스트.md.tmp.18544.c1e84ef5964b",
- "아키텍처/폴더-구조.md",
- "아키텍처/폴더-구조.md.tmp.18544.9f669ceabe5e",
- "트러블슈팅/README.md",
- "트러블슈팅/README.md.tmp.18544.1e3272166528",
- "트러블슈팅",
- "회의록/README.md",
- "회의록/README.md.tmp.18544.db3c5ccd98e5",
- "아키텍처/결정-기록/README.md.tmp.18544.4d148bc145e0",
- "아키텍처/결정-기록/ADR-003-zustand-pattern.md",
- "아키텍처/결정-기록/ADR-002-data-fetching-strategy.md",
- "레퍼런스/README.md",
- "기획/기능별-담당.md",
- "아키텍처/도메인-용어집.md",
- "아키텍처/결정-기록/README.md",
- "아키텍처/결정-기록/ADR-001-react-compiler.md",
- "온보딩/신규-개발자.md",
- "홈.md",
- "환영합니다!.md",
- "회의록/2026-06-01-예시.md",
- "템플릿/세션로그-템플릿.md",
- "템플릿/ADR-템플릿.md",
- "템플릿/회의록-템플릿.md",
- "기획/프로젝트-개요.md"
- ]
-}
\ No newline at end of file
diff --git "a/docs/\353\241\234\352\267\270/\354\204\270\354\205\230\353\241\234\352\267\270-JIN921-2026-06-25.md" "b/docs/\353\241\234\352\267\270/\354\204\270\354\205\230\353\241\234\352\267\270-JIN921-2026-06-25.md"
new file mode 100644
index 00000000..298f4c80
--- /dev/null
+++ "b/docs/\353\241\234\352\267\270/\354\204\270\354\205\230\353\241\234\352\267\270-JIN921-2026-06-25.md"
@@ -0,0 +1,80 @@
+# 세션로그 — @JIN921 — 2026-06-25
+
+## 오늘의 목표
+
+- 총 회비 설정 온보딩 5단계 중 4단계까지 구현 (Step 1~4)
+
+## 작업한 것
+
+### 인프라
+
+- `src/stores/useDuesSetupStore.ts` — Zustand `combine` + `devtools` + `persist` 스토어 신규 생성
+ - 5단계 폼 데이터를 localStorage에 유지 (`'duesSetup'` 키)
+ - 패턴 레퍼런스: `useCreateClubDraftStore`
+- `src/constants/mock.ts` — `MOCK_PAYMENT_TARGETS` (25명), `MOCK_PREVIOUS_BALANCE` 추가
+
+### 라우팅
+
+단계별 독립 URL 방식 선택 (결정 배경: 브라우저 히스토리 활용, 새로고침 안전성).
+
+```
+/[clubId]/admin/dues/setup/1 ← Step 1
+/[clubId]/admin/dues/setup/2 ← Step 2
+/[clubId]/admin/dues/setup/3 ← Step 3
+```
+
+### 공유 서브컴포넌트 (`components/admin/dues/setup/components/`)
+
+| 컴포넌트 | 역할 |
+|---|---|
+| `DuesSetupStepIndicator` | 5단계 진행 표시 (teal 완료/활성, gray 미완료) |
+| `FormCard` | 카드 래퍼 (step 번호 + 설명 헤더 통일) |
+| `NextButton` / `PrevButton` | 하단 네비게이션 버튼 |
+| `DuesMemberTable` | Step 2 체크박스 테이블 |
+| `DuesSearchBar` | Step 2 이름 검색 인풋 |
+
+### Step 1 — 기본 정보
+
+- 필드: 1인당 회비금액 (숫자 입력), 회비 이름 (최대 30자), 회비 설명 선택 (최대 30자)
+- 유효성 검사: 금액·이름 필수 (`ring-state-error`)
+- "다음으로" → `/setup/2`
+
+### Step 2 — 납부 대상
+
+- 멤버 목록: `MOCK_PAYMENT_TARGETS` (실 API 연결 전 mock 사용)
+- 탭 필터: 전체 / 선택됨 / 제외됨 (카운트 실시간 반영)
+- 이름 검색 (클라이언트 필터)
+- 체크박스로 납부 대상 선택 → `selectedMemberIds[]` 스토어 저장
+- 최초 진입 시 TARGETED 상태 멤버 자동 선택 (`memberIdsInitialized` 플래그)
+- 페이지네이션 (10개/페이지)
+
+### Step 3 — 이월 설정
+
+두 케이스 분기 (`MOCK_PREVIOUS_BALANCE null 여부`):
+
+| | Case 1 (이전 잔액 있음) | Case 2 (이전 잔액 없음) |
+|---|---|---|
+| 정보 카드 | 금액 + "이전 기수 N기 잔액" (light teal bg) | "이전 기수 정보가 없습니다! 직접 금액을 작성해주세요." (brand-primary 텍스트) |
+| 기본 선택 | 이월하지 않기 | 잔액을 이번 기수로 이월하기 |
+| "이월하지 않기" 부설명 | "이월 금액이 지출 내역으로 기록됩니다." | (없음) |
+| "이월하기" 부설명 | (없음) | "아래 금액을 작성해주세요" |
+| 추가 입력 | (없음) | 이월 금액 설명 textarea (선택, 최대 30자) |
+
+### Step 4 — 계좌 공개 ← **오늘 마지막 구현**
+
+- 필드: 계좌번호 (필수), 은행 (필수), 예금주 (필수, 최대 30자), 안내 문구 선택 (선택, 최대 30자)
+- 2열 grid 레이아웃: 계좌번호+은행 / 예금주+안내문구
+- "멤버에게 계좌를 공개" Switch 토글 → `isAccountPublic` 스토어 저장
+- 유효성: 3개 필수 필드 모두 입력 시에만 `/setup/5` 이동
+- `useDuesSetupStore`에 `accountNumber`, `bankName`, `accountHolder`, `accountGuide`, `isAccountPublic` 필드 추가
+- 라우팅: `/[clubId]/admin/dues/setup/4`
+
+## 남은 작업 (TODO)
+- [ ] 은행 선택 드롭다운 구현 레퍼런스 찾기
+- [ ] **온보딩 데이터 관리 레퍼런스 조사** — 실 API 연결 시 `selectedMemberIds`, `MOCK_PREVIOUS_BALANCE` 등을 어떻게 넘길지 결정 필요. 참고할 패턴: `useCreateClubDraftStore` → Server Action 제출 흐름
+- [ ] Step 5 — 최종 확인 구현
+
+## 공유할 것 (팀원이 봐야 할 변화)
+
+- `useDuesSetupStore` 추가됨 — persist로 새로고침해도 입력값 유지됨. 온보딩 완료 후 반드시 `reset()` 호출해야 함
+- mock 데이터 (`MOCK_PAYMENT_TARGETS`, `MOCK_PREVIOUS_BALANCE`) — API 연결 전 임시. 실 API 붙일 때 같이 교체
diff --git a/e2e/specs/landing.spec.ts b/e2e/specs/landing.spec.ts
index 742a88fa..1e225649 100644
--- a/e2e/specs/landing.spec.ts
+++ b/e2e/specs/landing.spec.ts
@@ -10,6 +10,8 @@ test.describe('랜딩 페이지', () => {
// storageState로 주입된 인증 쿠키를 제거해 미들웨어 리다이렉트 방지
await context.clearCookies();
await page.goto('/landing');
+ // hydration 완료 후 framer-motion 헤더 애니메이션이 안정화될 때까지 대기
+ await page.waitForLoadState('networkidle');
if (isMobile) {
// 모바일: 로그인 링크가 Sheet 안에 있으므로 햄버거 메뉴 먼저 오픈
@@ -17,7 +19,6 @@ test.describe('랜딩 페이지', () => {
}
const loginLink = page.getByRole('link', { name: '로그인' });
- await loginLink.waitFor({ state: 'visible' });
await Promise.all([page.waitForURL(/\/login/), loginLink.click()]);
});
});
diff --git a/src/app/(private)/[clubId]/admin/dues/setup/1/page.tsx b/src/app/(private)/[clubId]/admin/dues/setup/1/page.tsx
new file mode 100644
index 00000000..019e89cd
--- /dev/null
+++ b/src/app/(private)/[clubId]/admin/dues/setup/1/page.tsx
@@ -0,0 +1,5 @@
+import { DuesSetupStep1 } from '@/components/admin/dues/setup';
+
+export default function DuesSetupStep1Page() {
+ return ;
+}
diff --git a/src/app/(private)/[clubId]/admin/dues/setup/2/page.tsx b/src/app/(private)/[clubId]/admin/dues/setup/2/page.tsx
new file mode 100644
index 00000000..488a56ca
--- /dev/null
+++ b/src/app/(private)/[clubId]/admin/dues/setup/2/page.tsx
@@ -0,0 +1,5 @@
+import { DuesSetupStep2 } from '@/components/admin/dues/setup';
+
+export default function DuesSetupStep2Page() {
+ return ;
+}
diff --git a/src/app/(private)/[clubId]/admin/dues/setup/3/page.tsx b/src/app/(private)/[clubId]/admin/dues/setup/3/page.tsx
new file mode 100644
index 00000000..7cfff6a8
--- /dev/null
+++ b/src/app/(private)/[clubId]/admin/dues/setup/3/page.tsx
@@ -0,0 +1,5 @@
+import { DuesSetupStep3 } from '@/components/admin/dues/setup';
+
+export default function DuesSetupStep3Page() {
+ return ;
+}
diff --git a/src/app/(private)/[clubId]/admin/dues/setup/4/page.tsx b/src/app/(private)/[clubId]/admin/dues/setup/4/page.tsx
new file mode 100644
index 00000000..e79d1353
--- /dev/null
+++ b/src/app/(private)/[clubId]/admin/dues/setup/4/page.tsx
@@ -0,0 +1,5 @@
+import { DuesSetupStep4 } from '@/components/admin/dues/setup';
+
+export default function DuesSetupStep4Page() {
+ return ;
+}
diff --git a/src/app/(private)/[clubId]/admin/dues/setup/5/page.tsx b/src/app/(private)/[clubId]/admin/dues/setup/5/page.tsx
new file mode 100644
index 00000000..313f4d70
--- /dev/null
+++ b/src/app/(private)/[clubId]/admin/dues/setup/5/page.tsx
@@ -0,0 +1,5 @@
+import { DuesSetupStep5 } from '@/components/admin/dues/setup';
+
+export default function DuesSetupStep5Page() {
+ return ;
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 1c020a1e..9921e170 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -554,6 +554,21 @@ select:focus {
letter-spacing: var(--letter-spacing);
}
+@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);
+}
+
@utility typo-social {
font-family:
'SF Pro',
diff --git a/src/assets/image/dues_tutorial.png b/src/assets/image/dues_tutorial.png
new file mode 100644
index 00000000..15ecb574
Binary files /dev/null and b/src/assets/image/dues_tutorial.png differ
diff --git a/src/components/admin/dues/BackButton.tsx b/src/components/admin/dues/BackButton.tsx
new file mode 100644
index 00000000..396ea382
--- /dev/null
+++ b/src/components/admin/dues/BackButton.tsx
@@ -0,0 +1,21 @@
+'use client';
+
+import { BackIcon } from '@/assets/icons';
+import { Icon } from '@/components/ui';
+import { useDuesSetupNavigation } from './setup/useDuesSetupNavigation';
+
+function BackButton() {
+ const { goToDues } = useDuesSetupNavigation();
+ return (
+
+ );
+}
+
+export { BackButton };
diff --git a/src/components/admin/dues/DuesMemberPaymentTable.tsx b/src/components/admin/dues/DuesMemberPaymentTable.tsx
index b124bdc0..8f02300c 100644
--- a/src/components/admin/dues/DuesMemberPaymentTable.tsx
+++ b/src/components/admin/dues/DuesMemberPaymentTable.tsx
@@ -2,7 +2,7 @@
import { useState } from 'react';
-import { ArrowRightIcon, CheckIcon, SearchIcon } from '@/assets/icons';
+import { ArrowRightIcon, CheckIcon } from '@/assets/icons';
import {
Icon,
Table,
@@ -14,20 +14,15 @@ import {
} from '@/components/ui';
import { cn } from '@/lib/cn';
import { DuesMember, FilterType, PaymentStatus } from '@/types/admin/dues';
+import { DuesSearchBar } from './DuesSearchBar';
function PaymentStatusBadge({ status }: { status: PaymentStatus }) {
if (status === 'paid') {
return (
-
- 완료
-
+ 완료
);
}
- return (
-
- 미납
-
- );
+ return 미납;
}
const COLUMNS = [
@@ -121,18 +116,7 @@ function DuesMemberPaymentTable({
{/* 검색바 */}
-
-
-
-
-
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"
- />
-
+
{/* 테이블 */}
diff --git a/src/components/admin/dues/DuesPageContent.tsx b/src/components/admin/dues/DuesPageContent.tsx
index 135c288c..62fb0864 100644
--- a/src/components/admin/dues/DuesPageContent.tsx
+++ b/src/components/admin/dues/DuesPageContent.tsx
@@ -9,13 +9,15 @@ import { useCardinalSelector } from '@/hooks';
import { DuesTopBar } from './DuesTopBar';
import { DuesBalanceCard } from './DuesBalanceCard';
import { DuesChart } from './DuesChart';
-import { DuesTransactionTable } from './DuesTransactionTable';
+
import { DuesGenerationFilter } from './DuesGenerationFilter';
import { AddTransactionModal } from './modal/AddTransactionModal';
import { EditTransactionModal } from './modal/EditTransactionModal';
import { TransactionDetailModal } from './modal/TransactionDetailModal';
import type { TransactionDetail } from './modal/TransactionDetailModal';
import type { TransactionFormData } from './modal/TransactionForm';
+import { DuesTransactionTable } from './DuesTransactionTable';
+import { DuesTutorialModal } from './modal/DuesTutorialModal';
const MOCK_MONTHLY_DATA: MonthlyData[] = [
{ month: '3월', amount: 1425000 },
@@ -136,6 +138,9 @@ function DuesPageContent() {
const router = useRouter();
const { clubId } = useParams<{ clubId: string }>();
+ // TODO: 총 회비 정보 입력 안 됐을 때만 모달 띄우기
+ const [tutorialOpen, setTutorialOpen] = useState(true);
+
const [addOpen, setAddOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
@@ -174,10 +179,12 @@ function DuesPageContent() {
onSelect={setSelectedCardinalId}
/>
+ {/* TODO: 온보딩 현재 진행 중인 스텝으로 보내주기 */}
router.push(`/${clubId}/admin/dues/payment-status`)}
+ onSetTotalDues={() => router.push(`/${clubId}/admin/dues/setup/1`)}
/>
+ router.push(`/${clubId}/admin/dues/setup/1`)}
+ />
);
}
diff --git a/src/components/admin/dues/DuesPaymentStatusPageContent.tsx b/src/components/admin/dues/DuesPaymentStatusPageContent.tsx
index ff5863c0..ee637ddc 100644
--- a/src/components/admin/dues/DuesPaymentStatusPageContent.tsx
+++ b/src/components/admin/dues/DuesPaymentStatusPageContent.tsx
@@ -12,6 +12,7 @@ import { useCardinalSelector } from '@/hooks';
import { DuesMemberPaymentTable, type DuesMember } from './DuesMemberPaymentTable';
import { DuesPaymentSummaryCard } from './DuesPaymentSummaryCard';
+import { BackButton } from './BackButton';
const MOCK_MEMBERS: DuesMember[] = [
{ id: 1, name: '김위드', major: '경영학과', phone: '010-1234-1234', status: 'unpaid' },
@@ -158,14 +159,7 @@ function DuesPaymentStatusPageContent() {
{/* 헤더 */}
-
+
{generationLabel} 회비 납부 현황
diff --git a/src/components/admin/dues/DuesSearchBar.tsx b/src/components/admin/dues/DuesSearchBar.tsx
new file mode 100644
index 00000000..8cffa8a9
--- /dev/null
+++ b/src/components/admin/dues/DuesSearchBar.tsx
@@ -0,0 +1,27 @@
+import { SearchIcon } from '@/assets/icons';
+import { Icon } from '@/components/ui';
+
+interface DuesSearchBarProps {
+ searchQuery: string;
+ setSearchQuery: (value: string) => void;
+}
+
+function DuesSearchBar({ searchQuery, setSearchQuery }: DuesSearchBarProps) {
+ return (
+
+
+
+
+
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"
+ />
+
+ );
+}
+
+export { DuesSearchBar, type DuesSearchBarProps };
diff --git a/src/components/admin/dues/DuesTransactionTable.tsx b/src/components/admin/dues/DuesTransactionTable.tsx
index eae20827..e778a8a5 100644
--- a/src/components/admin/dues/DuesTransactionTable.tsx
+++ b/src/components/admin/dues/DuesTransactionTable.tsx
@@ -43,23 +43,15 @@ interface DuesTransactionTableProps extends React.HTMLAttributes
function TransactionTypeTag({ type }: { type: TransactionType }) {
if (type === 'income') {
return (
-
- 수입
-
+ 수입
);
}
if (type === 'dues') {
return (
-
- 회비
-
+ 회비
);
}
- return (
-
- 지출
-
- );
+ return 지출;
}
function DuesTransactionTable({
diff --git a/src/components/admin/dues/index.ts b/src/components/admin/dues/index.ts
index f80d554b..fa11489e 100644
--- a/src/components/admin/dues/index.ts
+++ b/src/components/admin/dues/index.ts
@@ -27,3 +27,9 @@ export {
type TransactionDetailModalProps,
} from './modal/TransactionDetailModal';
export { type TransactionFormData } from './modal/TransactionForm';
+export { PaymentTargetModal, type PaymentTargetModalProps } from './modal/PaymentTargetModal';
+export { DuesTutorialModal, type DuesTutorialModalProps } from './modal/DuesTutorialModal';
+
+export { BackButton } from './BackButton';
+
+export { DuesSearchBar, type DuesSearchBarProps } from './DuesSearchBar';
diff --git a/src/components/admin/dues/modal/DuesTutorialModal.tsx b/src/components/admin/dues/modal/DuesTutorialModal.tsx
new file mode 100644
index 00000000..62dbcdb4
--- /dev/null
+++ b/src/components/admin/dues/modal/DuesTutorialModal.tsx
@@ -0,0 +1,59 @@
+'use client';
+
+import Image from 'next/image';
+
+import DuesTutorialImage from '@/assets/image/dues_tutorial.png';
+import { InfoCircleIcon } from '@/assets/icons';
+import { Button } from '@/components/ui';
+import { Dialog, DialogContent } from '@/components/ui/dialog';
+import { Icon } from '@/components/ui/Icon';
+
+interface DuesTutorialModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onStart?: () => void;
+}
+
+function DuesTutorialModal({ open, onOpenChange, onStart }: DuesTutorialModalProps) {
+ const handleStart = () => {
+ onOpenChange(false);
+ onStart?.();
+ };
+
+ return (
+
+ );
+}
+
+export { DuesTutorialModal, type DuesTutorialModalProps };
diff --git a/src/components/admin/dues/modal/PaymentTargetModal.tsx b/src/components/admin/dues/modal/PaymentTargetModal.tsx
new file mode 100644
index 00000000..b3f8d1b5
--- /dev/null
+++ b/src/components/admin/dues/modal/PaymentTargetModal.tsx
@@ -0,0 +1,90 @@
+import { AdminCloseIcon } from '@/assets/icons/admin';
+import { ModalIconButton } from '@/components/admin/modal/ModalIconButton';
+import {
+ SCHEDULE_MODAL_CONTENT_CLASS,
+ SCHEDULE_MODAL_FOOTER_CLASS,
+} from '@/components/admin/schedule/modal/constants';
+import {
+ DuesMemberTable,
+ DuesPagination,
+ DuesTabs,
+} from '@/components/admin/dues/setup/components';
+import { DuesSearchBar } from '@/components/admin/dues/DuesSearchBar';
+import { Button } from '@/components/ui';
+import { Dialog, DialogContent } from '@/components/ui/dialog';
+import { usePaymentTargetFilter } from '@/hooks/admin';
+
+interface PaymentTargetModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedMemberIds: number[];
+}
+
+function PaymentTargetModal({ open, onOpenChange, selectedMemberIds }: PaymentTargetModalProps) {
+ const {
+ selectedCount,
+ tab,
+ search,
+ selectedSet,
+ page,
+ setPage,
+ excludedCount,
+ totalPages,
+ pagedTargets,
+ handleTabChange,
+ handleSearch,
+ } = usePaymentTargetFilter(selectedMemberIds);
+ const handleClose = () => onOpenChange(false);
+
+ return (
+
+ );
+}
+
+export { PaymentTargetModal, type PaymentTargetModalProps };
diff --git a/src/components/admin/dues/setup/DuesSetupStep1.tsx b/src/components/admin/dues/setup/DuesSetupStep1.tsx
new file mode 100644
index 00000000..285b3f04
--- /dev/null
+++ b/src/components/admin/dues/setup/DuesSetupStep1.tsx
@@ -0,0 +1,131 @@
+'use client';
+
+import { useState } from 'react';
+
+import { cn } from '@/lib/cn';
+import { useDuesSetupActions, useDuesSetupValues } from '@/stores/useDuesSetupStore';
+
+import { BackButton } from '@/components/admin/dues';
+import {
+ DuesSetupStepIndicator,
+ FormCard,
+ NextButton,
+} from '@/components/admin/dues/setup/components';
+import { useDuesSetupNavigation } from '@/components/admin/dues/setup/useDuesSetupNavigation';
+import { ScheduleTextField } from '@/components/admin/schedule/general/ScheduleTextField';
+
+const NAME_MAX = 30;
+const DESCRIPTION_MAX = 30;
+
+function DuesSetupStep1() {
+ const { goToStep } = useDuesSetupNavigation();
+ const { amount, name, description, generationNumber } = useDuesSetupValues();
+ const { setField } = useDuesSetupActions();
+
+ const [errors, setErrors] = useState<{ amount?: string; name?: string }>({});
+
+ const handleNext = () => {
+ const next: { amount?: string; name?: string } = {};
+ if (!amount || Number(amount) === 0) next.amount = '회비 금액을 입력해주세요';
+ if (!name.trim()) next.name = '회비 이름을 입력해주세요';
+ setErrors(next);
+ if (Object.keys(next).length > 0) return;
+ goToStep(2);
+ };
+
+ return (
+
+ {/* 헤더 */}
+
+
+
{generationNumber}기 총 회비 설정
+
+
+
+ {/* 스텝 인디케이터 */}
+
+
+
+ {/* 필드 행: 회비금액 + 회비 이름 */}
+
+ {/* 1인당 회비금액 */}
+
+
+
+
+ {
+ const raw = e.target.value.replace(/\D/g, '');
+ setField({ amount: raw });
+ if (errors.amount) setErrors((prev) => ({ ...prev, amount: undefined }));
+ }}
+ placeholder="0"
+ className={cn(
+ 'typo-sub3 placeholder:text-text-alternative min-w-0 flex-1 bg-transparent focus:outline-none',
+ amount ? 'text-text-normal' : 'text-text-alternative',
+ )}
+ />
+ 원
+
+
+ {errors.amount ? (
+ {errors.amount}
+ ) : (
+
+ 회비 금액은 등록 후에도 수정할 수 있습니다.
+
+ )}
+
+
+
+
+ {/* 회비 이름 */}
+
+ {
+ setField({ name: value });
+ if (errors.name) setErrors((prev) => ({ ...prev, name: undefined }));
+ }}
+ placeholder={`${generationNumber}기 정기회비`}
+ maxLength={NAME_MAX}
+ error={errors.name}
+ className="bg-container-neutral-alternative"
+ />
+
+
+
+ {/* 회비 설명 (선택) */}
+ setField({ description: value })}
+ placeholder="설명을 작성해주세요"
+ maxLength={DESCRIPTION_MAX}
+ className="bg-container-neutral-alternative"
+ />
+
+
+
+ {/* 다음으로 버튼 */}
+
+
+ );
+}
+
+export { DuesSetupStep1 };
diff --git a/src/components/admin/dues/setup/DuesSetupStep2.tsx b/src/components/admin/dues/setup/DuesSetupStep2.tsx
new file mode 100644
index 00000000..ca6f806d
--- /dev/null
+++ b/src/components/admin/dues/setup/DuesSetupStep2.tsx
@@ -0,0 +1,115 @@
+'use client';
+
+import { useEffect } from 'react';
+
+import { BackButton, DuesSearchBar } from '@/components/admin/dues';
+import { MOCK_PAYMENT_TARGETS } from '@/constants/mock';
+import { useDuesSetupValues, useDuesSetupActions } from '@/stores/useDuesSetupStore';
+
+import {
+ DuesSetupStepIndicator,
+ DuesMemberTable,
+ DuesPagination,
+ DuesTabs,
+ NextButton,
+ PrevButton,
+} from '@/components/admin/dues/setup/components';
+import { useDuesSetupNavigation } from '@/components/admin/dues/setup/useDuesSetupNavigation';
+import { usePaymentTargetFilter } from '@/hooks/admin';
+
+function DuesSetupStep2() {
+ const { goToStep } = useDuesSetupNavigation();
+
+ const { generationNumber, selectedMemberIds, memberIdsInitialized } = useDuesSetupValues();
+ const { setField } = useDuesSetupActions();
+
+ const {
+ totalCount,
+ selectedCount,
+ tab,
+ search,
+ selectedSet,
+ page,
+ setPage,
+ excludedCount,
+ totalPages,
+ pagedTargets,
+ handleTabChange,
+ handleSearch,
+ } = usePaymentTargetFilter(selectedMemberIds);
+
+ // 첫 진입 시 TARGETED 멤버로 초기화
+ useEffect(() => {
+ if (!memberIdsInitialized) {
+ const targetedIds = MOCK_PAYMENT_TARGETS.filter((t) => t.targetStatus === 'TARGETED').map(
+ (t) => t.paymentTargetInfo.clubMemberId,
+ );
+ setField({ selectedMemberIds: targetedIds, memberIdsInitialized: true });
+ }
+ }, [memberIdsInitialized, setField]);
+
+ const toggleMember = (id: number) => {
+ const next = selectedSet.has(id)
+ ? selectedMemberIds.filter((x) => x !== id)
+ : [...selectedMemberIds, id];
+ setField({ selectedMemberIds: next });
+ };
+
+ return (
+
+ {/* 헤더 */}
+
+
+
{generationNumber}기 총 회비 설정
+
+
+
+
+
+
+ {/* 섹션 헤더 */}
+
+ 납부 대상 (2/5)
+
이번 회비를 납부할 멤버를 선택해주세요
+
+
+ {/* 탭 + 검색 */}
+
+
+
+ {/* 검색바 */}
+
+
+
+ {/* 테이블 */}
+
+
+ {/* 페이지네이션 */}
+ {totalPages > 1 && (
+
+ )}
+
+
+
+ {/* 하단 네비게이션 */}
+
+
goToStep(1)} />
+ goToStep(3)} />
+
+
+ );
+}
+
+export { DuesSetupStep2 };
diff --git a/src/components/admin/dues/setup/DuesSetupStep3.tsx b/src/components/admin/dues/setup/DuesSetupStep3.tsx
new file mode 100644
index 00000000..e6475f1c
--- /dev/null
+++ b/src/components/admin/dues/setup/DuesSetupStep3.tsx
@@ -0,0 +1,110 @@
+'use client';
+
+import { useEffect } from 'react';
+
+import { BackButton } from '@/components/admin/dues';
+import { MOCK_PREVIOUS_BALANCE } from '@/constants/mock';
+import { useDuesSetupValues, useDuesSetupActions } from '@/stores/useDuesSetupStore';
+
+import {
+ DuesSetupStepIndicator,
+ FormCard,
+ NextButton,
+ PrevButton,
+ CarryOverCard,
+} from '@/components/admin/dues/setup/components';
+import { useDuesSetupNavigation } from '@/components/admin/dues/setup/useDuesSetupNavigation';
+
+import { ScheduleTextField } from '@/components/admin/schedule/general/ScheduleTextField';
+
+const DESCRIPTION_MAX = 30;
+
+function DuesSetupStep3() {
+ const { goToStep } = useDuesSetupNavigation();
+
+ const { generationNumber, carryOverOption, carryOverDescription, carryOverInitialized } =
+ useDuesSetupValues();
+ const { setField } = useDuesSetupActions();
+
+ const hasPreviousBalance = MOCK_PREVIOUS_BALANCE !== null;
+ const previousBalance = MOCK_PREVIOUS_BALANCE?.balance ?? 0;
+ const previousGeneration = MOCK_PREVIOUS_BALANCE?.generationNumber ?? generationNumber - 1;
+
+ // 첫 진입 시 기본값 설정
+ useEffect(() => {
+ if (!carryOverInitialized) {
+ setField({
+ carryOverOption: hasPreviousBalance ? 'carry' : 'none',
+ carryOverInitialized: true,
+ });
+ }
+ }, [carryOverInitialized, hasPreviousBalance, setField]);
+
+ return (
+
+ {/* 헤더 */}
+
+
+
{generationNumber}기 총 회비 설정
+
+
+
+
+
+
+ {/* 이전 기수 잔액 정보 카드 */}
+
+ {hasPreviousBalance ? (
+ <>
+
{previousBalance.toLocaleString()} 원
+
+ 이전 기수 {previousGeneration}기 잔액
+
+ >
+ ) : (
+
+ 이전 기수 정보가 없습니다! 직접 금액을 작성해주세요.
+
+ )}
+
+
+ {/* 이월 옵션 라디오 카드 */}
+
+ setField({ carryOverOption: 'none' })}
+ />
+ setField({ carryOverOption: 'carry' })}
+ />
+
+
+ {/* 이월하기 선택 + 이전 기수 정보 없을 때: 설명 입력 */}
+ {carryOverOption === 'carry' && (
+ setField({ carryOverDescription: value })}
+ placeholder="설명을 작성해주세요"
+ maxLength={DESCRIPTION_MAX}
+ className="bg-container-neutral-alternative"
+ />
+ )}
+
+
+
+ {/* 하단 네비게이션 */}
+
+
goToStep(2)} />
+ goToStep(4)} />
+
+
+ );
+}
+
+export { DuesSetupStep3 };
diff --git a/src/components/admin/dues/setup/DuesSetupStep4.tsx b/src/components/admin/dues/setup/DuesSetupStep4.tsx
new file mode 100644
index 00000000..f1ce5910
--- /dev/null
+++ b/src/components/admin/dues/setup/DuesSetupStep4.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+import { useState } from 'react';
+
+import { BackButton } from '@/components/admin/dues';
+import { Switch } from '@/components/ui';
+import { useDuesSetupValues, useDuesSetupActions } from '@/stores/useDuesSetupStore';
+
+import {
+ DuesSetupStepIndicator,
+ FormCard,
+ NextButton,
+ PrevButton,
+} from '@/components/admin/dues/setup/components';
+import { useDuesSetupNavigation } from '@/components/admin/dues/setup/useDuesSetupNavigation';
+
+import { ScheduleTextField } from '@/components/admin/schedule/general/ScheduleTextField';
+
+const HOLDER_MAX = 30;
+const GUIDE_MAX = 30;
+
+interface Errors {
+ accountNumber?: string;
+ bankName?: string;
+ accountHolder?: string;
+}
+
+function DuesSetupStep4() {
+ const { goToStep } = useDuesSetupNavigation();
+
+ const {
+ generationNumber,
+ accountNumber,
+ bankName,
+ accountHolder,
+ accountGuide,
+ isAccountPublic,
+ } = useDuesSetupValues();
+ const { setField } = useDuesSetupActions();
+
+ const [errors, setErrors] = useState({});
+
+ const handleNext = () => {
+ const next: Errors = {};
+ if (!accountNumber.trim()) next.accountNumber = '계좌번호를 입력해주세요';
+ if (!bankName.trim()) next.bankName = '은행을 입력해주세요';
+ if (!accountHolder.trim()) next.accountHolder = '예금주를 입력해주세요';
+ setErrors(next);
+ if (Object.keys(next).length > 0) return;
+ goToStep(5);
+ };
+
+ return (
+
+ {/* 헤더 */}
+
+
+
{generationNumber}기 총 회비 설정
+
+
+
+
+
+
+ {/* 행 1: 계좌번호 + 은행 */}
+
+ {
+ setField({ accountNumber: value });
+ if (errors.accountNumber)
+ setErrors((prev) => ({ ...prev, accountNumber: undefined }));
+ }}
+ placeholder="계좌번호를 입력해주세요"
+ maxLength={20}
+ error={errors.accountNumber}
+ className="bg-container-neutral-alternative"
+ />
+ {/* TODO: 은행 선택 드롭다운 만들기 */}
+ {
+ setField({ bankName: value });
+ if (errors.bankName) setErrors((prev) => ({ ...prev, bankName: undefined }));
+ }}
+ placeholder="ex)카카오뱅크"
+ error={errors.bankName}
+ className="bg-container-neutral-alternative"
+ />
+
+
+ {/* 행 2: 예금주 + 안내 문구 */}
+
+ {
+ setField({ accountHolder: value });
+ if (errors.accountHolder)
+ setErrors((prev) => ({ ...prev, accountHolder: undefined }));
+ }}
+ placeholder="ex)가천대 검도부"
+ maxLength={HOLDER_MAX}
+ error={errors.accountHolder}
+ className="bg-container-neutral-alternative"
+ />
+ setField({ accountGuide: value })}
+ placeholder="부원에게 계좌나 입금 안내를 해보세요"
+ maxLength={GUIDE_MAX}
+ className="bg-container-neutral-alternative"
+ />
+
+
+ {/* 계좌 공개 토글 */}
+
+ 멤버에게 계좌를 공개
+ setField({ isAccountPublic: checked })}
+ />
+
+
+
+
+ {/* 하단 네비게이션 */}
+
+
+ );
+}
+
+export { DuesSetupStep4 };
diff --git a/src/components/admin/dues/setup/DuesSetupStep5.tsx b/src/components/admin/dues/setup/DuesSetupStep5.tsx
new file mode 100644
index 00000000..5580da67
--- /dev/null
+++ b/src/components/admin/dues/setup/DuesSetupStep5.tsx
@@ -0,0 +1,161 @@
+'use client';
+
+import { useState } from 'react';
+
+import { QuestionCircleIcon } from '@/assets/icons';
+import { BackButton, PaymentTargetModal } from '@/components/admin/dues';
+import { Icon, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui';
+import { MOCK_PAYMENT_TARGETS, MOCK_PREVIOUS_BALANCE } from '@/constants/mock';
+import { useDuesSetupValues, useDuesSetupActions } from '@/stores/useDuesSetupStore';
+
+import {
+ DuesSetupStepIndicator,
+ FormCard,
+ NextButton,
+ PrevButton,
+ SettingResultCardGrid,
+} from '@/components/admin/dues/setup/components';
+import { useDuesSetupNavigation } from '@/components/admin/dues/setup/useDuesSetupNavigation';
+
+const MAX_AVATAR_DISPLAY = 4;
+
+function DuesSetupStep5() {
+ const { goToStep, goToDues } = useDuesSetupNavigation();
+
+ const {
+ generationNumber,
+ amount,
+ name,
+ selectedMemberIds,
+ carryOverOption,
+ carryOverDescription,
+ accountNumber,
+ bankName,
+ accountHolder,
+ accountGuide,
+ isAccountPublic,
+ } = useDuesSetupValues();
+ const { reset } = useDuesSetupActions();
+ const [isPaymentTargetModalOpen, setIsPaymentTargetModalOpen] = useState(false);
+
+ const hasPreviousBalance = MOCK_PREVIOUS_BALANCE !== null;
+ const previousBalance = MOCK_PREVIOUS_BALANCE?.balance ?? 0;
+ const previousGeneration = MOCK_PREVIOUS_BALANCE?.generationNumber ?? generationNumber - 1;
+
+ const totalCount = MOCK_PAYMENT_TARGETS.length;
+ const selectedCount = selectedMemberIds.length;
+ const excludedCount = totalCount - selectedCount;
+
+ const selectedTargets = MOCK_PAYMENT_TARGETS.filter((t) =>
+ selectedMemberIds.includes(t.paymentTargetInfo.clubMemberId),
+ );
+
+ const displayedAvatars = selectedTargets.slice(0, MAX_AVATAR_DISPLAY);
+ const remainingCount = Math.max(0, selectedTargets.length - MAX_AVATAR_DISPLAY);
+
+ const expectedDuesIncome = Number(amount) * selectedCount;
+ const carryOverAmount = carryOverOption === 'carry' ? previousBalance : 0;
+ const expectedTotal = expectedDuesIncome + carryOverAmount;
+
+ const handleComplete = () => {
+ // TODO: API 연결 후 실제 저장 로직 추가
+ reset();
+ goToDues();
+ };
+
+ return (
+
+ {/* 헤더 */}
+
+
+
{generationNumber}기 총 회비 설정
+
+
+
+
+
+
+ {/* 예상 관리 금액 요약 배너 */}
+
+
+
+
+
+
+ 예상 관리 금액
+
+
+
+
+ 예상 관리 금액은 실제 계산된 금액과 차이가 있을 수 있습니다.
+
+
+
+
{expectedTotal.toLocaleString()} 원
+
+
+
+ 예상 회비 수입
+
+ {expectedDuesIncome.toLocaleString()} 원
+
+
+
+ 이월 금액
+
+ {carryOverAmount.toLocaleString()} 원
+
+
+
+
+
+ {/* 4개 정보 카드 2×2 그리드 */}
+ setIsPaymentTargetModalOpen(true)}
+ hasPreviousBalance={hasPreviousBalance}
+ previousGeneration={previousGeneration}
+ previousBalance={previousBalance}
+ carryOverOption={carryOverOption}
+ carryOverDescription={carryOverDescription}
+ isAccountPublic={isAccountPublic}
+ accountNumber={accountNumber}
+ bankName={bankName}
+ accountHolder={accountHolder}
+ accountGuide={accountGuide}
+ goToStep={goToStep}
+ />
+
+
+
+
+
+ {/* 하단 네비게이션 */}
+
+
+ );
+}
+
+export { DuesSetupStep5 };
diff --git a/src/components/admin/dues/setup/components/CarryOverCard.tsx b/src/components/admin/dues/setup/components/CarryOverCard.tsx
new file mode 100644
index 00000000..33f504a3
--- /dev/null
+++ b/src/components/admin/dues/setup/components/CarryOverCard.tsx
@@ -0,0 +1,40 @@
+import { cn } from '@/lib/cn';
+
+interface CarryOverCardProps {
+ title: string;
+ description?: string;
+ selected: boolean;
+ onClick: () => void;
+}
+
+function CarryOverCard({ title, description, selected, onClick }: CarryOverCardProps) {
+ return (
+
+ );
+}
+
+export { CarryOverCard };
diff --git a/src/components/admin/dues/setup/components/DuesMemberTable.tsx b/src/components/admin/dues/setup/components/DuesMemberTable.tsx
new file mode 100644
index 00000000..e009899e
--- /dev/null
+++ b/src/components/admin/dues/setup/components/DuesMemberTable.tsx
@@ -0,0 +1,115 @@
+import {
+ Avatar,
+ AvatarFallback,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Checkbox,
+} from '@/components/ui';
+
+import type { MockPaymentTarget } from '@/constants/mock';
+
+const ROLE_LABEL: Record = {
+ LEAD: '리더',
+ ADMIN: '관리자',
+ USER: '일반멤버',
+};
+
+function SelectionStatusBadge({ isSelected }: { isSelected: boolean }) {
+ if (isSelected) {
+ return 선택됨;
+ }
+ return 제외됨;
+}
+
+interface DuesMemberTableProps {
+ pagedTargets: MockPaymentTarget[];
+ selectedSet: Set;
+ toggleMember?: (id: number) => void;
+ readOnly?: boolean;
+}
+
+function DuesMemberTable({
+ pagedTargets,
+ selectedSet,
+ toggleMember,
+ readOnly = false,
+}: DuesMemberTableProps) {
+ return (
+
+
+
+ {!readOnly && (
+
+ 선택
+
+ )}
+ 이름
+ 학과
+ 직급
+
+ 납부 대상
+
+
+
+
+ {pagedTargets.map(({ targetId, paymentTargetInfo }) => {
+ const { clubMemberId, name, department, memberRole } = paymentTargetInfo;
+ const isSelected = selectedSet.has(clubMemberId);
+ return (
+ toggleMember?.(clubMemberId)}
+ >
+ {!readOnly && (
+ e.stopPropagation()}>
+ toggleMember?.(clubMemberId)}
+ />
+
+ )}
+
+
+
+ {name[0]}
+
+
{name}
+
+
+ {department}
+
+ {ROLE_LABEL[memberRole] ?? memberRole}
+
+
+
+
+
+ );
+ })}
+ {pagedTargets.length === 0 && (
+
+
+ 멤버가 없습니다
+
+
+ )}
+
+
+ );
+}
+
+export { DuesMemberTable };
diff --git a/src/components/admin/dues/setup/components/DuesPagination.tsx b/src/components/admin/dues/setup/components/DuesPagination.tsx
new file mode 100644
index 00000000..6234e489
--- /dev/null
+++ b/src/components/admin/dues/setup/components/DuesPagination.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import {
+ Pagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from '@/components/ui';
+import { cn } from '@/lib/cn';
+
+interface DuesPaginationProps {
+ page: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+}
+
+function DuesPagination({ page, totalPages, onPageChange }: DuesPaginationProps) {
+ return (
+
+
+
+ {
+ e.preventDefault();
+ onPageChange(Math.max(1, page - 1));
+ }}
+ className={cn(page === 1 && 'pointer-events-none opacity-40')}
+ />
+
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
+
+ {
+ e.preventDefault();
+ onPageChange(p);
+ }}
+ >
+ {p}
+
+
+ ))}
+
+ {
+ e.preventDefault();
+ onPageChange(Math.min(totalPages, page + 1));
+ }}
+ className={cn(page === totalPages && 'pointer-events-none opacity-40')}
+ />
+
+
+
+ );
+}
+
+export { DuesPagination, type DuesPaginationProps };
diff --git a/src/components/admin/dues/setup/components/DuesSearchBar.tsx b/src/components/admin/dues/setup/components/DuesSearchBar.tsx
new file mode 100644
index 00000000..5b50844a
--- /dev/null
+++ b/src/components/admin/dues/setup/components/DuesSearchBar.tsx
@@ -0,0 +1,26 @@
+import { SearchIcon } from '@/assets/icons';
+import { Icon } from '@/components/ui';
+
+interface SearchBarProps {
+ searchQuery: string;
+ setSearchQuery: (value: string) => void;
+}
+
+function DuesSearchBar({ searchQuery, setSearchQuery }: SearchBarProps) {
+ return (
+
+
+
+
+
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"
+ />
+
+ );
+}
+
+export { DuesSearchBar };
diff --git a/src/components/admin/dues/setup/components/DuesSetupStepIndicator.tsx b/src/components/admin/dues/setup/components/DuesSetupStepIndicator.tsx
new file mode 100644
index 00000000..4345234c
--- /dev/null
+++ b/src/components/admin/dues/setup/components/DuesSetupStepIndicator.tsx
@@ -0,0 +1,62 @@
+import { CheckIcon } from '@/assets/icons';
+import { cn } from '@/lib/cn';
+import { Icon } from '@/components/ui';
+
+const STEPS = [
+ { step: 1, label: '기본 정보' },
+ { step: 2, label: '납부 대상' },
+ { step: 3, label: '이월 설정' },
+ { step: 4, label: '계좌 공개' },
+ { step: 5, label: '최종 확인' },
+];
+
+interface DuesSetupStepIndicatorProps {
+ currentStep: number;
+ className?: string;
+}
+
+function DuesSetupStepIndicator({ currentStep, className }: DuesSetupStepIndicatorProps) {
+ return (
+
+ {STEPS.map(({ step, label }) => {
+ const isActive = step === currentStep;
+ const isCompleted = step < currentStep;
+
+ return (
+
+
+
+ {isCompleted ? : step}
+
+
+ {label}
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+export { DuesSetupStepIndicator, type DuesSetupStepIndicatorProps };
diff --git a/src/components/admin/dues/setup/components/DuesTabs.tsx b/src/components/admin/dues/setup/components/DuesTabs.tsx
new file mode 100644
index 00000000..2a3f87b3
--- /dev/null
+++ b/src/components/admin/dues/setup/components/DuesTabs.tsx
@@ -0,0 +1,36 @@
+import { cn } from '@/lib/cn';
+
+interface TabItem {
+ key: T;
+ label: string;
+}
+
+interface DuesTabsProps {
+ tabs: readonly TabItem[];
+ activeTab: T;
+ onTabChange: (tab: T) => void;
+}
+
+function DuesTabs({ tabs, activeTab, onTabChange }: DuesTabsProps) {
+ return (
+
+ {tabs.map(({ key, label }) => (
+
+ ))}
+
+ );
+}
+
+export { DuesTabs, type DuesTabsProps };
diff --git a/src/components/admin/dues/setup/components/FormCard.tsx b/src/components/admin/dues/setup/components/FormCard.tsx
new file mode 100644
index 00000000..72bb93d2
--- /dev/null
+++ b/src/components/admin/dues/setup/components/FormCard.tsx
@@ -0,0 +1,24 @@
+import { ReactNode } from 'react';
+
+interface FormCardProps {
+ title: string;
+ step: number;
+ description: string;
+ children: ReactNode;
+}
+function FormCard({ title, step, description, children }: FormCardProps) {
+ return (
+
+ {/* 섹션 헤더 */}
+
+
+ {title} ({step}/5)
+
+
{description}
+
+ {children}
+
+ );
+}
+
+export { FormCard, type FormCardProps };
diff --git a/src/components/admin/dues/setup/components/NextButton.tsx b/src/components/admin/dues/setup/components/NextButton.tsx
new file mode 100644
index 00000000..d02a709c
--- /dev/null
+++ b/src/components/admin/dues/setup/components/NextButton.tsx
@@ -0,0 +1,19 @@
+import { ArrowRightIcon } from '@/assets/icons';
+import { Icon } from '@/components/ui';
+
+function NextButton({ last = false, handleNext }: { last?: boolean; handleNext: () => void }) {
+ return (
+
+
+
+ );
+}
+
+export { NextButton };
diff --git a/src/components/admin/dues/setup/components/PrevButton.tsx b/src/components/admin/dues/setup/components/PrevButton.tsx
new file mode 100644
index 00000000..157f4290
--- /dev/null
+++ b/src/components/admin/dues/setup/components/PrevButton.tsx
@@ -0,0 +1,19 @@
+import { ArrowLeftIcon } from '@/assets/icons';
+import { Icon } from '@/components/ui';
+
+function PrevButton({ handlePrev }: { handlePrev: () => void }) {
+ return (
+
+
+
+ );
+}
+
+export { PrevButton };
diff --git a/src/components/admin/dues/setup/components/SettingResultCardGrid.tsx b/src/components/admin/dues/setup/components/SettingResultCardGrid.tsx
new file mode 100644
index 00000000..f02d4f02
--- /dev/null
+++ b/src/components/admin/dues/setup/components/SettingResultCardGrid.tsx
@@ -0,0 +1,179 @@
+import type { ReactNode } from 'react';
+
+import { EditIcon } from '@/assets/icons';
+import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, Card, Icon } from '@/components/ui';
+import { cn } from '@/lib/cn';
+
+interface InfoRowProps {
+ label: string;
+ value: string;
+ valueClassName?: string;
+}
+
+function InfoRow({ label, value, valueClassName }: InfoRowProps) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+interface InfoCardProps {
+ title: string;
+ onEdit: () => void;
+ children: ReactNode;
+}
+
+function InfoCard({ title, onEdit, children }: InfoCardProps) {
+ return (
+
+
+ {title}
+
+
+ {children}
+
+ );
+}
+
+interface PaymentTargetAvatar {
+ paymentTargetInfo: {
+ clubMemberId: number;
+ name: string;
+ };
+}
+
+interface SettingResultCardGridProps {
+ // Step 1: 기본 정보
+ generationNumber: number;
+ amount: string;
+ name: string;
+ // Step 2: 납부 대상
+ selectedCount: number;
+ excludedCount: number;
+ displayedAvatars: PaymentTargetAvatar[];
+ remainingCount: number;
+ onOpenPaymentTargetModal: () => void;
+ // Step 3: 이월 설정
+ hasPreviousBalance: boolean;
+ previousGeneration: number;
+ previousBalance: number;
+ carryOverOption: 'none' | 'carry';
+ carryOverDescription?: string;
+ // Step 4: 계좌 공개
+ isAccountPublic: boolean;
+ accountNumber?: string;
+ bankName?: string;
+ accountHolder?: string;
+ accountGuide?: string;
+ // 네비게이션
+ goToStep: (step: number) => void;
+}
+
+function SettingResultCardGrid({
+ generationNumber,
+ amount,
+ name,
+ selectedCount,
+ excludedCount,
+ displayedAvatars,
+ remainingCount,
+ onOpenPaymentTargetModal,
+ hasPreviousBalance,
+ previousGeneration,
+ previousBalance,
+ carryOverOption,
+ carryOverDescription,
+ isAccountPublic,
+ accountNumber,
+ bankName,
+ accountHolder,
+ accountGuide,
+ goToStep,
+}: SettingResultCardGridProps) {
+ return (
+
+ {/* 기본 정보 */}
+
goToStep(1)}>
+
+
+
+
+
+ {/* 이월 설정 */}
+
goToStep(3)}>
+ {hasPreviousBalance ? (
+ <>
+
+
+ {carryOverOption === 'carry' && (
+
+ )}
+ >
+ ) : (
+ <>
+
+ {carryOverOption === 'carry' && carryOverDescription && (
+
+ )}
+ >
+ )}
+
+
+ {/* 납부 대상 */}
+
goToStep(2)}>
+
+
+
+
선택된 멤버
+
+
+
+
+ {/* 계좌 공개 */}
+
goToStep(4)}>
+
+ {bankName && }
+ {accountNumber && (
+
+ )}
+ {accountHolder && }
+ {accountGuide && }
+
+
+ );
+}
+
+export {
+ InfoRow,
+ InfoCard,
+ SettingResultCardGrid,
+ type InfoRowProps,
+ type InfoCardProps,
+ type SettingResultCardGridProps,
+};
diff --git a/src/components/admin/dues/setup/components/index.ts b/src/components/admin/dues/setup/components/index.ts
new file mode 100644
index 00000000..df71c9ff
--- /dev/null
+++ b/src/components/admin/dues/setup/components/index.ts
@@ -0,0 +1,17 @@
+export { DuesSetupStepIndicator, type DuesSetupStepIndicatorProps } from './DuesSetupStepIndicator';
+
+export { NextButton } from './NextButton';
+export { PrevButton } from './PrevButton';
+export { FormCard } from './FormCard';
+export { DuesMemberTable } from './DuesMemberTable';
+export { CarryOverCard } from './CarryOverCard';
+export {
+ SettingResultCardGrid,
+ InfoRow,
+ InfoCard,
+ type SettingResultCardGridProps,
+ type InfoRowProps,
+ type InfoCardProps,
+} from './SettingResultCardGrid';
+export { DuesPagination, type DuesPaginationProps } from './DuesPagination';
+export { DuesTabs, type DuesTabsProps } from './DuesTabs';
diff --git a/src/components/admin/dues/setup/index.ts b/src/components/admin/dues/setup/index.ts
new file mode 100644
index 00000000..0104c543
--- /dev/null
+++ b/src/components/admin/dues/setup/index.ts
@@ -0,0 +1,5 @@
+export { DuesSetupStep1 } from './DuesSetupStep1';
+export { DuesSetupStep2 } from './DuesSetupStep2';
+export { DuesSetupStep3 } from './DuesSetupStep3';
+export { DuesSetupStep4 } from './DuesSetupStep4';
+export { DuesSetupStep5 } from './DuesSetupStep5';
diff --git a/src/components/admin/dues/setup/useDuesSetupNavigation.ts b/src/components/admin/dues/setup/useDuesSetupNavigation.ts
new file mode 100644
index 00000000..55d1b191
--- /dev/null
+++ b/src/components/admin/dues/setup/useDuesSetupNavigation.ts
@@ -0,0 +1,13 @@
+import { useRouter, useParams } from 'next/navigation';
+
+function useDuesSetupNavigation() {
+ const router = useRouter();
+ const { clubId } = useParams<{ clubId: string }>();
+
+ const goToStep = (step: number) => router.push(`/${clubId}/admin/dues/setup/${step}`);
+ const goToDues = () => router.push(`/${clubId}/admin/dues`);
+
+ return { goToStep, goToDues };
+}
+
+export { useDuesSetupNavigation };
diff --git a/src/components/admin/schedule/general/ScheduleTextField.tsx b/src/components/admin/schedule/general/ScheduleTextField.tsx
index f758ae89..870bb24b 100644
--- a/src/components/admin/schedule/general/ScheduleTextField.tsx
+++ b/src/components/admin/schedule/general/ScheduleTextField.tsx
@@ -1,4 +1,5 @@
import { ScheduleFormField } from '@/components/admin/schedule/general/ScheduleFormField';
+import { cn } from '@/lib/cn';
interface ScheduleTextFieldProps {
label: string;
@@ -6,14 +7,19 @@ interface ScheduleTextFieldProps {
onChange: (value: string) => void;
placeholder?: string;
maxLength?: number;
+ className?: string;
+ error?: string;
}
+// TODO: label, maxLength 표시되는 공용 인풋 컴포넌트 만들기
function ScheduleTextField({
label,
value,
onChange,
placeholder,
maxLength,
+ className,
+ error,
}: ScheduleTextFieldProps) {
return (
@@ -23,12 +29,21 @@ function ScheduleTextField({
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
maxLength={maxLength}
- className="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"
+ 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',
+ error && 'ring-state-error ring-1',
+ className,
+ )}
/>
- {maxLength !== undefined && (
-
- {value.length}/{maxLength}
-
+ {(error || maxLength !== undefined) && (
+
+ {error ? {error} : }
+ {maxLength !== undefined && (
+
+ {value.length}/{maxLength}
+
+ )}
+
)}
);
diff --git a/src/components/ui/Checkbox.tsx b/src/components/ui/Checkbox.tsx
new file mode 100644
index 00000000..b738b456
--- /dev/null
+++ b/src/components/ui/Checkbox.tsx
@@ -0,0 +1,47 @@
+'use client';
+
+import { cva, type VariantProps } from 'class-variance-authority';
+import { CheckIcon } from 'lucide-react';
+import { Checkbox as CheckboxPrimitive } from 'radix-ui';
+
+import { cn } from '@/lib/cn';
+
+const checkboxVariants = cva(
+ 'peer border-icon-alternative size-4 shrink-0 cursor-pointer rounded-[4px] border outline-none transition-shadow focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:text-text-inverse',
+ {
+ variants: {
+ color: {
+ primary: 'data-[state=checked]:border-brand-primary data-[state=checked]:bg-brand-primary',
+ alternative:
+ 'data-[state=checked]:border-icon-alternative data-[state=checked]:bg-icon-alternative',
+ },
+ },
+ defaultVariants: {
+ color: 'primary',
+ },
+ },
+);
+
+interface CheckboxProps
+ extends
+ Omit, 'color'>,
+ VariantProps {}
+
+function Checkbox({ className, color, ...props }: CheckboxProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export { Checkbox, checkboxVariants, type CheckboxProps };
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index 5bd7f515..daa8fcc8 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -5,6 +5,9 @@
export { Button, buttonVariants } from './Button';
export type { ButtonProps } from './Button';
+export { Checkbox, checkboxVariants } from './Checkbox';
+export type { CheckboxProps } from './Checkbox';
+
export { Input } from './Input';
export type { InputProps } from './Input';
@@ -164,3 +167,13 @@ export { MobileBlocker, type MobileBlockerProps } from './MobileBlocker';
export { BackOrHomeButton, type BackOrHomeButtonProps } from './BackOrHomeButton';
export { ChartContainer, type ChartConfig } from './chart';
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationLink,
+ PaginationPrevious,
+ PaginationNext,
+ PaginationEllipsis,
+} from './pagination';
diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx
new file mode 100644
index 00000000..ab9e6efc
--- /dev/null
+++ b/src/components/ui/pagination.tsx
@@ -0,0 +1,92 @@
+import * as React from 'react';
+import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
+
+import { cn } from '@/lib/cn';
+
+function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
+ return (
+
+ );
+}
+
+function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
+ return (
+
+ );
+}
+
+function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
+ return ;
+}
+
+interface PaginationLinkProps extends React.ComponentProps<'a'> {
+ isActive?: boolean;
+}
+
+function PaginationLink({ className, isActive, ...props }: PaginationLinkProps) {
+ return (
+
+ );
+}
+
+function PaginationPrevious({ className, ...props }: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function PaginationNext({ className, ...props }: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+
+
+ 더 보기
+
+ );
+}
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationLink,
+ PaginationItem,
+ PaginationPrevious,
+ PaginationNext,
+ PaginationEllipsis,
+};
diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx
index afea54a9..48890990 100644
--- a/src/components/ui/table.tsx
+++ b/src/components/ui/table.tsx
@@ -65,7 +65,7 @@ function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
) {
| [] = [
+ { name: '김지수', department: '컴퓨터공학과', memberRole: 'LEAD' },
+ { name: '이도윤', department: '소프트웨어학과', memberRole: 'ADMIN' },
+ { name: '박서연', department: '정보통신공학과', memberRole: 'USER' },
+ { name: '최민준', department: '전자공학과', memberRole: 'USER' },
+ { name: '정하은', department: '경영학과', memberRole: 'USER' },
+ { name: '윤지호', department: '산업공학과', memberRole: 'USER' },
+ { name: '강나연', department: '컴퓨터공학과', memberRole: 'USER' },
+ { name: '조현우', department: '소프트웨어학과', memberRole: 'ADMIN' },
+ { name: '임서영', department: '정보통신공학과', memberRole: 'USER' },
+ { name: '한지민', department: '전자공학과', memberRole: 'USER' },
+ { name: '신민서', department: '경영학과', memberRole: 'USER' },
+ { name: '오승현', department: '산업공학과', memberRole: 'USER' },
+ { name: '문예린', department: '컴퓨터공학과', memberRole: 'USER' },
+ { name: '권태양', department: '소프트웨어학과', memberRole: 'USER' },
+ { name: '류하진', department: '정보통신공학과', memberRole: 'USER' },
+ { name: '배수현', department: '전자공학과', memberRole: 'USER' },
+ { name: '유재원', department: '경영학과', memberRole: 'USER' },
+ { name: '남가은', department: '산업공학과', memberRole: 'USER' },
+ { name: '고도현', department: '컴퓨터공학과', memberRole: 'USER' },
+ { name: '천지우', department: '소프트웨어학과', memberRole: 'USER' },
+ { name: '장미래', department: '정보통신공학과', memberRole: 'USER' },
+ { name: '허성민', department: '전자공학과', memberRole: 'USER' },
+ { name: '노은채', department: '경영학과', memberRole: 'USER' },
+ { name: '서준혁', department: '산업공학과', memberRole: 'USER' },
+ { name: '공하늘', department: '컴퓨터공학과', memberRole: 'USER' },
+];
+
+/** 납부 대상 Mock 데이터 — 25명 (TARGETED 20, EXCLUDED 5) */
+export const MOCK_PAYMENT_TARGETS: MockPaymentTarget[] = MOCK_MEMBERS.map(
+ ({ name, department, memberRole }, idx) => ({
+ targetId: idx + 1,
+ paymentTargetInfo: {
+ userId: idx + 1,
+ clubMemberId: idx + 1,
+ name,
+ tel: `0101234${String(idx).padStart(4, '0')}`,
+ school: '가천대학교',
+ department,
+ memberRole,
+ memberStatus: 'ACTIVE',
+ profileImageUrl: null,
+ },
+ targetStatus: idx < 20 ? 'TARGETED' : 'EXCLUDED',
+ paymentStatus: 'UNPAID',
+ dueAmount: 50000,
+ paidAmount: 0,
+ paidAt: null,
+ confirmedBy: null,
+ memo: null,
+ }),
+);
+// ─── 이월 잔액 Mock ───────────────────────────────────────────────────────────
+// null = 이전 기수 정보 없음, object = 이전 기수 잔액 존재
+export const MOCK_PREVIOUS_BALANCE: { balance: number; generationNumber: number } | null = {
+ balance: 240000,
+ generationNumber: 3,
+};
+
export const MOCK_DEPARTMENTS = [
'컴퓨터공학과',
'소프트웨어학과',
diff --git a/src/hooks/admin/index.ts b/src/hooks/admin/index.ts
index b3e277d4..c0f881ad 100644
--- a/src/hooks/admin/index.ts
+++ b/src/hooks/admin/index.ts
@@ -1,3 +1,4 @@
export { useFlattenedSessions, type FlattenedSession } from './useFlattenedSessions';
export { useSessionMutations } from './useSessionMutations';
export { useBoardDragReorder } from './useBoardDragReorder';
+export { usePaymentTargetFilter } from './usePaymentTargetFilter';
diff --git a/src/hooks/admin/usePaymentTargetFilter.ts b/src/hooks/admin/usePaymentTargetFilter.ts
new file mode 100644
index 00000000..7bd37cdf
--- /dev/null
+++ b/src/hooks/admin/usePaymentTargetFilter.ts
@@ -0,0 +1,62 @@
+'use client';
+
+import { MOCK_PAYMENT_TARGETS } from '@/constants/mock';
+import { useState } from 'react';
+
+const PAGE_SIZE = 10;
+
+type TabType = 'selected' | 'excluded' | 'all';
+
+function usePaymentTargetFilter(selectedMemberIds: number[]) {
+ const [tab, setTab] = useState('selected');
+ const [search, setSearch] = useState('');
+ const [page, setPage] = useState(1);
+
+ const selectedSet = new Set(selectedMemberIds);
+
+ const totalCount = MOCK_PAYMENT_TARGETS.length;
+ const selectedCount = selectedMemberIds.length;
+ const excludedCount = totalCount - selectedCount;
+
+ 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 currentPage = Math.min(page, totalPages);
+ const pagedTargets = filteredTargets.slice(
+ (currentPage - 1) * PAGE_SIZE,
+ currentPage * PAGE_SIZE,
+ );
+
+ const handleTabChange = (next: TabType) => {
+ setTab(next);
+ setPage(1);
+ };
+
+ const handleSearch = (value: string) => {
+ setSearch(value);
+ setPage(1);
+ };
+
+ return {
+ totalCount,
+ selectedCount,
+ tab,
+ search,
+ selectedSet,
+ page,
+ setPage,
+ excludedCount,
+ totalPages,
+ pagedTargets,
+ handleTabChange,
+ handleSearch,
+ };
+}
+
+export { usePaymentTargetFilter };
diff --git a/src/stores/__tests__/useDuesSetupStore.test.ts b/src/stores/__tests__/useDuesSetupStore.test.ts
new file mode 100644
index 00000000..424bca30
--- /dev/null
+++ b/src/stores/__tests__/useDuesSetupStore.test.ts
@@ -0,0 +1,205 @@
+import { act, renderHook } from '@testing-library/react';
+
+import {
+ useDuesSetupActions,
+ useDuesSetupStore,
+ useDuesSetupValues,
+} from '@/stores/useDuesSetupStore';
+
+const defaultState = {
+ generationNumber: 0,
+ amount: '',
+ name: '',
+ description: '',
+ selectedMemberIds: [] as number[],
+ memberIdsInitialized: false,
+ carryOverOption: 'none' as const,
+ carryOverDescription: '',
+ carryOverInitialized: false,
+ accountNumber: '',
+ bankName: '',
+ accountHolder: '',
+ accountGuide: '',
+ isAccountPublic: false,
+};
+
+beforeEach(() => {
+ localStorage.clear();
+ useDuesSetupStore.setState(defaultState);
+});
+
+describe('초기 상태', () => {
+ it('모든 필드가 기본값으로 초기화된다', () => {
+ const state = useDuesSetupStore.getState();
+
+ expect(state.generationNumber).toBe(0);
+ expect(state.amount).toBe('');
+ expect(state.name).toBe('');
+ expect(state.description).toBe('');
+ expect(state.selectedMemberIds).toEqual([]);
+ expect(state.memberIdsInitialized).toBe(false);
+ expect(state.carryOverOption).toBe('none');
+ expect(state.carryOverDescription).toBe('');
+ expect(state.carryOverInitialized).toBe(false);
+ expect(state.accountNumber).toBe('');
+ expect(state.bankName).toBe('');
+ expect(state.accountHolder).toBe('');
+ expect(state.accountGuide).toBe('');
+ expect(state.isAccountPublic).toBe(false);
+ });
+});
+
+describe('setField', () => {
+ it('Step 1 기본 정보 필드를 업데이트한다', () => {
+ act(() => {
+ useDuesSetupStore.getState().setField({
+ generationNumber: 10,
+ amount: '50000',
+ name: '2025년 1학기 회비',
+ description: '정기 회비입니다',
+ });
+ });
+
+ const state = useDuesSetupStore.getState();
+ expect(state.generationNumber).toBe(10);
+ expect(state.amount).toBe('50000');
+ expect(state.name).toBe('2025년 1학기 회비');
+ expect(state.description).toBe('정기 회비입니다');
+ });
+
+ it('Step 2 납부 대상 필드를 업데이트한다', () => {
+ act(() => {
+ useDuesSetupStore.getState().setField({
+ selectedMemberIds: [1, 2, 3],
+ memberIdsInitialized: true,
+ });
+ });
+
+ const state = useDuesSetupStore.getState();
+ expect(state.selectedMemberIds).toEqual([1, 2, 3]);
+ expect(state.memberIdsInitialized).toBe(true);
+ });
+
+ it('Step 3 이월 설정 필드를 업데이트한다', () => {
+ act(() => {
+ useDuesSetupStore.getState().setField({
+ carryOverOption: 'carry',
+ carryOverDescription: '잔액을 다음 기수로 이월합니다',
+ carryOverInitialized: true,
+ });
+ });
+
+ const state = useDuesSetupStore.getState();
+ expect(state.carryOverOption).toBe('carry');
+ expect(state.carryOverDescription).toBe('잔액을 다음 기수로 이월합니다');
+ expect(state.carryOverInitialized).toBe(true);
+ });
+
+ it('Step 4 계좌 공개 필드를 업데이트한다', () => {
+ act(() => {
+ useDuesSetupStore.getState().setField({
+ accountNumber: '110-123-456789',
+ bankName: '신한은행',
+ accountHolder: '홍길동',
+ accountGuide: '입금 시 이름을 기재해주세요',
+ isAccountPublic: true,
+ });
+ });
+
+ const state = useDuesSetupStore.getState();
+ expect(state.accountNumber).toBe('110-123-456789');
+ expect(state.bankName).toBe('신한은행');
+ expect(state.accountHolder).toBe('홍길동');
+ expect(state.accountGuide).toBe('입금 시 이름을 기재해주세요');
+ expect(state.isAccountPublic).toBe(true);
+ });
+
+ it('업데이트하지 않은 필드는 기존 값을 유지한다', () => {
+ act(() => {
+ useDuesSetupStore.getState().setField({ amount: '30000' });
+ });
+ act(() => {
+ useDuesSetupStore.getState().setField({ name: '회비' });
+ });
+
+ const state = useDuesSetupStore.getState();
+ expect(state.amount).toBe('30000');
+ expect(state.name).toBe('회비');
+ });
+});
+
+describe('reset', () => {
+ it('모든 필드를 초기 상태로 복원한다', () => {
+ act(() => {
+ useDuesSetupStore.getState().setField({
+ amount: '50000',
+ name: '회비',
+ selectedMemberIds: [1, 2],
+ memberIdsInitialized: true,
+ carryOverOption: 'carry',
+ carryOverInitialized: true,
+ isAccountPublic: true,
+ bankName: '카카오뱅크',
+ });
+ });
+
+ act(() => {
+ useDuesSetupStore.getState().reset();
+ });
+
+ const state = useDuesSetupStore.getState();
+ expect(state).toMatchObject(defaultState);
+ });
+});
+
+describe('useDuesSetupValues', () => {
+ it('스토어의 모든 상태 필드를 반환한다', () => {
+ act(() => {
+ useDuesSetupStore.getState().setField({ amount: '20000', generationNumber: 7 });
+ });
+
+ const { result } = renderHook(() => useDuesSetupValues());
+
+ expect(result.current.amount).toBe('20000');
+ expect(result.current.generationNumber).toBe(7);
+ expect(result.current.selectedMemberIds).toEqual([]);
+ expect(result.current.carryOverOption).toBe('none');
+ });
+});
+
+describe('useDuesSetupActions', () => {
+ it('setField와 reset 함수를 반환한다', () => {
+ const { result } = renderHook(() => useDuesSetupActions());
+
+ expect(typeof result.current.setField).toBe('function');
+ expect(typeof result.current.reset).toBe('function');
+ });
+
+ it('훅에서 받은 setField가 스토어를 업데이트한다', () => {
+ const { result } = renderHook(() => useDuesSetupActions());
+
+ act(() => {
+ result.current.setField({ amount: '15000', name: '테스트 회비' });
+ });
+
+ const state = useDuesSetupStore.getState();
+ expect(state.amount).toBe('15000');
+ expect(state.name).toBe('테스트 회비');
+ });
+
+ it('훅에서 받은 reset이 스토어를 초기 상태로 복원한다', () => {
+ act(() => {
+ useDuesSetupStore.getState().setField({ amount: '99000', name: '변경됨' });
+ });
+
+ const { result } = renderHook(() => useDuesSetupActions());
+
+ act(() => {
+ result.current.reset();
+ });
+
+ const state = useDuesSetupStore.getState();
+ expect(state.amount).toBe('');
+ expect(state.name).toBe('');
+ });
+});
diff --git a/src/stores/index.ts b/src/stores/index.ts
index cb9335f4..0ce095da 100644
--- a/src/stores/index.ts
+++ b/src/stores/index.ts
@@ -24,6 +24,12 @@ export {
useBoardNavReset,
} from './useBoardNavStore';
export { useCreateClubDraftStore } from './useCreateClubDraftStore';
+export {
+ useDuesSetupStore,
+ useDuesSetupValues,
+ useDuesSetupActions,
+ type DuesSetupState,
+} from './useDuesSetupStore';
export {
useAdminHeaderStore,
useIsAdminEditMode,
diff --git a/src/stores/useDuesSetupStore.ts b/src/stores/useDuesSetupStore.ts
new file mode 100644
index 00000000..3438ba4b
--- /dev/null
+++ b/src/stores/useDuesSetupStore.ts
@@ -0,0 +1,67 @@
+import { create } from 'zustand';
+import { combine, devtools, persist } from 'zustand/middleware';
+import { useShallow } from 'zustand/react/shallow';
+
+const initialState = {
+ generationNumber: 0,
+ // Step 1: 기본 정보
+ amount: '',
+ name: '',
+ description: '',
+ // Step 2: 납부 대상
+ selectedMemberIds: [] as number[],
+ memberIdsInitialized: false,
+ // Step 3: 이월 설정
+ carryOverOption: 'none' as 'none' | 'carry',
+ carryOverDescription: '',
+ carryOverInitialized: false,
+ // Step 4: 계좌 공개
+ accountNumber: '',
+ bankName: '',
+ accountHolder: '',
+ accountGuide: '',
+ isAccountPublic: false,
+};
+
+export type DuesSetupState = typeof initialState;
+
+export const useDuesSetupStore = create(
+ devtools(
+ persist(
+ combine(initialState, (set) => ({
+ setField: (field: Partial) => set(field, false, 'setField'),
+ reset: () => set(initialState, false, 'reset'),
+ })),
+ { name: 'duesSetup' },
+ ),
+ { name: 'DuesSetupStore' },
+ ),
+);
+
+export const useDuesSetupValues = () =>
+ useDuesSetupStore(
+ useShallow((state) => ({
+ generationNumber: state.generationNumber,
+ amount: state.amount,
+ name: state.name,
+ description: state.description,
+ selectedMemberIds: state.selectedMemberIds,
+ memberIdsInitialized: state.memberIdsInitialized,
+ carryOverOption: state.carryOverOption,
+ carryOverDescription: state.carryOverDescription,
+ carryOverInitialized: state.carryOverInitialized,
+ accountNumber: state.accountNumber,
+ bankName: state.bankName,
+ accountHolder: state.accountHolder,
+ accountGuide: state.accountGuide,
+ isAccountPublic: state.isAccountPublic,
+ })),
+ );
+
+export const useDuesSetupActions = () =>
+ useDuesSetupStore(
+ useShallow((state) => ({
+ setField: state.setField,
+ reset: state.reset,
+ })),
+ );
|