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 ( + + + {/* Header */} +
+

납부 대상

+ +
+ + {/* Body */} +
+ {/* Tabs + Search */} +
+ + +
+ + {/* Table */} + + + {/* Pagination */} + {totalPages > 1 && ( + + )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +} + +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 })} + /> +
+
+
+ + {/* 하단 네비게이션 */} +
+ goToStep(3)} /> + +
+
+ ); +} + +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} + /> +
+
+ + + + {/* 하단 네비게이션 */} +
+ goToStep(4)} /> + +
+
+ ); +} + +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 ( +