Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/app/(private)/[clubId]/admin/dues/setting/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { DuesSettingPageContent } from '@/components/admin/dues';

export default function DuesSettingPage() {
return <DuesSettingPageContent />;
}
2 changes: 1 addition & 1 deletion src/app/(private)/[clubId]/admin/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export default async function AdminLayout({
<div data-admin className="fixed inset-0 flex flex-col">
<div className="flex flex-1 overflow-hidden">
<LNB />
<main className="bg-background min-w-0 flex-1 overflow-x-auto overflow-y-auto [scrollbar-gutter:stable]">
<main className="bg-background min-w-0 flex-1 overflow-x-auto overflow-y-auto">
<Header />
{children}
</main>
Expand Down
11 changes: 7 additions & 4 deletions src/components/admin/dues/DuesGenerationFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function DuesGenerationFilter({
<DropdownMenuTrigger asChild>
<button
type="button"
className="bg-button-neutral typo-button2 text-text-strong hover:bg-button-neutral-interaction flex cursor-pointer items-center gap-100 rounded-sm px-300 py-200"
className="bg-button-neutral typo-button2 text-text-strong hover:bg-button-neutral-interaction flex shrink-0 cursor-pointer items-center gap-100 rounded-sm px-300 py-200 whitespace-nowrap"
>
{displayLabel}
<Image src={ArrowDownIcon} alt="아래 방향 화살표" width={16} height={16} />
Expand All @@ -58,9 +58,12 @@ function DuesGenerationFilter({
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="bg-container-neutral flex items-center gap-200 rounded-md px-300 py-200">
<span className="typo-body1` text-text-alternative">
마지막 수정&nbsp;&nbsp;{lastUpdated ? formatLastUpdated(lastUpdated) : '-'}
<div className="bg-container-neutral tablet:flex-none tablet:justify-start flex flex-1 items-center justify-between gap-200 rounded-md px-300 py-200">
<span className="typo-body1 text-text-alternative">
마지막 수정
<br className="tablet:hidden" />
<span className="tablet:inline hidden">&nbsp;&nbsp;</span>
{lastUpdated ? formatLastUpdated(lastUpdated) : '-'}
</span>
<Avatar size={24}>
<AvatarImage src={updaterProfileImage} alt="마지막 수정자 프로필" />
Expand Down
131 changes: 74 additions & 57 deletions src/components/admin/dues/DuesMemberPaymentTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useState } from 'react';

import { ArrowRightIcon, CheckIcon } from '@/assets/icons';
import { CheckIcon } from '@/assets/icons';
import {
Icon,
Table,
Expand All @@ -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';
Expand All @@ -35,20 +36,17 @@ const COLUMNS = [
{ key: 'major', label: '학과', className: 'min-w-32' },
{ key: 'phone', label: '연락처', className: 'w-[148px]' },
{ key: 'status', label: '납부 현황', className: 'w-32' },
{ key: 'action', label: '', className: 'w-[109px]' },
] as const;

interface DuesMemberPaymentTableProps extends React.HTMLAttributes<HTMLDivElement> {
members: DuesMember[];
onViewMember?: (member: DuesMember) => void;
selectedIds: Set<number>;
onSelectionChange: (ids: Set<number>) => void;
}

function DuesMemberPaymentTable({
className,
members,
onViewMember,
selectedIds,
onSelectionChange,
...props
Expand Down Expand Up @@ -101,10 +99,19 @@ 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);
};

Expand Down Expand Up @@ -160,64 +167,74 @@ function DuesMemberPaymentTable({
<TableBody>
{paged.length === 0 ? (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={6} className="py-700 text-center">
<TableCell colSpan={COLUMNS.length} className="py-700 text-center">
<span className="typo-body2 text-text-alternative">
해당하는 부원이 없습니다.
</span>
</TableCell>
</TableRow>
) : (
paged.map((member) => (
<TableRow
key={member.id}
className="border-line hover:bg-container-neutral-interaction border-t"
>
<TableCell>
<button
type="button"
onClick={() => toggleSelect(member.id)}
aria-label={selectedIds.has(member.id) ? '선택 해제' : '선택'}
className="flex cursor-pointer items-center justify-center"
>
<div
className={cn(
'flex h-4 w-4 items-center justify-center rounded-[3px] border transition-colors',
selectedIds.has(member.id)
? 'border-brand-primary bg-brand-primary'
: 'border-button-neutral',
)}
paged.map((member) => {
const isSelected = selectedIds.has(member.id);
// 선택 진행 중이고, 현재 선택 상태와 다른 상태의 멤버는 함께 선택할 수 없다.
const isDisabled =
!isSelected && selectedStatus !== null && member.status !== selectedStatus;
return (
<TableRow
key={member.id}
className="border-line hover:bg-container-neutral-interaction border-t"
>
<TableCell>
{/* disabled 버튼은 title 툴팁이 뜨지 않으므로 span으로 감싸 안내를 노출한다. */}
<span
title={
isDisabled ? '납부 상태가 같은 부원끼리만 선택할 수 있어요.' : undefined
}
className="inline-flex"
>
{selectedIds.has(member.id) && <Icon src={CheckIcon} alt="" size={10} />}
</div>
</button>
</TableCell>
<TableCell>
<div className="flex items-center gap-400">
<div className="bg-container-neutral-interaction text-text-alternative typo-caption1 flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-md">
{member.avatarInitial ?? member.name.slice(0, 1)}
</div>
<span className="typo-body2 text-text-strong min-w-0 truncate">
{member.name}
<button
type="button"
onClick={() => toggleSelect(member.id, member.status)}
disabled={isDisabled}
aria-label={isSelected ? '선택 해제' : '선택'}
className={cn(
'flex items-center justify-center',
isDisabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer',
)}
>
<div
className={cn(
'flex h-4 w-4 items-center justify-center rounded-[3px] border transition-colors',
isSelected
? 'border-brand-primary bg-brand-primary'
: 'border-button-neutral',
)}
>
{isSelected && <Icon src={CheckIcon} alt="" size={10} />}
</div>
</button>
</span>
</div>
</TableCell>
<TableCell className="typo-body2 text-text-strong">{member.major}</TableCell>
<TableCell className="typo-body2 text-text-strong">{member.phone}</TableCell>
<TableCell>
<PaymentStatusBadge status={member.status} />
</TableCell>
{/* <TableCell>
<button
type="button"
onClick={() => onViewMember?.(member)}
className="text-text-alternative hover:text-text-normal typo-button2 flex cursor-pointer items-center gap-100 transition-colors"
>
<span>멤버정보</span>
<Icon src={ArrowRightIcon} alt="" size={12} />
</button>
</TableCell> */}
</TableRow>
))
</TableCell>
<TableCell>
<div className="flex items-center gap-400">
<div className="bg-container-neutral-interaction text-text-alternative typo-caption1 flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-md">
{member.avatarInitial ?? member.name.slice(0, 1)}
</div>
<span className="typo-body2 text-text-strong min-w-0 truncate">
{member.name}
</span>
</div>
</TableCell>
<TableCell className="typo-body2 text-text-strong">{member.major}</TableCell>
<TableCell className="typo-body2 text-text-strong">
{formatPhone(member.phone)}
</TableCell>
<TableCell>
<PaymentStatusBadge status={member.status} />
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
Expand Down
84 changes: 61 additions & 23 deletions src/components/admin/dues/DuesPageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -41,6 +50,7 @@ function toPeriodLabel(yearMonth: string | undefined): string {
return `${year}.${month}.`;
}

// 목록 데이터(DuesTransaction) → 상세 모달용. 상세 응답 도착 전 폴백으로 사용한다.
function toTransactionDetail(tx: DuesTransaction): TransactionDetail {
return {
type: tx.type,
Expand All @@ -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,
Expand All @@ -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 변환)
Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -156,6 +170,14 @@ function DuesPageContent() {
const [selectedTransaction, setSelectedTransaction] = useState<DuesTransaction | null>(null);
const [editingValues, setEditingValues] = useState<Partial<TransactionFormData>>();

// 상세 모달이 열려 있을 때만 선택된 거래의 단건 상세(메모·영수증 포함)를 조회한다.
const { data: transactionDetail } = useAdminDuesTransactionQuery(
clubId,
dashboard?.accountId ?? 0,
selectedTransaction?.id ?? null,
detailOpen,
);

const handleMoreClick = (tx: DuesTransaction) => {
setSelectedTransaction(tx);
setDetailOpen(true);
Expand All @@ -165,6 +187,10 @@ function DuesPageContent() {
setAddOpen(true);
};

const handleSetting = () => {
router.push(`/${clubId}/admin/dues/setting`);
};

const handleEditOpen = () => {
if (!selectedTransaction) return;
setDetailOpen(false);
Expand All @@ -178,12 +204,20 @@ function DuesPageContent() {
setEditOpen(true);
};

// 기수가 선택된 상태에서 대시보드 로딩 중일 때만 스켈레톤을 노출한다.
// 기수가 하나도 없으면 activeCardinal이 계속 null(쿼리 skipToken)이라 스켈레톤이 무한 노출되므로 제외한다.
// 등록 미완료(20112)는 에러 상태라 isPending=false이므로 아래 튜토리얼 모달 흐름으로 넘어간다.
if (activeCardinal && isDashboardPending) {
return <DuesPageSkeleton />;
}

return (
<div className="tablet:p-700 flex min-w-85 flex-col gap-400 p-400">
<DuesTopBar
isPublic={isPublic}
onPublicChange={handlePublicChange}
onAddClick={handleAddTransaction}
onSettingsClick={handleSetting}
/>
<DuesGenerationFilter
cardinals={cardinals}
Expand Down Expand Up @@ -247,7 +281,11 @@ function DuesPageContent() {
<TransactionDetailModal
open={detailOpen}
onOpenChange={setDetailOpen}
transaction={toTransactionDetail(selectedTransaction)}
transaction={
transactionDetail && transactionDetail.transactionId === selectedTransaction.id
? detailToTransactionDetail(transactionDetail)
: toTransactionDetail(selectedTransaction)
}
onEdit={handleEditOpen}
onDelete={() => deleteTransaction(selectedTransaction.id)}
/>
Expand Down
Loading
Loading