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/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/DuesGenerationFilter.tsx b/src/components/admin/dues/DuesGenerationFilter.tsx index 43ebf3cf..1567e5ca 100644 --- a/src/components/admin/dues/DuesGenerationFilter.tsx +++ b/src/components/admin/dues/DuesGenerationFilter.tsx @@ -44,7 +44,7 @@ function DuesGenerationFilter({ - - -
-
- {member.avatarInitial ?? member.name.slice(0, 1)} -
- - {member.name} + -
-
- {member.major} - {member.phone} - - - - {/* - - */} - - )) + + +
+
+ {member.avatarInitial ?? member.name.slice(0, 1)} +
+ + {member.name} + +
+
+ {member.major} + + {formatPhone(member.phone)} + + + + + + ); + }) )} diff --git a/src/components/admin/dues/DuesPageContent.tsx b/src/components/admin/dues/DuesPageContent.tsx index 86d648af..a5e73feb 100644 --- a/src/components/admin/dues/DuesPageContent.tsx +++ b/src/components/admin/dues/DuesPageContent.tsx @@ -4,10 +4,17 @@ 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 { useDuesVisibilityToggle } from '@/hooks/admin'; 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'; @@ -20,11 +27,13 @@ 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, - useUpdateMemberVisibility, useUpdateTransaction, } from '@/hooks/mutations/admin/useAdminDuesMutations'; import { toastError, toastSuccess } from '@/stores/useToastStore'; @@ -41,6 +50,7 @@ function toPeriodLabel(yearMonth: string | undefined): string { return `${year}.${month}.`; } +// 목록 데이터(DuesTransaction) → 상세 모달용. 상세 응답 도착 전 폴백으로 사용한다. function toTransactionDetail(tx: DuesTransaction): TransactionDetail { return { type: tx.type, @@ -53,10 +63,24 @@ 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, + category: detail.category || undefined, + registrant: detail.registeredByName || undefined, + receiptUrl: detail.receipts[0]?.fileUrl, + receipts: detail.receipts, + }; +} + function DuesPageContent() { - // TODO: 대시보드 응답에 부원 공개 여부(member-visibility) 필드 추가 요청함(백엔드 대기 중). - // 추가되면 useState(true) 기본값 대신 dashboard 값으로 초기화할 것. - const [isPublic, setIsPublic] = useState(true); const [activeMonth, setActiveMonth] = useState(''); const { cardinals, setSelectedCardinalId, activeCardinal } = useCardinalSelector({ autoSelectLatest: true, @@ -66,10 +90,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 변환) @@ -113,22 +138,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) => ({ @@ -156,6 +170,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); @@ -165,6 +187,10 @@ function DuesPageContent() { setAddOpen(true); }; + const handleSetting = () => { + router.push(`/${clubId}/admin/dues/setting`); + }; + const handleEditOpen = () => { if (!selectedTransaction) return; setDetailOpen(false); @@ -178,12 +204,20 @@ function DuesPageContent() { setEditOpen(true); }; + // 기수가 선택된 상태에서 대시보드 로딩 중일 때만 스켈레톤을 노출한다. + // 기수가 하나도 없으면 activeCardinal이 계속 null(쿼리 skipToken)이라 스켈레톤이 무한 노출되므로 제외한다. + // 등록 미완료(20112)는 에러 상태라 isPending=false이므로 아래 튜토리얼 모달 흐름으로 넘어간다. + if (activeCardinal && isDashboardPending) { + return ; + } + return (
deleteTransaction(selectedTransaction.id)} /> diff --git a/src/components/admin/dues/DuesPageSkeleton.tsx b/src/components/admin/dues/DuesPageSkeleton.tsx new file mode 100644 index 00000000..707a32e7 --- /dev/null +++ b/src/components/admin/dues/DuesPageSkeleton.tsx @@ -0,0 +1,106 @@ +import { Skeleton } from '@/components/ui'; + +function DuesPageSkeleton() { + 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/DuesPaymentStatusPageContent.tsx b/src/components/admin/dues/DuesPaymentStatusPageContent.tsx index d009c746..ba32d0a8 100644 --- a/src/components/admin/dues/DuesPaymentStatusPageContent.tsx +++ b/src/components/admin/dues/DuesPaymentStatusPageContent.tsx @@ -2,9 +2,9 @@ import { useState } from 'react'; -import { useParams, useRouter } from 'next/navigation'; +import { useParams } 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 { @@ -45,12 +46,10 @@ function nowLocalDateTime(): string { interface StatCardProps { label: string; value: string; - action: string; - onAction?: () => void; className?: string; } -function StatCard({ label, value, action, onAction, className }: StatCardProps) { +function StatCard({ label, value, className }: StatCardProps) { return ( {value} {label}
- ); } @@ -120,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()); @@ -156,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('납부가 정정되었습니다.'); @@ -183,45 +178,17 @@ function DuesPaymentStatusPageContent() { return (
{/* Selection top bar — sticky top-0 z-10 -mt-15 로 Header 영역에 오버레이 */} - {selectedIds.size > 0 && ( -
-
- - {selectedIds.size}명 선택됨 -
-
- - - -
-
+ {selectedStatus !== null && ( + markUnpaid({ targetIds: selectedTargetIds() })} + onRefund={() => refund({ targetIds: selectedTargetIds(), memo: '' })} + onMarkPaid={() => + markPaid({ targetIds: selectedTargetIds(), paidAt: nowLocalDateTime(), memo: '' }) + } + /> )}
@@ -237,10 +204,9 @@ function DuesPaymentStatusPageContent() {
- {/* TODO: onAction 핸들러 넘기기 (미납 현황 업데이트) */} - - {/* TODO: onAction 핸들러 넘기기 (납부 대상 수정 - savePaymentTargets 재사용) */} - + + + {account && ( { - router.push(`/${clubId}/admin/member/${member.id}`); - }} />
diff --git a/src/components/admin/dues/DuesPaymentSummaryCard.tsx b/src/components/admin/dues/DuesPaymentSummaryCard.tsx index 44be7988..7366f50d 100644 --- a/src/components/admin/dues/DuesPaymentSummaryCard.tsx +++ b/src/components/admin/dues/DuesPaymentSummaryCard.tsx @@ -6,14 +6,12 @@ interface DuesPaymentSummaryCardProps { className?: string; totalCollected: number; totalTarget: number; - onUpdatePayment?: () => void; } function DuesPaymentSummaryCard({ className, totalCollected, totalTarget, - onUpdatePayment, }: DuesPaymentSummaryCardProps) { const percentage = totalTarget > 0 @@ -21,7 +19,7 @@ function DuesPaymentSummaryCard({ : 0; return ( - +

총 수납액

@@ -33,16 +31,6 @@ function DuesPaymentSummaryCard({
-
- -
-

목표 금액의 {percentage}%가 납부되었어요. diff --git a/src/components/admin/dues/DuesSettingPageContent.tsx b/src/components/admin/dues/DuesSettingPageContent.tsx new file mode 100644 index 00000000..d661f922 --- /dev/null +++ b/src/components/admin/dues/DuesSettingPageContent.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useState, type ReactNode } from 'react'; + +import { useParams } from 'next/navigation'; + +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 { 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 { clubId } = useParams<{ clubId: string }>(); + const { activeCardinal } = useCardinalSelector({ autoSelectLatest: true }); + + // 대시보드로 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); + + const [isPaymentTargetModalOpen, setIsPaymentTargetModalOpen] = useState(false); + + const { isPublic, handlePublicChange } = useDuesVisibilityToggle(clubId, accountId); + + 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; + + 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} + /> + + + +
+
+ 전체 회비 내역 공개 + + 부원이 회비 사용 내역을 볼 수 있어요 + +
+ +
+
+
+ + +
+ ); +} + +export { DuesSettingPageContent }; diff --git a/src/components/admin/dues/DuesTopBar.tsx b/src/components/admin/dues/DuesTopBar.tsx index 04bb3eca..b2ee6feb 100644 --- a/src/components/admin/dues/DuesTopBar.tsx +++ b/src/components/admin/dues/DuesTopBar.tsx @@ -55,7 +55,7 @@ function DuesTopBar({ + + {selectedCount} + + 명 선택됨 + +
+
+ {actions.map((action) => ( + + {action.label} + + ))} +
+
+ ); +} + +export { MemberSelectHeader, MemberActionButton, type MemberSelectHeaderProps }; diff --git a/src/components/admin/dues/index.ts b/src/components/admin/dues/index.ts index 2528e5f8..faa1b9b6 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'; @@ -10,6 +11,7 @@ export { type TransactionType, } from './DuesTransactionTable'; export { DuesPaymentStatusPageContent } from './DuesPaymentStatusPageContent'; +export { DuesSettingPageContent } from './DuesSettingPageContent'; export { DuesPaymentSummaryCard, type DuesPaymentSummaryCardProps } from './DuesPaymentSummaryCard'; export { DuesMemberPaymentTable, @@ -32,3 +34,4 @@ export { DuesTutorialModal, type DuesTutorialModalProps } from './modal/DuesTuto export { BackButton } from './BackButton'; export { DuesSearchBar, type DuesSearchBarProps } from './DuesSearchBar'; +export { MemberSelectHeader } from './MemberSelectHeader'; diff --git a/src/components/admin/dues/modal/TransactionDetailModal.tsx b/src/components/admin/dues/modal/TransactionDetailModal.tsx index e4b25ae8..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; @@ -29,9 +32,11 @@ interface TransactionDetail { description: string; vendor: string; date: string; + memo?: string; category?: string; registrant?: string; receiptUrl?: string; + receipts?: DuesReceiptFile[]; } interface TransactionDetailModalProps { @@ -50,8 +55,20 @@ function TransactionDetailModal({ onDelete, }: TransactionDetailModalProps) { const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const { type, direction, amount, description, vendor, date, category, registrant, receiptUrl } = - transaction; + const [receiptViewerOpen, setReceiptViewerOpen] = useState(false); + const { + type, + direction, + amount, + description, + vendor, + date, + memo, + category, + registrant, + receiptUrl, + receipts, + } = transaction; const typeConfig = TRANSACTION_TYPE_TAG[type]; const sign = direction === 'INCOME' ? '+' : '-'; @@ -59,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 ( {registrant}
)} + {memo && ( +
+ 메모 + + {memo} + +
+ )}
- {/* 영수증 카드 */} -
- {receiptUrl ? ( - <> - {/* eslint-disable-next-line @next/next/no-img-element */} - -
-

영수증

-

원본 보기

-
- - - - ) : ( -
-

영수증

-

첨부된 영수증이 없습니다

-
- )} -
+ setReceiptViewerOpen(true)} + /> + + {/* Footer */} 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/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}기 총 회비 설정

-
+
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/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'; 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/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 }; +} diff --git a/src/hooks/queries/admin/adminQueryKeys.ts b/src/hooks/queries/admin/adminQueryKeys.ts index 16565910..eebffe8f 100644 --- a/src/hooks/queries/admin/adminQueryKeys.ts +++ b/src/hooks/queries/admin/adminQueryKeys.ts @@ -34,6 +34,13 @@ 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/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 + ); } 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), 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 {