From 75520b934e72e78f3d52b13972ee75bfbc108ee4 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 5 Jul 2026 19:35:32 +0900 Subject: [PATCH 01/15] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EB=B2=84=ED=8A=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/dues/DuesPageContent.tsx | 3 +++ .../dues/DuesPaymentStatusPageContent.tsx | 18 ++++-------------- .../admin/dues/DuesPaymentSummaryCard.tsx | 12 ------------ 3 files changed, 7 insertions(+), 26 deletions(-) diff --git a/src/components/admin/dues/DuesPageContent.tsx b/src/components/admin/dues/DuesPageContent.tsx index 86d648af..3a9885ca 100644 --- a/src/components/admin/dues/DuesPageContent.tsx +++ b/src/components/admin/dues/DuesPageContent.tsx @@ -165,6 +165,8 @@ function DuesPageContent() { setAddOpen(true); }; + const handleSetting = () => {}; + const handleEditOpen = () => { if (!selectedTransaction) return; setDetailOpen(false); @@ -184,6 +186,7 @@ function DuesPageContent() { isPublic={isPublic} onPublicChange={handlePublicChange} onAddClick={handleAddTransaction} + onSettingsClick={handleSetting} /> void; className?: string; } -function StatCard({ label, value, action, onAction, className }: StatCardProps) { +function StatCard({ label, value, className }: StatCardProps) { return ( {value} {label} - ); } @@ -237,10 +228,9 @@ function DuesPaymentStatusPageContent() {
- {/* TODO: onAction 핸들러 넘기기 (미납 현황 업데이트) */} - - {/* TODO: onAction 핸들러 넘기기 (납부 대상 수정 - savePaymentTargets 재사용) */} - + + + {account && ( void; } function DuesPaymentSummaryCard({ className, totalCollected, totalTarget, - onUpdatePayment, }: DuesPaymentSummaryCardProps) { const percentage = totalTarget > 0 @@ -33,16 +31,6 @@ function DuesPaymentSummaryCard({
-
- -
-

목표 금액의 {percentage}%가 납부되었어요. From 3f0982dec9ab46dc58e5de6816e3fe9e2c93dc5b Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 5 Jul 2026 19:55:35 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20=ED=9A=8C=EB=B9=84=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=84=A4=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[clubId]/admin/dues/setting/page.tsx | 5 + src/components/admin/dues/DuesPageContent.tsx | 4 +- .../admin/dues/DuesSettingPageContent.tsx | 172 ++++++++++++++++++ src/components/admin/dues/index.ts | 1 + 4 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 src/app/(private)/[clubId]/admin/dues/setting/page.tsx create mode 100644 src/components/admin/dues/DuesSettingPageContent.tsx diff --git a/src/app/(private)/[clubId]/admin/dues/setting/page.tsx b/src/app/(private)/[clubId]/admin/dues/setting/page.tsx new file mode 100644 index 00000000..8cf4fef8 --- /dev/null +++ b/src/app/(private)/[clubId]/admin/dues/setting/page.tsx @@ -0,0 +1,5 @@ +import { DuesSettingPageContent } from '@/components/admin/dues'; + +export default function DuesSettingPage() { + return ; +} diff --git a/src/components/admin/dues/DuesPageContent.tsx b/src/components/admin/dues/DuesPageContent.tsx index 3a9885ca..ca4cb1d7 100644 --- a/src/components/admin/dues/DuesPageContent.tsx +++ b/src/components/admin/dues/DuesPageContent.tsx @@ -165,7 +165,9 @@ function DuesPageContent() { setAddOpen(true); }; - const handleSetting = () => {}; + const handleSetting = () => { + router.push(`/${clubId}/admin/dues/setting`); + }; const handleEditOpen = () => { if (!selectedTransaction) return; diff --git a/src/components/admin/dues/DuesSettingPageContent.tsx b/src/components/admin/dues/DuesSettingPageContent.tsx new file mode 100644 index 00000000..68865c9a --- /dev/null +++ b/src/components/admin/dues/DuesSettingPageContent.tsx @@ -0,0 +1,172 @@ +'use client'; + +import { useState, type ReactNode } from 'react'; + +import { useParams, useRouter } from 'next/navigation'; + +import { useQuery } from '@tanstack/react-query'; + +import { Switch } from '@/components/ui'; +import { useCardinalSelector } from '@/hooks'; +import { + duesRegistrationStatusQueryOptions, + useDuesDashboardQuery, + useDuesPaymentTargetsQuery, +} from '@/hooks/queries/admin'; +import { useUpdateMemberVisibility } from '@/hooks/mutations/admin/useAdminDuesMutations'; +import { useDuesSetupActions } from '@/stores/useDuesSetupStore'; +import { toastError } from '@/stores/useToastStore'; + +import { BackButton } from './BackButton'; +import { PaymentTargetModal } from './modal/PaymentTargetModal'; +import { SettingResultCardGrid } from './setup/components'; + +const MAX_AVATAR_DISPLAY = 4; + +interface SettingSectionProps { + title: string; + children: ReactNode; +} + +// 흰색 카드 컨테이너 + 리스트 헤더 (총 회비 설정 / 회비 공개 범위 공통 래퍼) +function SettingSection({ title, children }: SettingSectionProps) { + return ( +

+
+ {title} +
+
{children}
+
+ ); +} + +function DuesSettingPageContent() { + const router = useRouter(); + const { clubId } = useParams<{ clubId: string }>(); + const { activeCardinal } = useCardinalSelector({ autoSelectLatest: true }); + const { setField } = useDuesSetupActions(); + + // 대시보드로 accountId를 확보한 뒤 등록 상태·납부 대상 목록을 조회한다. + const { data: dashboard } = useDuesDashboardQuery(clubId, activeCardinal?.cardinalNumber ?? null); + const accountId = dashboard?.accountId ?? null; + + const { data: status } = useQuery(duesRegistrationStatusQueryOptions(clubId, accountId)); + const { data: paymentTargets } = useDuesPaymentTargetsQuery(clubId, accountId); + + // TODO: 대시보드 응답에 부원 공개 여부(member-visibility) 필드 추가 요청함(백엔드 대기 중). + // 추가되면 useState(true) 기본값 대신 서버 값으로 초기화할 것. + const [isPublic, setIsPublic] = useState(true); + const [isPaymentTargetModalOpen, setIsPaymentTargetModalOpen] = useState(false); + + const { mutate: updateMemberVisibility } = useUpdateMemberVisibility(clubId, accountId, { + onError: () => toastError('공개 설정 변경에 실패했습니다.'), + }); + + // 낙관적 업데이트: 스위치는 즉시 반영하고, 실패 시 이전 값으로 되돌린다. + const handlePublicChange = (value: boolean) => { + setIsPublic(value); + updateMemberVisibility(value, { + onError: () => setIsPublic(!value), + }); + }; + + const cardinalNumber = activeCardinal?.cardinalNumber ?? 0; + + const targetedMembers = (paymentTargets?.targets.content ?? []).filter( + (t) => t.targetStatus === 'TARGETED', + ); + const selectedMemberIds = targetedMembers.map((t) => t.paymentTargetInfo.clubMemberId); + const displayedAvatars = targetedMembers.slice(0, MAX_AVATAR_DISPLAY); + const remainingCount = Math.max(0, targetedMembers.length - MAX_AVATAR_DISPLAY); + + const hasPreviousBalance = status?.previousAccountBalance != null; + + // 수정 버튼 → 셋업 스텝으로 이동. 스텝이 store 값을 읽어 화면을 그리므로, + // 이동 전에 등록 상태 응답으로 store를 전 단계 rehydrate한다. (useRestoreDuesDraft와 동일 패턴) + const handleEditStep = (step: number) => { + if (accountId === null) return; + + setField({ + accountId, + cardinalNumber, + selectedMemberIds, + memberIdsInitialized: true, + ...(status?.basic && { + name: status.basic.name, + amount: String(status.basic.duesAmount), + description: status.basic.description ?? '', + }), + ...(status?.carryOver && { + carryOverOption: status.carryOver.enabled ? 'carry' : 'none', + carryOverAmount: status.carryOver.amount != null ? String(status.carryOver.amount) : '', + carryOverDescription: status.carryOver.memo ?? '', + carryOverInitialized: true, + }), + ...(status?.bankAccount && { + isAccountPublic: status.bankAccount.bankAccountVisible, + bankName: status.bankAccount.bankAccount?.bankName ?? '', + accountNumber: status.bankAccount.bankAccount?.accountNumber ?? '', + accountHolder: status.bankAccount.bankAccount?.holder ?? '', + accountGuide: status.bankAccount.bankAccount?.guide ?? '', + }), + }); + + router.push(`/${clubId}/admin/dues/setup/${step}`); + }; + + return ( +
+ {/* 헤더 */} +
+ +

회비 관리 설정

+
+ +
+ + setIsPaymentTargetModalOpen(true)} + hasPreviousBalance={hasPreviousBalance} + previousGeneration={status?.previousAccountBalance?.cardinalNumber ?? cardinalNumber - 1} + previousBalance={status?.previousAccountBalance?.balance ?? 0} + carryOverOption={status?.carryOver?.enabled ? 'carry' : 'none'} + carryOverDescription={status?.carryOver?.memo ?? undefined} + isAccountPublic={status?.bankAccount?.bankAccountVisible ?? false} + accountNumber={status?.bankAccount?.bankAccount?.accountNumber ?? undefined} + bankName={status?.bankAccount?.bankAccount?.bankName ?? undefined} + accountHolder={status?.bankAccount?.bankAccount?.holder ?? undefined} + accountGuide={status?.bankAccount?.bankAccount?.guide ?? undefined} + onEditStep={handleEditStep} + /> + + + +
+
+ 전체 회비 내역 공개 + + 부원이 회비 사용 내역을 볼 수 있어요 + +
+ +
+
+
+ + +
+ ); +} + +export { DuesSettingPageContent }; diff --git a/src/components/admin/dues/index.ts b/src/components/admin/dues/index.ts index 2528e5f8..6721cf1a 100644 --- a/src/components/admin/dues/index.ts +++ b/src/components/admin/dues/index.ts @@ -10,6 +10,7 @@ export { type TransactionType, } from './DuesTransactionTable'; export { DuesPaymentStatusPageContent } from './DuesPaymentStatusPageContent'; +export { DuesSettingPageContent } from './DuesSettingPageContent'; export { DuesPaymentSummaryCard, type DuesPaymentSummaryCardProps } from './DuesPaymentSummaryCard'; export { DuesMemberPaymentTable, From 26bf6074504caca4a5a452d3e83542c7f20fd3db Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Jul 2026 10:50:59 +0900 Subject: [PATCH 03/15] =?UTF-8?q?fix:=20=ED=9A=8C=EB=B9=84=20=EC=B4=9D?= =?UTF-8?q?=EC=95=A1=20=EC=88=98=EC=A0=95=20=EC=8B=9C=20=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dues/DuesSettingPageContent.tsx | 39 ++----------------- 1 file changed, 3 insertions(+), 36 deletions(-) diff --git a/src/components/admin/dues/DuesSettingPageContent.tsx b/src/components/admin/dues/DuesSettingPageContent.tsx index 68865c9a..a6116681 100644 --- a/src/components/admin/dues/DuesSettingPageContent.tsx +++ b/src/components/admin/dues/DuesSettingPageContent.tsx @@ -2,7 +2,7 @@ import { useState, type ReactNode } from 'react'; -import { useParams, useRouter } from 'next/navigation'; +import { useParams } from 'next/navigation'; import { useQuery } from '@tanstack/react-query'; @@ -14,7 +14,6 @@ import { useDuesPaymentTargetsQuery, } from '@/hooks/queries/admin'; import { useUpdateMemberVisibility } from '@/hooks/mutations/admin/useAdminDuesMutations'; -import { useDuesSetupActions } from '@/stores/useDuesSetupStore'; import { toastError } from '@/stores/useToastStore'; import { BackButton } from './BackButton'; @@ -41,10 +40,8 @@ function SettingSection({ title, children }: SettingSectionProps) { } function DuesSettingPageContent() { - const router = useRouter(); const { clubId } = useParams<{ clubId: string }>(); const { activeCardinal } = useCardinalSelector({ autoSelectLatest: true }); - const { setField } = useDuesSetupActions(); // 대시보드로 accountId를 확보한 뒤 등록 상태·납부 대상 목록을 조회한다. const { data: dashboard } = useDuesDashboardQuery(clubId, activeCardinal?.cardinalNumber ?? null); @@ -81,38 +78,8 @@ function DuesSettingPageContent() { const hasPreviousBalance = status?.previousAccountBalance != null; - // 수정 버튼 → 셋업 스텝으로 이동. 스텝이 store 값을 읽어 화면을 그리므로, - // 이동 전에 등록 상태 응답으로 store를 전 단계 rehydrate한다. (useRestoreDuesDraft와 동일 패턴) - const handleEditStep = (step: number) => { - if (accountId === null) return; - - setField({ - accountId, - cardinalNumber, - selectedMemberIds, - memberIdsInitialized: true, - ...(status?.basic && { - name: status.basic.name, - amount: String(status.basic.duesAmount), - description: status.basic.description ?? '', - }), - ...(status?.carryOver && { - carryOverOption: status.carryOver.enabled ? 'carry' : 'none', - carryOverAmount: status.carryOver.amount != null ? String(status.carryOver.amount) : '', - carryOverDescription: status.carryOver.memo ?? '', - carryOverInitialized: true, - }), - ...(status?.bankAccount && { - isAccountPublic: status.bankAccount.bankAccountVisible, - bankName: status.bankAccount.bankAccount?.bankName ?? '', - accountNumber: status.bankAccount.bankAccount?.accountNumber ?? '', - accountHolder: status.bankAccount.bankAccount?.holder ?? '', - accountGuide: status.bankAccount.bankAccount?.guide ?? '', - }), - }); - - router.push(`/${clubId}/admin/dues/setup/${step}`); - }; + // TODO: 카드별 "수정" 동작 연결 예정. (온보딩 스텝 이동은 하지 않기로 함) + const handleEditStep = () => {}; return (
From 82a03d48d7a6cdb2cca6cd1c0655408ec02e67b6 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Jul 2026 11:01:58 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20=ED=9A=8C=EB=B9=84=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EB=82=B4=EC=97=AD=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/dues/DuesPageContent.tsx | 41 +++++++++++++++++-- .../dues/modal/TransactionDetailModal.tsx | 23 ++++++++++- src/hooks/queries/admin/adminQueryKeys.ts | 4 ++ .../queries/admin/useAdminDuesQueries.ts | 30 +++++++++++++- src/lib/apis/adminDues.ts | 6 +++ 5 files changed, 98 insertions(+), 6 deletions(-) diff --git a/src/components/admin/dues/DuesPageContent.tsx b/src/components/admin/dues/DuesPageContent.tsx index ca4cb1d7..fd832c99 100644 --- a/src/components/admin/dues/DuesPageContent.tsx +++ b/src/components/admin/dues/DuesPageContent.tsx @@ -4,7 +4,12 @@ import { useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import type { MonthlyData, DuesTransaction, TransactionFilter } from '@/types/admin/dues'; +import type { + MonthlyData, + DuesTransaction, + TransactionFilter, + TransactionItem, +} from '@/types/admin/dues'; import { useCardinalSelector } from '@/hooks'; import { isDuesNotRegisteredError, useDuesDashboardQuery } from '@/hooks/queries/admin'; import { useDuesSetupActions } from '@/stores/useDuesSetupStore'; @@ -20,7 +25,10 @@ import type { TransactionDetail } from './modal/TransactionDetailModal'; import type { TransactionFormData } from './modal/TransactionForm'; import { DuesTransactionTable } from './DuesTransactionTable'; import { DuesTutorialModal } from './modal/DuesTutorialModal'; -import { useAdminDuesTransactionsQuery } from '@/hooks/queries/admin/useAdminDuesQueries'; +import { + useAdminDuesTransactionsQuery, + useAdminDuesTransactionQuery, +} from '@/hooks/queries/admin/useAdminDuesQueries'; import { useCreateTransaction, useDeleteTransaction, @@ -41,6 +49,7 @@ function toPeriodLabel(yearMonth: string | undefined): string { return `${year}.${month}.`; } +// 목록 데이터(DuesTransaction) → 상세 모달용. 상세 응답 도착 전 폴백으로 사용한다. function toTransactionDetail(tx: DuesTransaction): TransactionDetail { return { type: tx.type, @@ -53,6 +62,20 @@ function toTransactionDetail(tx: DuesTransaction): TransactionDetail { }; } +// 상세 응답(TransactionItem) → 상세 모달용. 목록에 없는 메모·영수증 정보까지 반영한다. +function detailToTransactionDetail(detail: TransactionItem): TransactionDetail { + return { + type: detail.type, + direction: detail.direction, + amount: String(detail.amount), + description: detail.title, + vendor: detail.source, + date: detail.transactedAt.slice(0, 10), + memo: detail.memo || undefined, + receiptUrl: detail.receipts[0]?.fileUrl, + }; +} + function DuesPageContent() { // TODO: 대시보드 응답에 부원 공개 여부(member-visibility) 필드 추가 요청함(백엔드 대기 중). // 추가되면 useState(true) 기본값 대신 dashboard 값으로 초기화할 것. @@ -156,6 +179,14 @@ function DuesPageContent() { const [selectedTransaction, setSelectedTransaction] = useState(null); const [editingValues, setEditingValues] = useState>(); + // 상세 모달이 열려 있을 때만 선택된 거래의 단건 상세(메모·영수증 포함)를 조회한다. + const { data: transactionDetail } = useAdminDuesTransactionQuery( + clubId, + dashboard?.accountId ?? 0, + selectedTransaction?.id ?? null, + detailOpen, + ); + const handleMoreClick = (tx: DuesTransaction) => { setSelectedTransaction(tx); setDetailOpen(true); @@ -252,7 +283,11 @@ function DuesPageContent() { deleteTransaction(selectedTransaction.id)} /> diff --git a/src/components/admin/dues/modal/TransactionDetailModal.tsx b/src/components/admin/dues/modal/TransactionDetailModal.tsx index e4b25ae8..44ed093a 100644 --- a/src/components/admin/dues/modal/TransactionDetailModal.tsx +++ b/src/components/admin/dues/modal/TransactionDetailModal.tsx @@ -29,6 +29,7 @@ interface TransactionDetail { description: string; vendor: string; date: string; + memo?: string; category?: string; registrant?: string; receiptUrl?: string; @@ -50,8 +51,18 @@ function TransactionDetailModal({ onDelete, }: TransactionDetailModalProps) { const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const { type, direction, amount, description, vendor, date, category, registrant, receiptUrl } = - transaction; + const { + type, + direction, + amount, + description, + vendor, + date, + memo, + category, + registrant, + receiptUrl, + } = transaction; const typeConfig = TRANSACTION_TYPE_TAG[type]; const sign = direction === 'INCOME' ? '+' : '-'; @@ -134,6 +145,14 @@ function TransactionDetailModal({ {registrant}
)} + {memo && ( +
+ 메모 + + {memo} + +
+ )}
diff --git a/src/hooks/queries/admin/adminQueryKeys.ts b/src/hooks/queries/admin/adminQueryKeys.ts index 16565910..60e4f7f3 100644 --- a/src/hooks/queries/admin/adminQueryKeys.ts +++ b/src/hooks/queries/admin/adminQueryKeys.ts @@ -34,6 +34,10 @@ export const adminQueryKeys = { params?: TransactionsParams, ) => ['admin', 'dues', 'transactions', clubId, accountId, params] as const, + // 회비 거래내역 상세 — accountId + transactionId 단위로 스코프 + duesTransaction: (clubId: string | null, accountId: number | null, transactionId: number | null) => + ['admin', 'dues', 'transaction', clubId, accountId, transactionId] as const, + // 회비 대시보드 — 기수 번호(cardinalNumber) 단위로 스코프 duesDashboard: (clubId: string, cardinalNumber: number | null) => ['admin', 'dues', 'dashboard', clubId, cardinalNumber] as const, diff --git a/src/hooks/queries/admin/useAdminDuesQueries.ts b/src/hooks/queries/admin/useAdminDuesQueries.ts index da80eb3a..c4fefd8b 100644 --- a/src/hooks/queries/admin/useAdminDuesQueries.ts +++ b/src/hooks/queries/admin/useAdminDuesQueries.ts @@ -1,7 +1,12 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { duesApi } from '@/lib/apis/adminDues'; -import type { DuesTransaction, TransactionCounts, TransactionsParams } from '@/types/admin/dues'; +import type { + DuesTransaction, + TransactionCounts, + TransactionItem, + TransactionsParams, +} from '@/types/admin/dues'; import { adminQueryKeys } from './adminQueryKeys'; @@ -44,3 +49,26 @@ export function useAdminDuesTransactionsQuery( }), }); } + +/** + * 회비 거래내역 상세 조회 훅. + * + * 목록에는 없는 메모·영수증 파일 정보까지 담긴 단건 상세를 조회한다. + * 상세 모달이 열릴 때만 호출되도록 `enabled`로 게이팅한다. + */ +export function useAdminDuesTransactionQuery( + clubId: string, + accountId: number, + transactionId: number | null, + enabled = true, +) { + return useQuery({ + queryKey: adminQueryKeys.duesTransaction(clubId, accountId, transactionId), + queryFn: (): Promise => + duesApi + .getTransaction(clubId, accountId, transactionId as number) + .then((res) => res.data.data), + enabled: enabled && !!clubId && accountId > 0 && transactionId !== null, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/src/lib/apis/adminDues.ts b/src/lib/apis/adminDues.ts index 1bdc41dc..44945532 100644 --- a/src/lib/apis/adminDues.ts +++ b/src/lib/apis/adminDues.ts @@ -15,6 +15,7 @@ import type { SaveCarryOverBody, SavePaymentTargetsBody, TransactionBody, + TransactionItem, TransactionsInfo, TransactionsParams, } from '@/types/admin/dues'; @@ -43,6 +44,11 @@ export const duesApi = { { params }, ), + getTransaction: (clubId: string, accountId: number, transactionId: number) => + apiClient.get>( + `/admin/clubs/${clubId}/accounts/${accountId}/transactions/${transactionId}`, + ), + // 부원 거래 내역 공개 여부 수정 updateMemberVisibility: (clubId: string, accountId: number, body: MemberVisibilityBody) => apiClient.patch(`/admin/clubs/${clubId}/accounts/${accountId}/member-visibility`, body), From 0ac3503e76c1b46635872239be36cd44141a8b92 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Jul 2026 11:12:53 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=ED=9A=8C=EB=B9=84=20=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/dues/DuesPageContent.tsx | 17 ++- .../admin/dues/DuesPageSkeleton.tsx | 103 ++++++++++++++++++ src/components/admin/dues/index.ts | 1 + .../dues/modal/TransactionDetailModal.tsx | 62 +++++------ 4 files changed, 147 insertions(+), 36 deletions(-) create mode 100644 src/components/admin/dues/DuesPageSkeleton.tsx diff --git a/src/components/admin/dues/DuesPageContent.tsx b/src/components/admin/dues/DuesPageContent.tsx index fd832c99..e4be5d0d 100644 --- a/src/components/admin/dues/DuesPageContent.tsx +++ b/src/components/admin/dues/DuesPageContent.tsx @@ -13,6 +13,7 @@ import type { import { useCardinalSelector } from '@/hooks'; import { isDuesNotRegisteredError, useDuesDashboardQuery } from '@/hooks/queries/admin'; import { useDuesSetupActions } from '@/stores/useDuesSetupStore'; +import { DuesPageSkeleton } from './DuesPageSkeleton'; import { DuesTopBar } from './DuesTopBar'; import { DuesBalanceCard } from './DuesBalanceCard'; import { DuesChart } from './DuesChart'; @@ -73,6 +74,7 @@ function detailToTransactionDetail(detail: TransactionItem): TransactionDetail { date: detail.transactedAt.slice(0, 10), memo: detail.memo || undefined, receiptUrl: detail.receipts[0]?.fileUrl, + receipts: detail.receipts, }; } @@ -89,10 +91,11 @@ function DuesPageContent() { const { reset, setField } = useDuesSetupActions(); // 회비 대시보드 조회. 등록이 완료되지 않은 장부(20112)면 온보딩 튜토리얼 모달을 띄운다. - const { data: dashboard, error: dashboardError } = useDuesDashboardQuery( - clubId, - activeCardinal?.cardinalNumber ?? null, - ); + const { + data: dashboard, + error: dashboardError, + isPending: isDashboardPending, + } = useDuesDashboardQuery(clubId, activeCardinal?.cardinalNumber ?? null); const isNotRegistered = isDuesNotRegisteredError(dashboardError); // 거래내역 필터/정렬/페이지 — 서버 파라미터로 전달 (page는 UI 1-base → API 0-base 변환) @@ -213,6 +216,12 @@ function DuesPageContent() { setEditOpen(true); }; + // 대시보드 로딩 중(기수 선택 대기 포함)에는 스켈레톤을 노출한다. + // 등록 미완료(20112)는 에러 상태라 isPending=false이므로 아래 튜토리얼 모달 흐름으로 넘어간다. + if (isDashboardPending) { + return ; + } + return (
+ {/* TopBar */} +
+ +
+ + +
+
+ + {/* GenerationFilter */} +
+ + +
+ + {/* BalanceCard + Chart */} +
+ + +
+ + {/* TransactionTable */} + +
+ ); +} + +function DuesBalanceCardSkeleton() { + return ( +
+ + + + +
+ + +
+
+ ); +} + +function DuesChartSkeleton() { + return ( +
+
+
+ + +
+ +
+ +
+ {[70, 45, 90, 55, 80, 60].map((height, index) => ( + + ))} +
+
+ ); +} + +function DuesTransactionTableSkeleton() { + return ( +
+ + +
+
+
+ {Array.from({ length: 4 }, (_, index) => ( + + ))} +
+ +
+ +
+ + {Array.from({ length: 5 }, (_, index) => ( +
+ + + + +
+ ))} +
+
+
+ ); +} + +export { DuesPageSkeleton }; diff --git a/src/components/admin/dues/index.ts b/src/components/admin/dues/index.ts index 6721cf1a..a1040878 100644 --- a/src/components/admin/dues/index.ts +++ b/src/components/admin/dues/index.ts @@ -1,4 +1,5 @@ export { DuesPageContent } from './DuesPageContent'; +export { DuesPageSkeleton } from './DuesPageSkeleton'; export { DuesGenerationFilter, type DuesGenerationFilterProps } from './DuesGenerationFilter'; export { DuesTopBar, type DuesTopBarProps } from './DuesTopBar'; export { DuesBalanceCard, type DuesBalanceCardProps } from './DuesBalanceCard'; diff --git a/src/components/admin/dues/modal/TransactionDetailModal.tsx b/src/components/admin/dues/modal/TransactionDetailModal.tsx index 44ed093a..5f833e64 100644 --- a/src/components/admin/dues/modal/TransactionDetailModal.tsx +++ b/src/components/admin/dues/modal/TransactionDetailModal.tsx @@ -21,6 +21,9 @@ import { SCHEDULE_MODAL_CONTENT_CLASS } from '@/components/admin/schedule/modal/ import { cn } from '@/lib/cn'; import { TRANSACTION_TYPE_TAG } from '../DuesTransactionTable'; import type { TransactionDirection, TransactionType } from '@/types/admin/dues'; +import { DuesReceiptCard, DuesReceiptViewerModal } from '@/components/dues'; +import { getReceiptFiles } from '@/utils/dues/duesTransaction'; +import type { DuesReceiptFile, DuesTransaction } from '@/types/dues'; interface TransactionDetail { type: TransactionType; @@ -33,6 +36,7 @@ interface TransactionDetail { category?: string; registrant?: string; receiptUrl?: string; + receipts?: DuesReceiptFile[]; } interface TransactionDetailModalProps { @@ -51,6 +55,7 @@ function TransactionDetailModal({ onDelete, }: TransactionDetailModalProps) { const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [receiptViewerOpen, setReceiptViewerOpen] = useState(false); const { type, direction, @@ -62,6 +67,7 @@ function TransactionDetailModal({ category, registrant, receiptUrl, + receipts, } = transaction; const typeConfig = TRANSACTION_TYPE_TAG[type]; @@ -70,6 +76,18 @@ function TransactionDetailModal({ const formattedAmount = (isNaN(numAmount) ? 0 : numAmount).toLocaleString('ko-KR'); const classificationLabel = category ? `${typeConfig.label} · ${category}` : typeConfig.label; + const receiptTransaction: DuesTransaction = { + id: 0, + type: direction === 'INCOME' ? 'income' : 'expense', + title: description, + description, + amount: isNaN(numAmount) ? 0 : numAmount, + date, + receipts, + receiptUrl, + }; + const receiptFiles = getReceiptFiles(receiptTransaction); + return ( - {/* 영수증 카드 */} -
- {receiptUrl ? ( - <> - {/* eslint-disable-next-line @next/next/no-img-element */} - -
-

영수증

-

원본 보기

-
- - - - ) : ( -
-

영수증

-

첨부된 영수증이 없습니다

-
- )} -
+ setReceiptViewerOpen(true)} + /> + + {/* Footer */} From 6f71cc338344d82c47623efb7f382625a181aace Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Jul 2026 11:15:36 +0900 Subject: [PATCH 06/15] =?UTF-8?q?fix:=20=ED=9A=8C=EB=B9=84=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/admin/dues.constants.ts | 1 + src/hooks/queries/admin/useDuesDashboardQuery.ts | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/constants/admin/dues.constants.ts b/src/constants/admin/dues.constants.ts index 32cb37cb..a0cb4215 100644 --- a/src/constants/admin/dues.constants.ts +++ b/src/constants/admin/dues.constants.ts @@ -2,3 +2,4 @@ /** 등록이 완료되지 않은 장부에 대시보드를 조회했을 때 반환되는 코드 (온보딩 필요) */ export const DUES_NOT_REGISTERED_CODE = 20112; +export const DUES_NOT_EXIST_CODE = 20100; diff --git a/src/hooks/queries/admin/useDuesDashboardQuery.ts b/src/hooks/queries/admin/useDuesDashboardQuery.ts index 20e1cfd0..dafcb81e 100644 --- a/src/hooks/queries/admin/useDuesDashboardQuery.ts +++ b/src/hooks/queries/admin/useDuesDashboardQuery.ts @@ -1,6 +1,6 @@ import { skipToken, useQuery } from '@tanstack/react-query'; -import { DUES_NOT_REGISTERED_CODE } from '@/constants/admin/dues.constants'; +import { DUES_NOT_EXIST_CODE, DUES_NOT_REGISTERED_CODE } from '@/constants/admin/dues.constants'; import { duesApi } from '@/lib/apis/adminDues'; import { getApiErrorCode } from '@/utils/shared/getApiErrorCode'; @@ -26,5 +26,8 @@ export function useDuesDashboardQuery(clubId: string, cardinalNumber: number | n /** 대시보드 조회 에러가 "등록 미완료 장부(20112)"인지 판별 */ export function isDuesNotRegisteredError(error: unknown): boolean { - return getApiErrorCode(error) === DUES_NOT_REGISTERED_CODE; + return ( + getApiErrorCode(error) === DUES_NOT_REGISTERED_CODE || + getApiErrorCode(error) === DUES_NOT_EXIST_CODE + ); } From 5565bba0af6fc1a905d9c52925237d395ebed183 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Jul 2026 11:23:34 +0900 Subject: [PATCH 07/15] =?UTF-8?q?fix:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=ED=88=B4=ED=8C=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dues/setup/DuesSetupStep1.tsx | 6 ++--- .../dues/setup/components/SetupHeader.tsx | 24 +++++++++++++++++++ .../admin/dues/setup/components/index.ts | 1 + 3 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 src/components/admin/dues/setup/components/SetupHeader.tsx diff --git a/src/components/admin/dues/setup/DuesSetupStep1.tsx b/src/components/admin/dues/setup/DuesSetupStep1.tsx index 9f9e6efc..e33e92b1 100644 --- a/src/components/admin/dues/setup/DuesSetupStep1.tsx +++ b/src/components/admin/dues/setup/DuesSetupStep1.tsx @@ -20,6 +20,7 @@ import { FormCard, NextButton, DuesAmountField, + SetupHeader, } from '@/components/admin/dues/setup/components'; import { useDuesStepNavigator } from '@/hooks/admin/useDuesStepNavigator'; import { useRestoreDuesDraft } from '@/hooks/admin/useRestoreDuesDraft'; @@ -137,10 +138,7 @@ function DuesSetupStep1() {
{/* 헤더 */} -
- -

{cardinalNumber}기 총 회비 설정

-
+
{/* 스텝 인디케이터 */} diff --git a/src/components/admin/dues/setup/components/SetupHeader.tsx b/src/components/admin/dues/setup/components/SetupHeader.tsx new file mode 100644 index 00000000..d74d6a98 --- /dev/null +++ b/src/components/admin/dues/setup/components/SetupHeader.tsx @@ -0,0 +1,24 @@ +import { Icon, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui'; +import { BackButton } from '../../BackButton'; +import { QuestionCircleIcon } from '@/assets/icons'; + +function SetupHeader({ cardinalNumber }: { cardinalNumber: number }) { + return ( +
+ +
+

{cardinalNumber}기 총 회비 설정

+ + + + + + 다음으로 버튼을 눌러야 변경사항이 저장됩니다. + + +
+
+ ); +} + +export { SetupHeader }; diff --git a/src/components/admin/dues/setup/components/index.ts b/src/components/admin/dues/setup/components/index.ts index 71f46e1a..1c33600d 100644 --- a/src/components/admin/dues/setup/components/index.ts +++ b/src/components/admin/dues/setup/components/index.ts @@ -17,3 +17,4 @@ export { } from './SettingResultCardGrid'; export { DuesPagination, type DuesPaginationProps } from './DuesPagination'; export { DuesTabs, type DuesTabsProps } from './DuesTabs'; +export { SetupHeader } from './SetupHeader'; From 4b8d57a28395a054fdba539773961765b6b91970 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Jul 2026 11:24:38 +0900 Subject: [PATCH 08/15] =?UTF-8?q?fix:=20=EC=A0=84=EC=B2=B4=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/dues/setup/DuesSetupStep2.tsx | 6 ++---- src/components/admin/dues/setup/DuesSetupStep3.tsx | 6 ++---- src/components/admin/dues/setup/DuesSetupStep4.tsx | 6 ++---- src/components/admin/dues/setup/DuesSetupStep5.tsx | 6 ++---- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/src/components/admin/dues/setup/DuesSetupStep2.tsx b/src/components/admin/dues/setup/DuesSetupStep2.tsx index fb4abfa0..d90296e3 100644 --- a/src/components/admin/dues/setup/DuesSetupStep2.tsx +++ b/src/components/admin/dues/setup/DuesSetupStep2.tsx @@ -16,6 +16,7 @@ import { DuesTabs, NextButton, PrevButton, + SetupHeader, } from '@/components/admin/dues/setup/components'; import { useDuesSetupNavigation } from '@/hooks/admin/useDuesSetupNavigation'; import { useDuesStepNavigator } from '@/hooks/admin/useDuesStepNavigator'; @@ -89,10 +90,7 @@ function DuesSetupStep2() { return (
{/* 헤더 */} -
- -

{cardinalNumber}기 총 회비 설정

-
+
diff --git a/src/components/admin/dues/setup/DuesSetupStep3.tsx b/src/components/admin/dues/setup/DuesSetupStep3.tsx index 631426da..b601a30f 100644 --- a/src/components/admin/dues/setup/DuesSetupStep3.tsx +++ b/src/components/admin/dues/setup/DuesSetupStep3.tsx @@ -16,6 +16,7 @@ import { PrevButton, CarryOverCard, DuesAmountField, + SetupHeader, } from '@/components/admin/dues/setup/components'; import { useDuesSetupNavigation } from '@/hooks/admin/useDuesSetupNavigation'; import { useDuesStepNavigator } from '@/hooks/admin/useDuesStepNavigator'; @@ -76,10 +77,7 @@ function DuesSetupStep3() { return (
{/* 헤더 */} -
- -

{cardinalNumber}기 총 회비 설정

-
+
diff --git a/src/components/admin/dues/setup/DuesSetupStep4.tsx b/src/components/admin/dues/setup/DuesSetupStep4.tsx index b1e89afe..808a2596 100644 --- a/src/components/admin/dues/setup/DuesSetupStep4.tsx +++ b/src/components/admin/dues/setup/DuesSetupStep4.tsx @@ -18,6 +18,7 @@ import { FormCard, NextButton, PrevButton, + SetupHeader, } from '@/components/admin/dues/setup/components'; import { useDuesSetupNavigation } from '@/hooks/admin/useDuesSetupNavigation'; import { useDuesStepNavigator } from '@/hooks/admin/useDuesStepNavigator'; @@ -97,10 +98,7 @@ function DuesSetupStep4() { return (
{/* 헤더 */} -
- -

{cardinalNumber}기 총 회비 설정

-
+
diff --git a/src/components/admin/dues/setup/DuesSetupStep5.tsx b/src/components/admin/dues/setup/DuesSetupStep5.tsx index 6871d077..8e27f8d5 100644 --- a/src/components/admin/dues/setup/DuesSetupStep5.tsx +++ b/src/components/admin/dues/setup/DuesSetupStep5.tsx @@ -19,6 +19,7 @@ import { NextButton, PrevButton, SettingResultCardGrid, + SetupHeader, } from '@/components/admin/dues/setup/components'; import { useDuesSetupNavigation } from '@/hooks/admin/useDuesSetupNavigation'; @@ -114,10 +115,7 @@ function DuesSetupStep5() { return (
{/* 헤더 */} -
- -

{cardinalNumber}기 총 회비 설정

-
+
From 1afbd2fffb9baa10085caf88a68789bb343aaac4 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Jul 2026 11:28:03 +0900 Subject: [PATCH 09/15] =?UTF-8?q?fix:=20=EB=82=A9=EB=B6=80=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=20=EB=A9=A4=EB=B2=84=20=EC=A0=84=ED=99=94=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=ED=8F=AC=EB=A9=A7=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/dues/DuesMemberPaymentTable.tsx | 5 ++++- src/components/admin/dues/DuesPaymentStatusPageContent.tsx | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/admin/dues/DuesMemberPaymentTable.tsx b/src/components/admin/dues/DuesMemberPaymentTable.tsx index 393615bc..4ab51569 100644 --- a/src/components/admin/dues/DuesMemberPaymentTable.tsx +++ b/src/components/admin/dues/DuesMemberPaymentTable.tsx @@ -13,6 +13,7 @@ import { TableRow, } from '@/components/ui'; import { cn } from '@/lib/cn'; +import { formatPhone } from '@/utils/shared'; import { DuesPagination } from '@/components/admin/dues/setup/components'; import { DuesMember, FilterType, PaymentStatus } from '@/types/admin/dues'; import { DuesSearchBar } from './DuesSearchBar'; @@ -202,7 +203,9 @@ function DuesMemberPaymentTable({
{member.major} - {member.phone} + + {formatPhone(member.phone)} + diff --git a/src/components/admin/dues/DuesPaymentStatusPageContent.tsx b/src/components/admin/dues/DuesPaymentStatusPageContent.tsx index 3c8c7197..77c88ec6 100644 --- a/src/components/admin/dues/DuesPaymentStatusPageContent.tsx +++ b/src/components/admin/dues/DuesPaymentStatusPageContent.tsx @@ -181,9 +181,9 @@ function DuesPaymentStatusPageContent() { type="button" onClick={() => setSelectedIds(new Set())} aria-label="선택 해제" - className="text-text-inverse hover:bg-container-primary-interaction cursor-pointer rounded-sm p-200 transition-colors" + className="text-text-inverse hover:bg-container-primary-interaction flex cursor-pointer self-center rounded-sm p-200 transition-colors" > - + {selectedIds.size}명 선택됨
From 8b6cd9a9778ebda92fda6c0a1eef1f4ee383f9fd Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Jul 2026 11:38:08 +0900 Subject: [PATCH 10/15] =?UTF-8?q?fix:=20=EB=82=A9=EB=B6=80=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=20=EB=A9=A4=EB=B2=84=20=EC=83=81=ED=83=9C=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=EB=A9=A4=EB=B2=84=20=EC=84=A0=ED=83=9D=20=EC=95=88?= =?UTF-8?q?=20=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(private)/[clubId]/admin/layout.tsx | 2 +- .../admin/dues/DuesMemberPaymentTable.tsx | 42 ++++++++++---- .../admin/dues/MemberSeletHeader.tsx | 58 +++++++++++++++++++ 3 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 src/components/admin/dues/MemberSeletHeader.tsx diff --git a/src/app/(private)/[clubId]/admin/layout.tsx b/src/app/(private)/[clubId]/admin/layout.tsx index abc6bdcb..9047fe10 100644 --- a/src/app/(private)/[clubId]/admin/layout.tsx +++ b/src/app/(private)/[clubId]/admin/layout.tsx @@ -56,7 +56,7 @@ export default async function AdminLayout({
-
+
{children}
diff --git a/src/components/admin/dues/DuesMemberPaymentTable.tsx b/src/components/admin/dues/DuesMemberPaymentTable.tsx index 4ab51569..fa84d4a2 100644 --- a/src/components/admin/dues/DuesMemberPaymentTable.tsx +++ b/src/components/admin/dues/DuesMemberPaymentTable.tsx @@ -102,10 +102,21 @@ function DuesMemberPaymentTable({ setPage(1); }; - const toggleSelect = (id: number) => { + // 현재 선택된 멤버들의 납부 상태(모두 동일하게 유지된다). 선택이 없으면 null. + const selectedStatus: PaymentStatus | null = + selectedIds.size === 0 + ? null + : (members.find((m) => selectedIds.has(m.id))?.status ?? null); + + const toggleSelect = (id: number, status: PaymentStatus) => { const next = new Set(selectedIds); - if (next.has(id)) next.delete(id); - else next.add(id); + if (next.has(id)) { + next.delete(id); + } else { + // 첫 선택 상태와 다른 상태는 함께 선택할 수 없다(벌크 액션이 일부에게 무의미해지는 것 방지). + if (selectedStatus !== null && status !== selectedStatus) return; + next.add(id); + } onSelectionChange(next); }; @@ -168,7 +179,12 @@ function DuesMemberPaymentTable({ ) : ( - paged.map((member) => ( + paged.map((member) => { + const isSelected = selectedIds.has(member.id); + // 선택 진행 중이고, 현재 선택 상태와 다른 상태의 멤버는 함께 선택할 수 없다. + const isDisabled = + !isSelected && selectedStatus !== null && member.status !== selectedStatus; + return ( @@ -220,7 +241,8 @@ function DuesMemberPaymentTable({ */} - )) + ); + }) )} diff --git a/src/components/admin/dues/MemberSeletHeader.tsx b/src/components/admin/dues/MemberSeletHeader.tsx new file mode 100644 index 00000000..5044a60c --- /dev/null +++ b/src/components/admin/dues/MemberSeletHeader.tsx @@ -0,0 +1,58 @@ +import { BackIcon } from '@/assets/icons'; +import { Icon } from '@/components/ui'; + +function MemberSelectHeader({ + selectedIds, + setSelectedIds, + markUnpaid, + selectedTargetIds, +}: { + selectedIds: number[]; + setSelectedIds: () => void; + refund: () => void; + markUnpaid: () => void; + selectedTargetIds: () => void; +}) { + return ( +
+
+ + {selectedIds.size}명 선택됨 +
+
+ + + +
+
+ ); +} + +export { MemberSelectHeader }; From 7074ff65fb4ff93c51985d99a2c8d77d12a41f4a Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Jul 2026 11:50:52 +0900 Subject: [PATCH 11/15] =?UTF-8?q?fix:=20=EB=82=A9=EB=B6=80=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=20=EC=84=A0=ED=83=9D=20=ED=97=A4=EB=8D=94=20=EB=B0=98?= =?UTF-8?q?=EC=9D=91=ED=98=95=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dues/DuesPaymentStatusPageContent.tsx | 50 +++--------- .../admin/dues/DuesPaymentSummaryCard.tsx | 2 +- .../admin/dues/MemberSelectHeader.tsx | 79 +++++++++++++++++++ .../admin/dues/MemberSeletHeader.tsx | 58 -------------- src/components/admin/dues/index.ts | 1 + 5 files changed, 92 insertions(+), 98 deletions(-) create mode 100644 src/components/admin/dues/MemberSelectHeader.tsx delete mode 100644 src/components/admin/dues/MemberSeletHeader.tsx diff --git a/src/components/admin/dues/DuesPaymentStatusPageContent.tsx b/src/components/admin/dues/DuesPaymentStatusPageContent.tsx index 77c88ec6..ac9ee4b4 100644 --- a/src/components/admin/dues/DuesPaymentStatusPageContent.tsx +++ b/src/components/admin/dues/DuesPaymentStatusPageContent.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { BackIcon, CopyIcon } from '@/assets/icons'; +import { CopyIcon } from '@/assets/icons'; import { Card, Icon } from '@/components/ui'; import { cn } from '@/lib/cn'; import { toastError, toastSuccess } from '@/stores/useToastStore'; @@ -21,6 +21,7 @@ import type { PaymentTarget } from '@/types/admin/dues'; import { DuesMemberPaymentTable, type DuesMember } from './DuesMemberPaymentTable'; import { DuesPaymentSummaryCard } from './DuesPaymentSummaryCard'; import { BackButton } from './BackButton'; +import { MemberSelectHeader } from './MemberSelectHeader'; // 납부 대상(PaymentTarget) → 테이블이 쓰는 DuesMember 형태로 변환 function toDuesMember(target: PaymentTarget): DuesMember { @@ -175,44 +176,15 @@ function DuesPaymentStatusPageContent() {
{/* Selection top bar — sticky top-0 z-10 -mt-15 로 Header 영역에 오버레이 */} {selectedIds.size > 0 && ( -
-
- - {selectedIds.size}명 선택됨 -
-
- - - -
-
+ markUnpaid({ targetIds: selectedTargetIds() })} + onRefund={() => refund({ targetIds: selectedTargetIds(), memo: '' })} + onMarkPaid={() => + markPaid({ targetIds: selectedTargetIds(), paidAt: nowLocalDateTime(), memo: '' }) + } + /> )}
diff --git a/src/components/admin/dues/DuesPaymentSummaryCard.tsx b/src/components/admin/dues/DuesPaymentSummaryCard.tsx index 67f0f45e..7366f50d 100644 --- a/src/components/admin/dues/DuesPaymentSummaryCard.tsx +++ b/src/components/admin/dues/DuesPaymentSummaryCard.tsx @@ -19,7 +19,7 @@ function DuesPaymentSummaryCard({ : 0; return ( - +

총 수납액

diff --git a/src/components/admin/dues/MemberSelectHeader.tsx b/src/components/admin/dues/MemberSelectHeader.tsx new file mode 100644 index 00000000..fc4af8d9 --- /dev/null +++ b/src/components/admin/dues/MemberSelectHeader.tsx @@ -0,0 +1,79 @@ +import { BackIcon } from '@/assets/icons'; +import { Icon } from '@/components/ui'; +import { cn } from '@/lib/cn'; + +interface MemberActionButtonProps extends React.ButtonHTMLAttributes { + ref?: React.Ref; +} + +function MemberActionButton({ className, ref, ...props }: MemberActionButtonProps) { + return ( + + + {selectedCount} + + 명 선택됨 + +
+
+ {actions.map((action) => ( + + {action.label} + + ))} +
+
+ ); +} + +export { MemberSelectHeader, MemberActionButton, type MemberSelectHeaderProps }; diff --git a/src/components/admin/dues/MemberSeletHeader.tsx b/src/components/admin/dues/MemberSeletHeader.tsx deleted file mode 100644 index 5044a60c..00000000 --- a/src/components/admin/dues/MemberSeletHeader.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { BackIcon } from '@/assets/icons'; -import { Icon } from '@/components/ui'; - -function MemberSelectHeader({ - selectedIds, - setSelectedIds, - markUnpaid, - selectedTargetIds, -}: { - selectedIds: number[]; - setSelectedIds: () => void; - refund: () => void; - markUnpaid: () => void; - selectedTargetIds: () => void; -}) { - return ( -
-
- - {selectedIds.size}명 선택됨 -
-
- - - -
-
- ); -} - -export { MemberSelectHeader }; diff --git a/src/components/admin/dues/index.ts b/src/components/admin/dues/index.ts index a1040878..faa1b9b6 100644 --- a/src/components/admin/dues/index.ts +++ b/src/components/admin/dues/index.ts @@ -34,3 +34,4 @@ export { DuesTutorialModal, type DuesTutorialModalProps } from './modal/DuesTuto export { BackButton } from './BackButton'; export { DuesSearchBar, type DuesSearchBarProps } from './DuesSearchBar'; +export { MemberSelectHeader } from './MemberSelectHeader'; From 23e6971ae9146b9615760469daeff0867bb7a559 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Jul 2026 12:01:47 +0900 Subject: [PATCH 12/15] =?UTF-8?q?fix:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=98=EC=9D=91=ED=98=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/dues/DuesGenerationFilter.tsx | 11 +++++++---- src/components/admin/dues/DuesTopBar.tsx | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/admin/dues/DuesGenerationFilter.tsx b/src/components/admin/dues/DuesGenerationFilter.tsx index 43ebf3cf..ff0b0fdf 100644 --- a/src/components/admin/dues/DuesGenerationFilter.tsx +++ b/src/components/admin/dues/DuesGenerationFilter.tsx @@ -44,7 +44,7 @@ function DuesGenerationFilter({ +
+ {isSelected && } +
+ +
@@ -230,16 +234,6 @@ function DuesMemberPaymentTable({ - {/* - - */} ); }) diff --git a/src/components/admin/dues/DuesPageContent.tsx b/src/components/admin/dues/DuesPageContent.tsx index e4be5d0d..c2f43577 100644 --- a/src/components/admin/dues/DuesPageContent.tsx +++ b/src/components/admin/dues/DuesPageContent.tsx @@ -11,6 +11,7 @@ import type { TransactionItem, } from '@/types/admin/dues'; import { useCardinalSelector } from '@/hooks'; +import { useDuesVisibilityToggle } from '@/hooks/admin'; import { isDuesNotRegisteredError, useDuesDashboardQuery } from '@/hooks/queries/admin'; import { useDuesSetupActions } from '@/stores/useDuesSetupStore'; import { DuesPageSkeleton } from './DuesPageSkeleton'; @@ -33,7 +34,6 @@ import { import { useCreateTransaction, useDeleteTransaction, - useUpdateMemberVisibility, useUpdateTransaction, } from '@/hooks/mutations/admin/useAdminDuesMutations'; import { toastError, toastSuccess } from '@/stores/useToastStore'; @@ -79,9 +79,6 @@ function detailToTransactionDetail(detail: TransactionItem): TransactionDetail { } function DuesPageContent() { - // TODO: 대시보드 응답에 부원 공개 여부(member-visibility) 필드 추가 요청함(백엔드 대기 중). - // 추가되면 useState(true) 기본값 대신 dashboard 값으로 초기화할 것. - const [isPublic, setIsPublic] = useState(true); const [activeMonth, setActiveMonth] = useState(''); const { cardinals, setSelectedCardinalId, activeCardinal } = useCardinalSelector({ autoSelectLatest: true, @@ -139,22 +136,11 @@ function DuesPageContent() { onError: () => toastError('거래내역 삭제에 실패했습니다.'), }); - const { mutate: updateMemberVisibility } = useUpdateMemberVisibility( + const { isPublic, handlePublicChange } = useDuesVisibilityToggle( clubId, dashboard?.accountId ?? null, - { - onError: () => toastError('공개 설정 변경에 실패했습니다.'), - }, ); - // 낙관적 업데이트: 스위치는 즉시 반영하고, 실패 시 이전 값으로 되돌린다. - const handlePublicChange = (value: boolean) => { - setIsPublic(value); - updateMemberVisibility(value, { - onError: () => setIsPublic(!value), - }); - }; - // 월별 잔액 추이 차트 데이터 (yearMonth → 'N월', endingBalance → 막대 높이) const monthlyData: MonthlyData[] = dashboard?.monthlyBalances.map((balance) => ({ @@ -216,9 +202,10 @@ function DuesPageContent() { setEditOpen(true); }; - // 대시보드 로딩 중(기수 선택 대기 포함)에는 스켈레톤을 노출한다. + // 기수가 선택된 상태에서 대시보드 로딩 중일 때만 스켈레톤을 노출한다. + // 기수가 하나도 없으면 activeCardinal이 계속 null(쿼리 skipToken)이라 스켈레톤이 무한 노출되므로 제외한다. // 등록 미완료(20112)는 에러 상태라 isPending=false이므로 아래 튜토리얼 모달 흐름으로 넘어간다. - if (isDashboardPending) { + if (activeCardinal && isDashboardPending) { return ; } diff --git a/src/components/admin/dues/DuesPaymentStatusPageContent.tsx b/src/components/admin/dues/DuesPaymentStatusPageContent.tsx index ac9ee4b4..219923a2 100644 --- a/src/components/admin/dues/DuesPaymentStatusPageContent.tsx +++ b/src/components/admin/dues/DuesPaymentStatusPageContent.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; -import { useParams, useRouter } from 'next/navigation'; +import { useParams } from 'next/navigation'; import { CopyIcon } from '@/assets/icons'; import { Card, Icon } from '@/components/ui'; @@ -112,7 +112,6 @@ function AccountCard({ } function DuesPaymentStatusPageContent() { - const router = useRouter(); const { clubId } = useParams<{ clubId: string }>(); const { activeCardinal } = useCardinalSelector({ autoSelectLatest: true }); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -219,9 +218,6 @@ function DuesPaymentStatusPageContent() { members={members} selectedIds={selectedIds} onSelectionChange={setSelectedIds} - onViewMember={(member) => { - router.push(`/${clubId}/admin/member/${member.id}`); - }} />
diff --git a/src/components/admin/dues/DuesSettingPageContent.tsx b/src/components/admin/dues/DuesSettingPageContent.tsx index a6116681..fedae239 100644 --- a/src/components/admin/dues/DuesSettingPageContent.tsx +++ b/src/components/admin/dues/DuesSettingPageContent.tsx @@ -8,13 +8,12 @@ import { useQuery } from '@tanstack/react-query'; import { Switch } from '@/components/ui'; import { useCardinalSelector } from '@/hooks'; +import { useDuesVisibilityToggle } from '@/hooks/admin'; import { duesRegistrationStatusQueryOptions, useDuesDashboardQuery, useDuesPaymentTargetsQuery, } from '@/hooks/queries/admin'; -import { useUpdateMemberVisibility } from '@/hooks/mutations/admin/useAdminDuesMutations'; -import { toastError } from '@/stores/useToastStore'; import { BackButton } from './BackButton'; import { PaymentTargetModal } from './modal/PaymentTargetModal'; @@ -50,22 +49,9 @@ function DuesSettingPageContent() { const { data: status } = useQuery(duesRegistrationStatusQueryOptions(clubId, accountId)); const { data: paymentTargets } = useDuesPaymentTargetsQuery(clubId, accountId); - // TODO: 대시보드 응답에 부원 공개 여부(member-visibility) 필드 추가 요청함(백엔드 대기 중). - // 추가되면 useState(true) 기본값 대신 서버 값으로 초기화할 것. - const [isPublic, setIsPublic] = useState(true); const [isPaymentTargetModalOpen, setIsPaymentTargetModalOpen] = useState(false); - const { mutate: updateMemberVisibility } = useUpdateMemberVisibility(clubId, accountId, { - onError: () => toastError('공개 설정 변경에 실패했습니다.'), - }); - - // 낙관적 업데이트: 스위치는 즉시 반영하고, 실패 시 이전 값으로 되돌린다. - const handlePublicChange = (value: boolean) => { - setIsPublic(value); - updateMemberVisibility(value, { - onError: () => setIsPublic(!value), - }); - }; + const { isPublic, handlePublicChange } = useDuesVisibilityToggle(clubId, accountId); const cardinalNumber = activeCardinal?.cardinalNumber ?? 0; @@ -78,9 +64,6 @@ function DuesSettingPageContent() { const hasPreviousBalance = status?.previousAccountBalance != null; - // TODO: 카드별 "수정" 동작 연결 예정. (온보딩 스텝 이동은 하지 않기로 함) - const handleEditStep = () => {}; - return (
{/* 헤더 */} @@ -110,7 +93,6 @@ function DuesSettingPageContent() { bankName={status?.bankAccount?.bankAccount?.bankName ?? undefined} accountHolder={status?.bankAccount?.bankAccount?.holder ?? undefined} accountGuide={status?.bankAccount?.bankAccount?.guide ?? undefined} - onEditStep={handleEditStep} /> diff --git a/src/components/admin/dues/setup/components/SettingResultCardGrid.tsx b/src/components/admin/dues/setup/components/SettingResultCardGrid.tsx index dad8425d..f49f8ddb 100644 --- a/src/components/admin/dues/setup/components/SettingResultCardGrid.tsx +++ b/src/components/admin/dues/setup/components/SettingResultCardGrid.tsx @@ -21,7 +21,7 @@ function InfoRow({ label, value, valueClassName }: InfoRowProps) { interface InfoCardProps { title: string; - onEdit: () => void; + onEdit?: () => void; children: ReactNode; } @@ -30,14 +30,16 @@ function InfoCard({ title, onEdit, children }: InfoCardProps) {
{title} - + {onEdit && ( + + )}
{children}
@@ -75,7 +77,8 @@ interface SettingResultCardGridProps { accountHolder?: string; accountGuide?: string; // 편집 버튼 → 해당 스텝으로 이동 (최종 확인으로 복귀하도록 returnStep을 설정한다) - onEditStep: (step: number) => void; + // 미전달 시 각 카드의 수정 버튼을 노출하지 않는다. + onEditStep?: (step: number) => void; } function SettingResultCardGrid({ @@ -102,14 +105,14 @@ function SettingResultCardGrid({ return (
{/* 기본 정보 */} - onEditStep(1)}> + onEditStep(1) : undefined}> {/* 이월 설정 */} - onEditStep(3)}> + onEditStep(3) : undefined}> {hasPreviousBalance ? ( <> @@ -135,7 +138,7 @@ function SettingResultCardGrid({ {/* 납부 대상 */} - onEditStep(2)}> + onEditStep(2) : undefined}>
@@ -156,7 +159,7 @@ function SettingResultCardGrid({ {/* 계좌 공개 */} - onEditStep(4)}> + onEditStep(4) : undefined}> {bankName && } {accountNumber && ( diff --git a/src/hooks/admin/index.ts b/src/hooks/admin/index.ts index c0f881ad..a203b6d1 100644 --- a/src/hooks/admin/index.ts +++ b/src/hooks/admin/index.ts @@ -2,3 +2,4 @@ export { useFlattenedSessions, type FlattenedSession } from './useFlattenedSessi export { useSessionMutations } from './useSessionMutations'; export { useBoardDragReorder } from './useBoardDragReorder'; export { usePaymentTargetFilter } from './usePaymentTargetFilter'; +export { useDuesVisibilityToggle } from './useDuesVisibilityToggle'; diff --git a/src/hooks/admin/useDuesVisibilityToggle.ts b/src/hooks/admin/useDuesVisibilityToggle.ts new file mode 100644 index 00000000..2451f778 --- /dev/null +++ b/src/hooks/admin/useDuesVisibilityToggle.ts @@ -0,0 +1,29 @@ +import { useState } from 'react'; + +import { useUpdateMemberVisibility } from '@/hooks/mutations/admin/useAdminDuesMutations'; +import { toastError } from '@/stores/useToastStore'; + +/** + * 회비 내역 공개 여부 토글 상태 + 낙관적 업데이트 훅. + * + * 스위치는 즉시 반영하고, 서버 요청이 실패하면 이전 값으로 되돌린다. + * + * TODO: 대시보드 응답에 부원 공개 여부(member-visibility) 필드가 추가되면 + * useState(true) 기본값 대신 서버 값으로 초기화할 것(백엔드 대기 중). + */ +export function useDuesVisibilityToggle(clubId: string, accountId: number | null) { + const [isPublic, setIsPublic] = useState(true); + + const { mutate: updateMemberVisibility } = useUpdateMemberVisibility(clubId, accountId, { + onError: () => toastError('공개 설정 변경에 실패했습니다.'), + }); + + const handlePublicChange = (value: boolean) => { + setIsPublic(value); + updateMemberVisibility(value, { + onError: () => setIsPublic(!value), + }); + }; + + return { isPublic, handlePublicChange }; +} From 707ac2cf52f66c684a1644a6033e4abfca50d6ee Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Jul 2026 14:48:07 +0900 Subject: [PATCH 14/15] =?UTF-8?q?style:=20prettier=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=ED=8C=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../admin/dues/DuesGenerationFilter.tsx | 2 +- .../admin/dues/DuesMemberPaymentTable.tsx | 98 +++++++++---------- .../admin/dues/DuesPageSkeleton.tsx | 5 +- .../admin/dues/DuesSettingPageContent.tsx | 4 +- src/hooks/queries/admin/adminQueryKeys.ts | 7 +- 5 files changed, 61 insertions(+), 55 deletions(-) diff --git a/src/components/admin/dues/DuesGenerationFilter.tsx b/src/components/admin/dues/DuesGenerationFilter.tsx index ff0b0fdf..1567e5ca 100644 --- a/src/components/admin/dues/DuesGenerationFilter.tsx +++ b/src/components/admin/dues/DuesGenerationFilter.tsx @@ -58,7 +58,7 @@ function DuesGenerationFilter({ ))} -
+
마지막 수정
diff --git a/src/components/admin/dues/DuesMemberPaymentTable.tsx b/src/components/admin/dues/DuesMemberPaymentTable.tsx index 205f94b5..f1aa4dd2 100644 --- a/src/components/admin/dues/DuesMemberPaymentTable.tsx +++ b/src/components/admin/dues/DuesMemberPaymentTable.tsx @@ -101,9 +101,7 @@ function DuesMemberPaymentTable({ // 현재 선택된 멤버들의 납부 상태(모두 동일하게 유지된다). 선택이 없으면 null. const selectedStatus: PaymentStatus | null = - selectedIds.size === 0 - ? null - : (members.find((m) => selectedIds.has(m.id))?.status ?? null); + selectedIds.size === 0 ? null : (members.find((m) => selectedIds.has(m.id))?.status ?? null); const toggleSelect = (id: number, status: PaymentStatus) => { const next = new Set(selectedIds); @@ -182,59 +180,59 @@ function DuesMemberPaymentTable({ const isDisabled = !isSelected && selectedStatus !== null && member.status !== selectedStatus; return ( - - - {/* disabled 버튼은 title 툴팁이 뜨지 않으므로 span으로 감싸 안내를 노출한다. */} - - + + + +
+
+ {member.avatarInitial ?? member.name.slice(0, 1)}
- - - - -
-
- {member.avatarInitial ?? member.name.slice(0, 1)} + + {member.name} +
- - {member.name} - -
-
- {member.major} - - {formatPhone(member.phone)} - - - - - + + {member.major} + + {formatPhone(member.phone)} + + + + + ); }) )} diff --git a/src/components/admin/dues/DuesPageSkeleton.tsx b/src/components/admin/dues/DuesPageSkeleton.tsx index 9d2f0d4c..707a32e7 100644 --- a/src/components/admin/dues/DuesPageSkeleton.tsx +++ b/src/components/admin/dues/DuesPageSkeleton.tsx @@ -87,7 +87,10 @@ function DuesTransactionTableSkeleton() {
{Array.from({ length: 5 }, (_, index) => ( -
+
diff --git a/src/components/admin/dues/DuesSettingPageContent.tsx b/src/components/admin/dues/DuesSettingPageContent.tsx index fedae239..d661f922 100644 --- a/src/components/admin/dues/DuesSettingPageContent.tsx +++ b/src/components/admin/dues/DuesSettingPageContent.tsx @@ -84,7 +84,9 @@ function DuesSettingPageContent() { remainingCount={remainingCount} onOpenPaymentTargetModal={() => setIsPaymentTargetModalOpen(true)} hasPreviousBalance={hasPreviousBalance} - previousGeneration={status?.previousAccountBalance?.cardinalNumber ?? cardinalNumber - 1} + previousGeneration={ + status?.previousAccountBalance?.cardinalNumber ?? cardinalNumber - 1 + } previousBalance={status?.previousAccountBalance?.balance ?? 0} carryOverOption={status?.carryOver?.enabled ? 'carry' : 'none'} carryOverDescription={status?.carryOver?.memo ?? undefined} diff --git a/src/hooks/queries/admin/adminQueryKeys.ts b/src/hooks/queries/admin/adminQueryKeys.ts index 60e4f7f3..eebffe8f 100644 --- a/src/hooks/queries/admin/adminQueryKeys.ts +++ b/src/hooks/queries/admin/adminQueryKeys.ts @@ -35,8 +35,11 @@ export const adminQueryKeys = { ) => ['admin', 'dues', 'transactions', clubId, accountId, params] as const, // 회비 거래내역 상세 — accountId + transactionId 단위로 스코프 - duesTransaction: (clubId: string | null, accountId: number | null, transactionId: number | null) => - ['admin', 'dues', 'transaction', clubId, accountId, transactionId] as const, + duesTransaction: ( + clubId: string | null, + accountId: number | null, + transactionId: number | null, + ) => ['admin', 'dues', 'transaction', clubId, accountId, transactionId] as const, // 회비 대시보드 — 기수 번호(cardinalNumber) 단위로 스코프 duesDashboard: (clubId: string, cardinalNumber: number | null) => From de83e0a58bf5357b2bb792136c4e1d52c74b1641 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Jul 2026 16:29:58 +0900 Subject: [PATCH 15/15] =?UTF-8?q?fix:=20=EB=B2=8C=ED=81=AC=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=B3=84=20=EB=B6=84=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8=EB=A7=81=20=EC=84=B8=EB=B6=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/dues/DuesPageContent.tsx | 2 ++ .../dues/DuesPaymentStatusPageContent.tsx | 7 ++++++- .../admin/dues/MemberSelectHeader.tsx | 19 ++++++++++++++----- src/types/admin/dues.d.ts | 3 +++ 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/components/admin/dues/DuesPageContent.tsx b/src/components/admin/dues/DuesPageContent.tsx index c2f43577..a5e73feb 100644 --- a/src/components/admin/dues/DuesPageContent.tsx +++ b/src/components/admin/dues/DuesPageContent.tsx @@ -73,6 +73,8 @@ function detailToTransactionDetail(detail: TransactionItem): TransactionDetail { vendor: detail.source, date: detail.transactedAt.slice(0, 10), memo: detail.memo || undefined, + category: detail.category || undefined, + registrant: detail.registeredByName || undefined, receiptUrl: detail.receipts[0]?.fileUrl, receipts: detail.receipts, }; diff --git a/src/components/admin/dues/DuesPaymentStatusPageContent.tsx b/src/components/admin/dues/DuesPaymentStatusPageContent.tsx index 219923a2..ba32d0a8 100644 --- a/src/components/admin/dues/DuesPaymentStatusPageContent.tsx +++ b/src/components/admin/dues/DuesPaymentStatusPageContent.tsx @@ -147,6 +147,10 @@ function DuesPaymentStatusPageContent() { const clearSelection = () => setSelectedIds(new Set()); const accountId = dashboard?.accountId ?? null; + // 선택은 동일 상태로만 이루어지므로, 첫 선택 멤버의 상태가 곧 선택 상태다. + const selectedStatus = + selectedIds.size === 0 ? null : (members.find((m) => selectedIds.has(m.id))?.status ?? null); + const { mutate: markUnpaid } = useMarkPaymentTargetsUnpaid(clubId, accountId, { onSuccess: () => { toastSuccess('납부가 정정되었습니다.'); @@ -174,9 +178,10 @@ function DuesPaymentStatusPageContent() { return (
{/* Selection top bar — sticky top-0 z-10 -mt-15 로 Header 영역에 오버레이 */} - {selectedIds.size > 0 && ( + {selectedStatus !== null && ( markUnpaid({ targetIds: selectedTargetIds() })} onRefund={() => refund({ targetIds: selectedTargetIds(), memo: '' })} diff --git a/src/components/admin/dues/MemberSelectHeader.tsx b/src/components/admin/dues/MemberSelectHeader.tsx index fc4af8d9..1df00d50 100644 --- a/src/components/admin/dues/MemberSelectHeader.tsx +++ b/src/components/admin/dues/MemberSelectHeader.tsx @@ -1,6 +1,7 @@ import { BackIcon } from '@/assets/icons'; import { Icon } from '@/components/ui'; import { cn } from '@/lib/cn'; +import type { PaymentStatus } from '@/types/admin/dues'; interface MemberActionButtonProps extends React.ButtonHTMLAttributes { ref?: React.Ref; @@ -25,6 +26,8 @@ function MemberActionButton({ className, ref, ...props }: MemberActionButtonProp interface MemberSelectHeaderProps { /** 현재 선택된 부원 수 */ selectedCount: number; + /** 현재 선택된 부원들의 납부 상태(선택은 동일 상태로만 이루어진다) */ + selectedStatus: PaymentStatus; /** 선택 해제 */ onClear: () => void; /** 납부 정정 */ @@ -37,16 +40,22 @@ interface MemberSelectHeaderProps { function MemberSelectHeader({ selectedCount, + selectedStatus, onClear, onMarkUnpaid, onRefund, onMarkPaid, }: MemberSelectHeaderProps) { - const actions = [ - { label: '납부 정정', onClick: onMarkUnpaid }, - { label: '환불 처리', onClick: onRefund }, - { label: '납부 완료', onClick: onMarkPaid }, - ]; + // 벌크 액션은 선택된 납부 상태에 따라 유효한 것만 노출한다. + // - 납부 정정·환불 처리: 이미 납부된(paid) 대상에만 의미가 있다. + // - 납부 완료: 아직 미납(unpaid) 대상에만 의미가 있다. + const actions = + selectedStatus === 'paid' + ? [ + { label: '납부 정정', onClick: onMarkUnpaid }, + { label: '환불 처리', onClick: onRefund }, + ] + : [{ label: '납부 완료', onClick: onMarkPaid }]; return (
diff --git a/src/types/admin/dues.d.ts b/src/types/admin/dues.d.ts index 2a254de5..470205e8 100644 --- a/src/types/admin/dues.d.ts +++ b/src/types/admin/dues.d.ts @@ -252,6 +252,9 @@ export interface TransactionItem { memo: string; hasReceipt: boolean; receipts: TransactionReceipt[]; + // 상세 응답에만 포함되는 필드 (목록 응답에는 없음) + category?: string; + registeredByName?: string; } export interface TransactionsInfo {