diff --git a/package-lock.json b/package-lock.json index 8a615e2..f6030af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.575.0", @@ -2630,6 +2631,39 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/package.json b/package.json index a3da0b6..d2851a4 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.575.0", diff --git a/src/api/activity/get-activities.ts b/src/api/activity/get-activities.ts index 6987c42..226c9e2 100644 --- a/src/api/activity/get-activities.ts +++ b/src/api/activity/get-activities.ts @@ -1,8 +1,30 @@ -import { requestApi } from './request'; -import type { Activity, ApiResult } from './types'; +import { requestApi } from "./request" +import type { AdminActivityPageResponse, ApiResult } from "./types" -export async function GetActivities(): Promise> { - return requestApi('/admin/activities', { - method: 'GET', - }); +export interface ActivityPageQuery { + page?: number + size?: number + sort?: string + keyword?: string +} + +export async function GetActivities( + query: ActivityPageQuery, +): Promise> { + const params = new URLSearchParams() + + params.set("page", String(query.page ?? 0)) + params.set("size", String(query.size ?? 50)) + + if (query.sort) { + params.set("sort", query.sort) + } + + if (query.keyword && query.keyword.trim().length > 0) { + params.set("keyword", query.keyword.trim()) + } + + return requestApi(`/admin/activities?${params.toString()}`, { + method: "GET", + }) } diff --git a/src/api/activity/types.ts b/src/api/activity/types.ts index cc19c64..85c8788 100644 --- a/src/api/activity/types.ts +++ b/src/api/activity/types.ts @@ -1,13 +1,22 @@ export interface Activity { - activityId: number; - name: string; - pointAmount: number; + activityId: number + name: string + pointAmount: number } export interface MemberInfo { - memberId: number; - name: string; - studentId: string; + memberId: number + name: string + studentId: string } -export type { ApiResult } from '@/lib/http/types'; +export interface AdminActivityPageResponse { + content: Activity[] + page: number + size: number + totalElements: number + totalPages: number + hasNext: boolean +} + +export type { ApiResult } from "@/lib/http/types" diff --git a/src/api/coupon/get-coupon-codes.ts b/src/api/coupon/get-coupon-codes.ts index 3f25431..d3775ef 100644 --- a/src/api/coupon/get-coupon-codes.ts +++ b/src/api/coupon/get-coupon-codes.ts @@ -1,6 +1,28 @@ -import { requestApi } from './request'; -import type { AdminCouponCode, ApiResult } from './types'; +import { requestApi } from "./request" +import type { AdminCouponCodePageResponse, ApiResult } from "./types" -export async function getCouponCodes(): Promise> { - return requestApi('/admin/coupons/code', { method: 'GET' }); +export interface CouponCodePageQuery { + page?: number + size?: number + sort?: string + keyword?: string +} + +export async function getCouponCodes( + query: CouponCodePageQuery, +): Promise> { + const params = new URLSearchParams() + + params.set("page", String(query.page ?? 0)) + params.set("size", String(query.size ?? 50)) + + if (query.sort) { + params.set("sort", query.sort) + } + + if (query.keyword && query.keyword.trim().length > 0) { + params.set("keyword", query.keyword.trim()) + } + + return requestApi(`/admin/coupons/code?${params.toString()}`, { method: "GET" }) } diff --git a/src/api/coupon/get-coupons.ts b/src/api/coupon/get-coupons.ts index 0257a3e..b74159a 100644 --- a/src/api/coupon/get-coupons.ts +++ b/src/api/coupon/get-coupons.ts @@ -1,6 +1,26 @@ -import { requestApi } from './request'; -import type { AdminCoupon, ApiResult } from './types'; +import { requestApi } from "./request" +import type { AdminCouponPageResponse, ApiResult } from "./types" -export async function getCoupons(): Promise> { - return requestApi('/admin/coupons', { method: 'GET' }); +export interface CouponPageQuery { + page?: number + size?: number + sort?: string + keyword?: string +} + +export async function getCoupons(query: CouponPageQuery): Promise> { + const params = new URLSearchParams() + + params.set("page", String(query.page ?? 0)) + params.set("size", String(query.size ?? 50)) + + if (query.sort) { + params.set("sort", query.sort) + } + + if (query.keyword && query.keyword.trim().length > 0) { + params.set("keyword", query.keyword.trim()) + } + + return requestApi(`/admin/coupons?${params.toString()}`, { method: "GET" }) } diff --git a/src/api/coupon/get-issued-coupons.ts b/src/api/coupon/get-issued-coupons.ts index f5782ef..107cc41 100644 --- a/src/api/coupon/get-issued-coupons.ts +++ b/src/api/coupon/get-issued-coupons.ts @@ -1,6 +1,45 @@ -import { requestApi } from './request'; -import type { AdminIssuedCoupon, ApiResult } from './types'; +import { requestApi } from "./request" +import type { AdminIssuedCouponPageResponse, ApiResult } from "./types" -export async function getIssuedCoupons(): Promise> { - return requestApi('/admin/coupons/issued', { method: 'GET' }); +export interface IssuedCouponPageQuery { + page?: number + size?: number + sort?: string + keyword?: string + couponId?: number + memberId?: number + isValid?: boolean +} + +export async function getIssuedCoupons( + query: IssuedCouponPageQuery, +): Promise> { + const params = new URLSearchParams() + + params.set("page", String(query.page ?? 0)) + params.set("size", String(query.size ?? 50)) + + if (query.sort) { + params.set("sort", query.sort) + } + + if (query.keyword && query.keyword.trim().length > 0) { + params.set("keyword", query.keyword.trim()) + } + + if (query.couponId !== undefined) { + params.set("couponId", String(query.couponId)) + } + + if (query.memberId !== undefined) { + params.set("memberId", String(query.memberId)) + } + + if (query.isValid !== undefined) { + params.set("isValid", String(query.isValid)) + } + + return requestApi(`/admin/coupons/issued?${params.toString()}`, { + method: "GET", + }) } diff --git a/src/api/coupon/types.ts b/src/api/coupon/types.ts index 8f5ef60..c7ab2ba 100644 --- a/src/api/coupon/types.ts +++ b/src/api/coupon/types.ts @@ -1,43 +1,70 @@ export interface AdminCoupon { - couponId: number; - couponName: string; - discountAmount: number; - createdAt: string; - updatedAt: string; + couponId: number + couponName: string + discountAmount: number + createdAt: string + updatedAt: string } export interface AdminCouponCode { - codeCouponId: number; - couponId: number; - couponName: string; - code: string; - description: string | null; - isValid: boolean; - issuedCouponId: number | null; - usedAt: string | null; - createdAt: string; + codeCouponId: number + couponId: number + couponName: string + code: string + description: string | null + isValid: boolean + issuedCouponId: number | null + usedAt: string | null + createdAt: string } export interface AdminIssuedCoupon { - issuedCouponId: number; - couponId: number; - couponName: string; - discountAmount: number; - memberId: number; - memberName: string; - memberEmail: string; - isValid: boolean; - paymentId: number | null; - usedAt: string | null; - createdAt: string; + issuedCouponId: number + couponId: number + couponName: string + discountAmount: number + memberId: number + memberName: string + memberEmail: string + isValid: boolean + paymentId: number | null + usedAt: string | null + createdAt: string } export interface AdminMemberSummary { - memberId: number; - studentId: string | null; - name: string; - email: string; - role: string; + memberId: number + studentId: string | null + name: string + email: string + role: string } -export type { ApiResult } from "@/lib/http/types"; +export interface AdminCouponPageResponse { + content: AdminCoupon[] + page: number + size: number + totalElements: number + totalPages: number + hasNext: boolean +} + +export interface AdminCouponCodePageResponse { + content: AdminCouponCode[] + page: number + size: number + totalElements: number + totalPages: number + hasNext: boolean +} + +export interface AdminIssuedCouponPageResponse { + content: AdminIssuedCoupon[] + page: number + size: number + totalElements: number + totalPages: number + hasNext: boolean +} + +export type { ApiResult } from "@/lib/http/types" diff --git a/src/components/admin/index.ts b/src/components/admin/index.ts index 56a9845..a91316f 100644 --- a/src/components/admin/index.ts +++ b/src/components/admin/index.ts @@ -3,3 +3,6 @@ export { AdminPageHeader } from "@/components/admin/AdminPageHeader" export { AdminSectionCard } from "@/components/admin/AdminSectionCard" export { AdminTableEmptyRow } from "@/components/admin/AdminTableEmptyRow" export { AdminSortableTableHead } from "@/components/admin/AdminSortableTableHead" +export { AdminDataTable } from "@/components/admin/table/AdminDataTable" +export { createSortingState, normalizeSingleSorting, serializeSortingState } from "@/components/admin/table/sorting" +export type { AdminColumnMeta, AdminDataTableProps, ColumnSortMap } from "@/components/admin/table/types" diff --git a/src/components/admin/table/AdminDataTable.tsx b/src/components/admin/table/AdminDataTable.tsx new file mode 100644 index 0000000..5c1df35 --- /dev/null +++ b/src/components/admin/table/AdminDataTable.tsx @@ -0,0 +1,150 @@ +import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react" +import { flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table" + +import { Button } from "@/components/ui/button" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { cn } from "@/lib/utils" + +import type { AdminColumnMeta, AdminDataTableProps } from "./types" + +export function AdminDataTable({ + columns, + data, + sorting, + onSortingChange, + pagination, + onPaginationChange, + pageCount, + totalElements, + isLoading = false, + loadingMessage = "데이터를 불러오는 중입니다.", + emptyMessage = "조회 결과가 없습니다.", + getRowId, + onRowClick, + rowClassName, + meta, +}: AdminDataTableProps) { + const safePageCount = Math.max(pageCount, 1) + + const table = useReactTable({ + data, + columns, + state: { + sorting, + pagination, + }, + onSortingChange, + onPaginationChange, + getCoreRowModel: getCoreRowModel(), + manualSorting: true, + manualPagination: true, + enableSortingRemoval: false, + pageCount: safePageCount, + getRowId, + meta, + }) + + const rows = table.getRowModel().rows + const columnCount = table.getVisibleLeafColumns().length + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const columnMeta = header.column.columnDef.meta as AdminColumnMeta | undefined + + return ( + + {header.isPlaceholder ? null : header.column.getCanSort() ? ( + + ) : ( + flexRender(header.column.columnDef.header, header.getContext()) + )} + + ) + })} + + ))} + + + + {rows.length === 0 ? ( + + + {isLoading ? loadingMessage : emptyMessage} + + + ) : ( + rows.map((row) => ( + onRowClick?.(row.original)} + > + {row.getVisibleCells().map((cell) => { + const columnMeta = cell.column.columnDef.meta as AdminColumnMeta | undefined + + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + )) + )} + +
+
+ +
+ + 총 {totalElements ?? rows.length}건 / {pagination.pageIndex + 1}페이지 + + +
+ + +
+
+
+ ) +} diff --git a/src/components/admin/table/sorting.ts b/src/components/admin/table/sorting.ts new file mode 100644 index 0000000..28291e4 --- /dev/null +++ b/src/components/admin/table/sorting.ts @@ -0,0 +1,42 @@ +import type { SortingState, Updater } from "@tanstack/react-table" +import { functionalUpdate } from "@tanstack/react-table" + +import type { ColumnSortMap } from "./types" + +export function createSortingState(columnId: string, desc = false): SortingState { + return [{ id: columnId, desc }] +} + +export function normalizeSingleSorting( + updater: Updater, + current: SortingState, + fallback: SortingState, +): SortingState { + const resolved = functionalUpdate(updater, current) + const first = resolved[0] + + if (!first || !first.id) { + return fallback + } + + return [{ id: first.id, desc: Boolean(first.desc) }] +} + +export function serializeSortingState( + sorting: SortingState, + sortMap: ColumnSortMap, + fallback: string, +): string { + const first = sorting[0] + + if (!first) { + return fallback + } + + const sortKey = sortMap[first.id] + if (!sortKey) { + return fallback + } + + return `${sortKey},${first.desc ? "desc" : "asc"}` +} diff --git a/src/components/admin/table/types.ts b/src/components/admin/table/types.ts new file mode 100644 index 0000000..15932d5 --- /dev/null +++ b/src/components/admin/table/types.ts @@ -0,0 +1,33 @@ +import type { + ColumnDef, + PaginationState, + Row, + SortingState, + TableMeta, + Updater, +} from "@tanstack/react-table" + +export type ColumnSortMap = Record + +export interface AdminDataTableProps { + columns: ColumnDef[] + data: TData[] + sorting: SortingState + onSortingChange: (updater: Updater) => void + pagination: PaginationState + onPaginationChange: (updater: Updater) => void + pageCount: number + totalElements?: number + isLoading?: boolean + loadingMessage?: string + emptyMessage?: string + getRowId?: (originalRow: TData, index: number, parent?: Row) => string + onRowClick?: (row: TData) => void + rowClassName?: (row: TData) => string | undefined + meta?: TableMeta +} + +export interface AdminColumnMeta { + headerClassName?: string + cellClassName?: string +} diff --git a/src/page/coupon.tsx b/src/page/coupon.tsx index c83ed58..a5c7e58 100644 --- a/src/page/coupon.tsx +++ b/src/page/coupon.tsx @@ -1,4 +1,3 @@ -import { Card, CardContent } from "@/components/ui/card" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { formatDateTime, useCouponPageState } from "@/page/coupon/hooks/useCouponPageState" import { CouponCodeTabSection } from "@/page/coupon/sections/CouponCodeTabSection" @@ -11,7 +10,13 @@ const CouponPage: React.FC = () => { return (
- + state.setTab(value as typeof state.tab)}> @@ -27,11 +32,16 @@ const CouponPage: React.FC = () => { newDiscountAmount={state.newDiscountAmount} onNewDiscountAmountChange={state.setNewDiscountAmount} onCreateCoupon={state.handleCreateCoupon} - filteredCoupons={state.filteredCoupons} + couponPage={state.couponPage} couponNameDrafts={state.couponNameDrafts} onCouponNameDraftChange={state.handleCouponNameDraftChange} onUpdateCouponName={state.handleUpdateCouponName} onDeleteCoupon={state.handleDeleteCoupon} + sorting={state.couponSorting} + onSortingChange={state.handleCouponSortingChange} + pagination={state.couponPagination} + onPaginationChange={state.handleCouponPaginationChange} + isLoading={state.isDataLoading} /> @@ -43,9 +53,14 @@ const CouponPage: React.FC = () => { newCouponCodeDescription={state.newCouponCodeDescription} onNewCouponCodeDescriptionChange={state.setNewCouponCodeDescription} onCreateCouponCode={state.handleCreateCouponCode} - filteredCouponCodes={state.filteredCouponCodes} + couponCodePage={state.couponCodePage} onDeleteCouponCode={state.handleDeleteCouponCode} formatDateTime={formatDateTime} + sorting={state.couponCodeSorting} + onSortingChange={state.handleCouponCodeSortingChange} + pagination={state.couponCodePagination} + onPaginationChange={state.handleCouponCodePaginationChange} + isLoading={state.isDataLoading} /> @@ -66,24 +81,17 @@ const CouponPage: React.FC = () => { onClearFilteredMembers={state.handleClearFilteredMembers} onClearAllSelectedMembers={state.handleClearAllSelectedMembers} onToggleMemberForIssue={state.handleToggleMemberForIssue} - filteredIssuedCoupons={state.filteredIssuedCoupons} + issuedCouponPage={state.issuedCouponPage} onDeleteIssuedCoupon={state.handleDeleteIssuedCoupon} formatDateTime={formatDateTime} + sorting={state.issuedSorting} + onSortingChange={state.handleIssuedSortingChange} + pagination={state.issuedPagination} + onPaginationChange={state.handleIssuedPaginationChange} + isLoading={state.isDataLoading} /> - - {state.isDataLoading && ( - - 데이터를 불러오는 중입니다. - - )} - - {!state.isDataLoading && state.currentRows.length === 0 && ( - - 조건에 맞는 데이터가 없습니다. - - )}
) } diff --git a/src/page/coupon/hooks/useCouponPageState.ts b/src/page/coupon/hooks/useCouponPageState.ts index f336623..f8043fb 100644 --- a/src/page/coupon/hooks/useCouponPageState.ts +++ b/src/page/coupon/hooks/useCouponPageState.ts @@ -1,5 +1,10 @@ import { useEffect, useMemo, useState } from "react" +import type { PaginationState, SortingState, Updater } from "@tanstack/react-table" +import { functionalUpdate } from "@tanstack/react-table" + +import { createSortingState, normalizeSingleSorting, serializeSortingState } from "@/components/admin" +import type { ColumnSortMap } from "@/components/admin" import { createCoupon } from "@/api/coupon/post-coupon" import { createCouponCode } from "@/api/coupon/post-coupon-code" import { createIssuedCoupons } from "@/api/coupon/post-issued-coupons" @@ -13,8 +18,9 @@ import { getIssuedCoupons } from "@/api/coupon/get-issued-coupons" import { updateCouponName } from "@/api/coupon/patch-coupon-name" import type { AdminCoupon, - AdminCouponCode, - AdminIssuedCoupon, + AdminCouponCodePageResponse, + AdminCouponPageResponse, + AdminIssuedCouponPageResponse, AdminMemberSummary, } from "@/api/coupon/types" import { resolveAdminErrorMessage } from "@/lib/errors/admin-error" @@ -22,6 +28,32 @@ import { showConfirm, showError, showSuccess } from "@/utils/alert" export type CouponTab = "coupon" | "code" | "issued" +const PAGE_SIZE = 50 + +const COUPON_SORT_MAP: ColumnSortMap = { + id: "id", + name: "couponName", + discount: "discountAmount", +} + +const COUPON_CODE_SORT_MAP: ColumnSortMap = { + id: "id", + couponId: "couponId", + code: "code", + usedAt: "usedAt", +} + +const ISSUED_SORT_MAP: ColumnSortMap = { + id: "id", + coupon: "coupon", + member: "member", + usedAt: "usedAt", +} + +const DEFAULT_COUPON_SORTING = createSortingState("id", false) +const DEFAULT_COUPON_CODE_SORTING = createSortingState("id", false) +const DEFAULT_ISSUED_SORTING = createSortingState("id", false) + const couponErrorOverrides: Record = { COUPON_ALREADY_EXISTS: "동일한 이름과 할인 금액을 가진 쿠폰이 이미 존재합니다.", COUPON_NOT_FOUND: "쿠폰을 찾을 수 없습니다.", @@ -54,32 +86,53 @@ export interface CouponPageState { tab: CouponTab setTab: (nextTab: CouponTab) => void isDataLoading: boolean - searchText: string - setSearchText: (value: string) => void + searchDraft: string + setSearchDraft: (value: string) => void + appliedKeyword: string + handleApplySearch: () => void + newCouponName: string setNewCouponName: (value: string) => void newDiscountAmount: string setNewDiscountAmount: (value: string) => void couponNameDrafts: Record handleCouponNameDraftChange: (couponId: number, value: string) => void + selectedCouponIdForCode: number setSelectedCouponIdForCode: (couponId: number) => void newCouponCodeDescription: string setNewCouponCodeDescription: (description: string) => void + selectedCouponIdForIssue: number setSelectedCouponIdForIssue: (couponId: number) => void memberSearchText: string setMemberSearchText: (value: string) => void selectedMemberIdsForIssue: number[] + coupons: AdminCoupon[] - filteredCoupons: AdminCoupon[] - filteredCouponCodes: AdminCouponCode[] - filteredIssuedCoupons: AdminIssuedCoupon[] + couponPage: AdminCouponPageResponse | null + couponCodePage: AdminCouponCodePageResponse | null + issuedCouponPage: AdminIssuedCouponPageResponse | null + + couponSorting: SortingState + couponPagination: PaginationState + couponCodeSorting: SortingState + couponCodePagination: PaginationState + issuedSorting: SortingState + issuedPagination: PaginationState + + handleCouponSortingChange: (updater: Updater) => void + handleCouponPaginationChange: (updater: Updater) => void + handleCouponCodeSortingChange: (updater: Updater) => void + handleCouponCodePaginationChange: (updater: Updater) => void + handleIssuedSortingChange: (updater: Updater) => void + handleIssuedPaginationChange: (updater: Updater) => void + filteredMembersForIssue: AdminMemberSummary[] selectedMemberIdSet: Set isAllFilteredMembersSelected: boolean selectedAmongFilteredCount: number - currentRows: AdminCoupon[] | AdminCouponCode[] | AdminIssuedCoupon[] + handleCreateCoupon: () => Promise handleUpdateCouponName: (couponId: number) => Promise handleDeleteCoupon: (couponId: number) => Promise @@ -95,72 +148,220 @@ export interface CouponPageState { export const useCouponPageState = (): CouponPageState => { const [tab, setTab] = useState("coupon") - const [isDataLoading, setIsDataLoading] = useState(false) + const [searchDraft, setSearchDraft] = useState("") + const [appliedKeyword, setAppliedKeyword] = useState("") + + const [isCouponLoading, setIsCouponLoading] = useState(false) + const [isCouponCodeLoading, setIsCouponCodeLoading] = useState(false) + const [isIssuedLoading, setIsIssuedLoading] = useState(false) + + const [couponPage, setCouponPage] = useState(null) + const [couponCodePage, setCouponCodePage] = useState(null) + const [issuedCouponPage, setIssuedCouponPage] = useState(null) const [coupons, setCoupons] = useState([]) - const [couponCodes, setCouponCodes] = useState([]) - const [issuedCoupons, setIssuedCoupons] = useState([]) - const [members, setMembers] = useState([]) + const [couponNameDrafts, setCouponNameDrafts] = useState>({}) - const [searchText, setSearchText] = useState("") + const [members, setMembers] = useState([]) const [memberSearchText, setMemberSearchText] = useState("") + const [selectedMemberIdsForIssue, setSelectedMemberIdsForIssue] = useState([]) const [newCouponName, setNewCouponName] = useState("") const [newDiscountAmount, setNewDiscountAmount] = useState("5000") - const [couponNameDrafts, setCouponNameDrafts] = useState>({}) - const [selectedCouponIdForCode, setSelectedCouponIdForCode] = useState(0) const [newCouponCodeDescription, setNewCouponCodeDescription] = useState("") const [selectedCouponIdForIssue, setSelectedCouponIdForIssue] = useState(0) - const [selectedMemberIdsForIssue, setSelectedMemberIdsForIssue] = useState([]) - const loadAll = async (): Promise => { - setIsDataLoading(true) - const [couponsRes, codesRes, issuedRes, membersRes] = await Promise.all([ - getCoupons(), - getCouponCodes(), - getIssuedCoupons(), - getAdminMembers(), - ]) + const [couponSorting, setCouponSorting] = useState(DEFAULT_COUPON_SORTING) + const [couponPagination, setCouponPagination] = useState({ + pageIndex: 0, + pageSize: PAGE_SIZE, + }) + + const [couponCodeSorting, setCouponCodeSorting] = useState(DEFAULT_COUPON_CODE_SORTING) + const [couponCodePagination, setCouponCodePagination] = useState({ + pageIndex: 0, + pageSize: PAGE_SIZE, + }) + + const [issuedSorting, setIssuedSorting] = useState(DEFAULT_ISSUED_SORTING) + const [issuedPagination, setIssuedPagination] = useState({ + pageIndex: 0, + pageSize: PAGE_SIZE, + }) + + const isDataLoading = tab === "coupon" ? isCouponLoading : tab === "code" ? isCouponCodeLoading : isIssuedLoading + + const fetchCouponOptions = async (): Promise => { + const response = await getCoupons({ + page: 0, + size: 100, + sort: "id,asc", + }) + + if (!response.ok) { + showError(resolveCouponErrorMessage(response.errorName)) + return + } + const data = response.data + if (!data) { + showError(resolveCouponErrorMessage()) + return + } - if (couponsRes.ok && couponsRes.data) { - const orderedCoupons = [...couponsRes.data].sort((a, b) => a.couponId - b.couponId) - setCoupons(orderedCoupons) - setCouponNameDrafts((prevDrafts) => { - const nextDrafts: Record = {} - orderedCoupons.forEach((coupon) => { - nextDrafts[coupon.couponId] = prevDrafts[coupon.couponId] ?? coupon.couponName - }) - return nextDrafts + setCoupons(data.content) + setCouponNameDrafts((prevDrafts) => { + const nextDrafts: Record = {} + data.content.forEach((coupon) => { + nextDrafts[coupon.couponId] = prevDrafts[coupon.couponId] ?? coupon.couponName }) - } else { - showError(resolveCouponErrorMessage(couponsRes.errorName)) + return nextDrafts + }) + } + + const fetchMembers = async (): Promise => { + const membersRes = await getAdminMembers() + + if (membersRes.ok && membersRes.data) { + setMembers([...membersRes.data].sort((a, b) => a.memberId - b.memberId)) + return } - if (codesRes.ok && codesRes.data) { - setCouponCodes([...codesRes.data].sort((a, b) => a.codeCouponId - b.codeCouponId)) - } else { - showError(resolveCouponErrorMessage(codesRes.errorName)) + showError("회원 목록을 가져오지 못했습니다.") + } + + const fetchCouponPage = async ( + pagination: PaginationState, + sorting: SortingState, + keyword: string, + ): Promise => { + setIsCouponLoading(true) + + const response = await getCoupons({ + page: pagination.pageIndex, + size: pagination.pageSize, + sort: serializeSortingState(sorting, COUPON_SORT_MAP, "id,asc"), + keyword: keyword || undefined, + }) + + if (!response.ok) { + showError(resolveCouponErrorMessage(response.errorName)) + setCouponPage(null) + setIsCouponLoading(false) + return + } + const data = response.data + if (!data) { + showError(resolveCouponErrorMessage()) + setCouponPage(null) + setIsCouponLoading(false) + return } - if (issuedRes.ok && issuedRes.data) { - setIssuedCoupons([...issuedRes.data].sort((a, b) => a.issuedCouponId - b.issuedCouponId)) - } else { - showError(resolveCouponErrorMessage(issuedRes.errorName)) + setCouponPage(data) + setCouponPagination((prev) => { + if (prev.pageIndex === data.page && prev.pageSize === data.size) { + return prev + } + return { + ...prev, + pageIndex: data.page, + pageSize: data.size, + } + }) + + setIsCouponLoading(false) + } + + const fetchCouponCodePage = async ( + pagination: PaginationState, + sorting: SortingState, + keyword: string, + ): Promise => { + setIsCouponCodeLoading(true) + + const response = await getCouponCodes({ + page: pagination.pageIndex, + size: pagination.pageSize, + sort: serializeSortingState(sorting, COUPON_CODE_SORT_MAP, "id,asc"), + keyword: keyword || undefined, + }) + + if (!response.ok) { + showError(resolveCouponErrorMessage(response.errorName)) + setCouponCodePage(null) + setIsCouponCodeLoading(false) + return + } + const data = response.data + if (!data) { + showError(resolveCouponErrorMessage()) + setCouponCodePage(null) + setIsCouponCodeLoading(false) + return } - if (membersRes.ok && membersRes.data) { - setMembers([...membersRes.data].sort((a, b) => a.memberId - b.memberId)) - } else { - showError("회원 목록을 가져오지 못했습니다.") + setCouponCodePage(data) + setCouponCodePagination((prev) => { + if (prev.pageIndex === data.page && prev.pageSize === data.size) { + return prev + } + return { + ...prev, + pageIndex: data.page, + pageSize: data.size, + } + }) + + setIsCouponCodeLoading(false) + } + + const fetchIssuedCouponPage = async ( + pagination: PaginationState, + sorting: SortingState, + keyword: string, + ): Promise => { + setIsIssuedLoading(true) + + const response = await getIssuedCoupons({ + page: pagination.pageIndex, + size: pagination.pageSize, + sort: serializeSortingState(sorting, ISSUED_SORT_MAP, "id,asc"), + keyword: keyword || undefined, + }) + + if (!response.ok) { + showError(resolveCouponErrorMessage(response.errorName)) + setIssuedCouponPage(null) + setIsIssuedLoading(false) + return + } + const data = response.data + if (!data) { + showError(resolveCouponErrorMessage()) + setIssuedCouponPage(null) + setIsIssuedLoading(false) + return } - setIsDataLoading(false) + setIssuedCouponPage(data) + setIssuedPagination((prev) => { + if (prev.pageIndex === data.page && prev.pageSize === data.size) { + return prev + } + return { + ...prev, + pageIndex: data.page, + pageSize: data.size, + } + }) + + setIsIssuedLoading(false) } useEffect(() => { - void loadAll() + void Promise.all([fetchCouponOptions(), fetchMembers()]) }, []) useEffect(() => { @@ -179,59 +380,125 @@ export const useCouponPageState = (): CouponPageState => { } }, [coupons, selectedCouponIdForCode, selectedCouponIdForIssue]) - const keyword = searchText.trim().toLowerCase() + useEffect(() => { + if (tab !== "coupon") { + return + } - const filteredCoupons = useMemo(() => { - if (!keyword) { - return coupons + void fetchCouponPage(couponPagination, couponSorting, appliedKeyword) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tab, couponPagination.pageIndex, couponPagination.pageSize, couponSorting, appliedKeyword]) + + useEffect(() => { + if (tab !== "code") { + return } - return coupons.filter((coupon) => { - return ( - coupon.couponName.toLowerCase().includes(keyword) || - String(coupon.couponId).includes(keyword) || - String(coupon.discountAmount).includes(keyword) - ) - }) - }, [coupons, keyword]) + void fetchCouponCodePage(couponCodePagination, couponCodeSorting, appliedKeyword) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tab, couponCodePagination.pageIndex, couponCodePagination.pageSize, couponCodeSorting, appliedKeyword]) - const filteredCouponCodes = useMemo(() => { - if (!keyword) { - return couponCodes + useEffect(() => { + if (tab !== "issued") { + return } - return couponCodes.filter((couponCode) => { - return ( - couponCode.couponName.toLowerCase().includes(keyword) || - couponCode.code.toLowerCase().includes(keyword) || - (couponCode.description ?? "").toLowerCase().includes(keyword) || - String(couponCode.codeCouponId).includes(keyword) || - String(couponCode.couponId).includes(keyword) - ) - }) - }, [couponCodes, keyword]) + void fetchIssuedCouponPage(issuedPagination, issuedSorting, appliedKeyword) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tab, issuedPagination.pageIndex, issuedPagination.pageSize, issuedSorting, appliedKeyword]) + + const handleApplySearch = (): void => { + const nextKeyword = searchDraft.trim() - const studentIdByMemberId = useMemo(() => { - return new Map(members.map((member) => [member.memberId, member.studentId?.toLowerCase() ?? ""])) - }, [members]) + setAppliedKeyword(nextKeyword) - const filteredIssuedCoupons = useMemo(() => { - if (!keyword) { - return issuedCoupons + if (tab === "coupon") { + setCouponPagination((prev) => ({ + ...prev, + pageIndex: 0, + })) + return } - return issuedCoupons.filter((issuedCoupon) => { - const studentId = studentIdByMemberId.get(issuedCoupon.memberId) ?? "" - return ( - issuedCoupon.couponName.toLowerCase().includes(keyword) || - issuedCoupon.memberName.toLowerCase().includes(keyword) || - issuedCoupon.memberEmail.toLowerCase().includes(keyword) || - studentId.includes(keyword) || - String(issuedCoupon.issuedCouponId).includes(keyword) || - String(issuedCoupon.memberId).includes(keyword) - ) - }) - }, [issuedCoupons, keyword, studentIdByMemberId]) + if (tab === "code") { + setCouponCodePagination((prev) => ({ + ...prev, + pageIndex: 0, + })) + return + } + + setIssuedPagination((prev) => ({ + ...prev, + pageIndex: 0, + })) + } + + const handleCouponSortingChange = (updater: Updater): void => { + setCouponSorting((prev) => normalizeSingleSorting(updater, prev, DEFAULT_COUPON_SORTING)) + setCouponPagination((prev) => ({ + ...prev, + pageIndex: 0, + })) + } + + const handleCouponPaginationChange = (updater: Updater): void => { + const nextPagination = functionalUpdate(updater, couponPagination) + + if (nextPagination.pageIndex < 0) { + return + } + + if (couponPage && nextPagination.pageIndex >= couponPage.totalPages) { + return + } + + setCouponPagination(nextPagination) + } + + const handleCouponCodeSortingChange = (updater: Updater): void => { + setCouponCodeSorting((prev) => normalizeSingleSorting(updater, prev, DEFAULT_COUPON_CODE_SORTING)) + setCouponCodePagination((prev) => ({ + ...prev, + pageIndex: 0, + })) + } + + const handleCouponCodePaginationChange = (updater: Updater): void => { + const nextPagination = functionalUpdate(updater, couponCodePagination) + + if (nextPagination.pageIndex < 0) { + return + } + + if (couponCodePage && nextPagination.pageIndex >= couponCodePage.totalPages) { + return + } + + setCouponCodePagination(nextPagination) + } + + const handleIssuedSortingChange = (updater: Updater): void => { + setIssuedSorting((prev) => normalizeSingleSorting(updater, prev, DEFAULT_ISSUED_SORTING)) + setIssuedPagination((prev) => ({ + ...prev, + pageIndex: 0, + })) + } + + const handleIssuedPaginationChange = (updater: Updater): void => { + const nextPagination = functionalUpdate(updater, issuedPagination) + + if (nextPagination.pageIndex < 0) { + return + } + + if (issuedCouponPage && nextPagination.pageIndex >= issuedCouponPage.totalPages) { + return + } + + setIssuedPagination(nextPagination) + } const memberKeyword = memberSearchText.trim().toLowerCase() @@ -286,7 +553,10 @@ export const useCouponPageState = (): CouponPageState => { showSuccess("쿠폰을 생성했습니다.") setNewCouponName("") - await loadAll() + await Promise.all([ + fetchCouponOptions(), + fetchCouponPage(couponPagination, couponSorting, appliedKeyword), + ]) } const handleUpdateCouponName = async (couponId: number): Promise => { @@ -303,7 +573,10 @@ export const useCouponPageState = (): CouponPageState => { } showSuccess("쿠폰 이름을 수정했습니다.") - await loadAll() + await Promise.all([ + fetchCouponOptions(), + fetchCouponPage(couponPagination, couponSorting, appliedKeyword), + ]) } const handleDeleteCoupon = async (couponId: number): Promise => { @@ -319,7 +592,10 @@ export const useCouponPageState = (): CouponPageState => { } showSuccess("쿠폰을 삭제했습니다.") - await loadAll() + await Promise.all([ + fetchCouponOptions(), + fetchCouponPage(couponPagination, couponSorting, appliedKeyword), + ]) } const handleCreateCouponCode = async (): Promise => { @@ -337,7 +613,7 @@ export const useCouponPageState = (): CouponPageState => { showSuccess("쿠폰 코드를 생성했습니다.") setNewCouponCodeDescription("") - await loadAll() + await fetchCouponCodePage(couponCodePagination, couponCodeSorting, appliedKeyword) } const handleDeleteCouponCode = async (codeCouponId: number): Promise => { @@ -353,7 +629,7 @@ export const useCouponPageState = (): CouponPageState => { } showSuccess("쿠폰 코드를 삭제했습니다.") - await loadAll() + await fetchCouponCodePage(couponCodePagination, couponCodeSorting, appliedKeyword) } const handleCreateIssuedCoupons = async (): Promise => { @@ -375,7 +651,7 @@ export const useCouponPageState = (): CouponPageState => { showSuccess(`${response.data?.length ?? 0}건의 쿠폰 발급을 완료했습니다.`) setSelectedMemberIdsForIssue([]) - await loadAll() + await fetchIssuedCouponPage(issuedPagination, issuedSorting, appliedKeyword) } const handleDeleteIssuedCoupon = async (issuedCouponId: number): Promise => { @@ -391,7 +667,7 @@ export const useCouponPageState = (): CouponPageState => { } showSuccess("발급된 쿠폰을 삭제했습니다.") - await loadAll() + await fetchIssuedCouponPage(issuedPagination, issuedSorting, appliedKeyword) } const handleToggleMemberForIssue = (memberId: number): void => { @@ -425,9 +701,6 @@ export const useCouponPageState = (): CouponPageState => { }) } - const currentRows = - tab === "coupon" ? filteredCoupons : tab === "code" ? filteredCouponCodes : filteredIssuedCoupons - const handleCouponNameDraftChange = (couponId: number, value: string): void => { setCouponNameDrafts((prev) => ({ ...prev, @@ -439,8 +712,10 @@ export const useCouponPageState = (): CouponPageState => { tab, setTab, isDataLoading, - searchText, - setSearchText, + searchDraft, + setSearchDraft, + appliedKeyword, + handleApplySearch, newCouponName, setNewCouponName, newDiscountAmount, @@ -457,14 +732,25 @@ export const useCouponPageState = (): CouponPageState => { setMemberSearchText, selectedMemberIdsForIssue, coupons, - filteredCoupons, - filteredCouponCodes, - filteredIssuedCoupons, + couponPage, + couponCodePage, + issuedCouponPage, + couponSorting, + couponPagination, + couponCodeSorting, + couponCodePagination, + issuedSorting, + issuedPagination, + handleCouponSortingChange, + handleCouponPaginationChange, + handleCouponCodeSortingChange, + handleCouponCodePaginationChange, + handleIssuedSortingChange, + handleIssuedPaginationChange, filteredMembersForIssue, selectedMemberIdSet, isAllFilteredMembersSelected, selectedAmongFilteredCount, - currentRows, handleCreateCoupon, handleUpdateCouponName, handleDeleteCoupon, diff --git a/src/page/coupon/sections/CouponCodeTabSection.tsx b/src/page/coupon/sections/CouponCodeTabSection.tsx index 2cc9c27..f5c796c 100644 --- a/src/page/coupon/sections/CouponCodeTabSection.tsx +++ b/src/page/coupon/sections/CouponCodeTabSection.tsx @@ -1,26 +1,18 @@ -import { useMemo, useState } from "react" +import { useMemo } from "react" -import type { AdminCoupon, AdminCouponCode } from "@/api/coupon/types" -import { AdminSortableTableHead } from "@/components/admin" +import type { ColumnDef, PaginationState, SortingState, Updater } from "@tanstack/react-table" + +import type { + AdminCoupon, + AdminCouponCode, + AdminCouponCodePageResponse, +} from "@/api/coupon/types" +import { AdminDataTable } from "@/components/admin" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" interface CouponCodeTabSectionProps { coupons: AdminCoupon[] @@ -29,9 +21,14 @@ interface CouponCodeTabSectionProps { newCouponCodeDescription: string onNewCouponCodeDescriptionChange: (description: string) => void onCreateCouponCode: () => Promise - filteredCouponCodes: AdminCouponCode[] + couponCodePage: AdminCouponCodePageResponse | null onDeleteCouponCode: (codeCouponId: number) => Promise formatDateTime: (value: string | null) => string + sorting: SortingState + onSortingChange: (updater: Updater) => void + pagination: PaginationState + onPaginationChange: (updater: Updater) => void + isLoading: boolean } export const CouponCodeTabSection: React.FC = ({ @@ -41,34 +38,80 @@ export const CouponCodeTabSection: React.FC = ({ newCouponCodeDescription, onNewCouponCodeDescriptionChange, onCreateCouponCode, - filteredCouponCodes, + couponCodePage, onDeleteCouponCode, formatDateTime, + sorting, + onSortingChange, + pagination, + onPaginationChange, + isLoading, }) => { - const [sort, setSort] = useState< - "id,asc" | "id,desc" | "couponId,asc" | "couponId,desc" | "code,asc" | "code,desc" | "usedAt,asc" | "usedAt,desc" - >("id,asc") - - const sortedCouponCodes = useMemo(() => { - const [sortKey, direction] = sort.split(",") as ["id" | "couponId" | "code" | "usedAt", "asc" | "desc"] - const sorted = [...filteredCouponCodes].sort((left, right) => { - if (sortKey === "id") { - return left.codeCouponId - right.codeCouponId - } - if (sortKey === "couponId") { - return left.couponId - right.couponId - } - if (sortKey === "code") { - return left.code.localeCompare(right.code, "ko") - } - - const leftValue = left.usedAt ? new Date(left.usedAt).getTime() : Number.POSITIVE_INFINITY - const rightValue = right.usedAt ? new Date(right.usedAt).getTime() : Number.POSITIVE_INFINITY - return leftValue - rightValue - }) - - return direction === "asc" ? sorted : sorted.reverse() - }, [filteredCouponCodes, sort]) + const columns = useMemo[]>(() => { + return [ + { + id: "id", + accessorKey: "codeCouponId", + header: "코드 ID", + enableSorting: true, + }, + { + id: "couponId", + accessorKey: "couponId", + header: "쿠폰 ID", + enableSorting: true, + }, + { + id: "code", + accessorKey: "code", + header: "코드", + enableSorting: true, + cell: ({ row }) =>
{row.original.code}
, + }, + { + id: "description", + accessorKey: "description", + header: "설명", + cell: ({ row }) => ( +
+ {row.original.description ?? "-"} +
+ ), + }, + { + id: "status", + header: "상태", + cell: ({ row }) => + row.original.isValid ? "사용 가능" : `사용 완료 (${row.original.couponName})`, + }, + { + id: "usedAt", + accessorKey: "usedAt", + header: "사용 시각", + enableSorting: true, + sortDescFirst: true, + cell: ({ row }) => formatDateTime(row.original.usedAt), + }, + { + id: "delete", + header: "삭제", + meta: { + headerClassName: "text-right", + cellClassName: "text-right", + }, + cell: ({ row }) => ( + + ), + }, + ] + }, [formatDateTime, onDeleteCouponCode]) return ( <> @@ -107,56 +150,20 @@ export const CouponCodeTabSection: React.FC = ({ -
- - - - setSort(nextSort as typeof sort)} /> - setSort(nextSort as typeof sort)} - /> - setSort(nextSort as typeof sort)} /> - 설명 - 상태 - setSort(nextSort as typeof sort)} - defaultDirection="desc" - /> - 삭제 - - - - {sortedCouponCodes.map((couponCode) => ( - - {couponCode.codeCouponId} - {couponCode.couponId} - {couponCode.code} - - {couponCode.description ?? "-"} - - {couponCode.isValid ? "사용 가능" : `사용 완료 (${couponCode.couponName})`} - {formatDateTime(couponCode.usedAt)} - - - - - ))} - -
-
+ String(row.codeCouponId)} + />
diff --git a/src/page/coupon/sections/CouponIssuedTabSection.tsx b/src/page/coupon/sections/CouponIssuedTabSection.tsx index bbd4a9a..7156c5f 100644 --- a/src/page/coupon/sections/CouponIssuedTabSection.tsx +++ b/src/page/coupon/sections/CouponIssuedTabSection.tsx @@ -1,7 +1,14 @@ -import { useMemo, useState } from "react" +import { useMemo } from "react" -import type { AdminCoupon, AdminIssuedCoupon, AdminMemberSummary } from "@/api/coupon/types" -import { AdminSortableTableHead } from "@/components/admin" +import type { ColumnDef, PaginationState, SortingState, Updater } from "@tanstack/react-table" + +import type { + AdminCoupon, + AdminIssuedCoupon, + AdminIssuedCouponPageResponse, + AdminMemberSummary, +} from "@/api/coupon/types" +import { AdminDataTable } from "@/components/admin" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" @@ -9,21 +16,7 @@ import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { ScrollArea } from "@/components/ui/scroll-area" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" interface CouponIssuedTabSectionProps { coupons: AdminCoupon[] @@ -41,9 +34,14 @@ interface CouponIssuedTabSectionProps { onClearFilteredMembers: () => void onClearAllSelectedMembers: () => void onToggleMemberForIssue: (memberId: number) => void - filteredIssuedCoupons: AdminIssuedCoupon[] + issuedCouponPage: AdminIssuedCouponPageResponse | null onDeleteIssuedCoupon: (issuedCouponId: number) => Promise formatDateTime: (value: string | null) => string + sorting: SortingState + onSortingChange: (updater: Updater) => void + pagination: PaginationState + onPaginationChange: (updater: Updater) => void + isLoading: boolean } export const CouponIssuedTabSection: React.FC = ({ @@ -62,34 +60,84 @@ export const CouponIssuedTabSection: React.FC = ({ onClearFilteredMembers, onClearAllSelectedMembers, onToggleMemberForIssue, - filteredIssuedCoupons, + issuedCouponPage, onDeleteIssuedCoupon, formatDateTime, + sorting, + onSortingChange, + pagination, + onPaginationChange, + isLoading, }) => { - const [sort, setSort] = useState< - "id,asc" | "id,desc" | "coupon,asc" | "coupon,desc" | "member,asc" | "member,desc" | "usedAt,asc" | "usedAt,desc" - >("id,asc") - - const sortedIssuedCoupons = useMemo(() => { - const [sortKey, direction] = sort.split(",") as ["id" | "coupon" | "member" | "usedAt", "asc" | "desc"] - const sorted = [...filteredIssuedCoupons].sort((left, right) => { - if (sortKey === "id") { - return left.issuedCouponId - right.issuedCouponId - } - if (sortKey === "coupon") { - return left.couponName.localeCompare(right.couponName, "ko") - } - if (sortKey === "member") { - return left.memberName.localeCompare(right.memberName, "ko") - } - - const leftValue = left.usedAt ? new Date(left.usedAt).getTime() : Number.POSITIVE_INFINITY - const rightValue = right.usedAt ? new Date(right.usedAt).getTime() : Number.POSITIVE_INFINITY - return leftValue - rightValue - }) - - return direction === "asc" ? sorted : sorted.reverse() - }, [filteredIssuedCoupons, sort]) + const columns = useMemo[]>(() => { + return [ + { + id: "id", + accessorKey: "issuedCouponId", + header: "발급 ID", + enableSorting: true, + }, + { + id: "coupon", + accessorKey: "couponName", + header: "쿠폰", + enableSorting: true, + cell: ({ row }) => + `[${row.original.couponId}] ${row.original.couponName} (${Number(row.original.discountAmount).toLocaleString("ko-KR")}원)`, + }, + { + id: "member", + accessorKey: "memberName", + header: "회원", + enableSorting: true, + cell: ({ row }) => ( +
+
+ [{row.original.memberId}] {row.original.memberName} +
+
{row.original.memberEmail}
+
+ ), + }, + { + id: "status", + header: "상태", + cell: ({ row }) => (row.original.isValid ? "미사용" : "사용됨"), + }, + { + id: "paymentId", + accessorKey: "paymentId", + header: "결제 ID", + cell: ({ row }) => row.original.paymentId ?? "-", + }, + { + id: "usedAt", + accessorKey: "usedAt", + header: "사용 시각", + enableSorting: true, + sortDescFirst: true, + cell: ({ row }) => formatDateTime(row.original.usedAt), + }, + { + id: "delete", + header: "삭제", + meta: { + headerClassName: "text-right", + cellClassName: "text-right", + }, + cell: ({ row }) => ( + + ), + }, + ] + }, [formatDateTime, onDeleteIssuedCoupon]) return ( <> @@ -186,68 +234,20 @@ export const CouponIssuedTabSection: React.FC = ({ -
- - - - setSort(nextSort as typeof sort)} /> - setSort(nextSort as typeof sort)} - /> - setSort(nextSort as typeof sort)} - /> - 상태 - 결제 ID - setSort(nextSort as typeof sort)} - defaultDirection="desc" - /> - 삭제 - - - - {sortedIssuedCoupons.map((issuedCoupon) => ( - - {issuedCoupon.issuedCouponId} - - [{issuedCoupon.couponId}] {issuedCoupon.couponName} ({Number(issuedCoupon.discountAmount).toLocaleString("ko-KR")}원) - - -
-
- [{issuedCoupon.memberId}] {issuedCoupon.memberName} -
-
{issuedCoupon.memberEmail}
-
-
- {issuedCoupon.isValid ? "미사용" : "사용됨"} - {issuedCoupon.paymentId ?? "-"} - {formatDateTime(issuedCoupon.usedAt)} - - - -
- ))} -
-
-
+ String(row.issuedCouponId)} + />
diff --git a/src/page/coupon/sections/CouponPageHeader.tsx b/src/page/coupon/sections/CouponPageHeader.tsx index 3701a38..8d27558 100644 --- a/src/page/coupon/sections/CouponPageHeader.tsx +++ b/src/page/coupon/sections/CouponPageHeader.tsx @@ -1,14 +1,21 @@ +import { Button } from "@/components/ui/button" import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" interface CouponPageHeaderProps { - searchText: string - onSearchTextChange: (value: string) => void + searchDraft: string + appliedKeyword: string + isLoading: boolean + onSearchDraftChange: (value: string) => void + onApplySearch: () => void } export const CouponPageHeader: React.FC = ({ - searchText, - onSearchTextChange, + searchDraft, + appliedKeyword, + isLoading, + onSearchDraftChange, + onApplySearch, }) => { return ( @@ -16,13 +23,26 @@ export const CouponPageHeader: React.FC = ({
쿠폰 관리 쿠폰, 쿠폰 코드, 발급된 쿠폰을 한 화면에서 관리합니다. + + 적용된 검색어: {appliedKeyword ? `"${appliedKeyword}"` : "(없음)"} + +
+ +
+ onSearchDraftChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + onApplySearch() + } + }} + placeholder="ID, 쿠폰명, 회원명, 학번, 이메일, 코드, 설명 검색" + /> +
- onSearchTextChange(event.target.value)} - placeholder="ID, 쿠폰명, 회원명, 학번, 이메일, 코드, 설명 검색" - />
) diff --git a/src/page/coupon/sections/CouponTabSection.tsx b/src/page/coupon/sections/CouponTabSection.tsx index beca9a1..ac41f31 100644 --- a/src/page/coupon/sections/CouponTabSection.tsx +++ b/src/page/coupon/sections/CouponTabSection.tsx @@ -1,19 +1,13 @@ -import { useMemo, useState } from "react" +import { useMemo } from "react" -import type { AdminCoupon } from "@/api/coupon/types" -import { AdminSortableTableHead } from "@/components/admin" +import type { ColumnDef, PaginationState, SortingState, Updater } from "@tanstack/react-table" + +import type { AdminCoupon, AdminCouponPageResponse } from "@/api/coupon/types" +import { AdminDataTable } from "@/components/admin" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" interface CouponTabSectionProps { newCouponName: string @@ -21,11 +15,16 @@ interface CouponTabSectionProps { newDiscountAmount: string onNewDiscountAmountChange: (value: string) => void onCreateCoupon: () => Promise - filteredCoupons: AdminCoupon[] + couponPage: AdminCouponPageResponse | null couponNameDrafts: Record onCouponNameDraftChange: (couponId: number, value: string) => void onUpdateCouponName: (couponId: number) => Promise onDeleteCoupon: (couponId: number) => Promise + sorting: SortingState + onSortingChange: (updater: Updater) => void + pagination: PaginationState + onPaginationChange: (updater: Updater) => void + isLoading: boolean } export const CouponTabSection: React.FC = ({ @@ -34,30 +33,75 @@ export const CouponTabSection: React.FC = ({ newDiscountAmount, onNewDiscountAmountChange, onCreateCoupon, - filteredCoupons, + couponPage, couponNameDrafts, onCouponNameDraftChange, onUpdateCouponName, onDeleteCoupon, + sorting, + onSortingChange, + pagination, + onPaginationChange, + isLoading, }) => { - const [sort, setSort] = useState<"id,asc" | "id,desc" | "name,asc" | "name,desc" | "discount,asc" | "discount,desc">( - "id,asc", - ) - - const sortedCoupons = useMemo(() => { - const [sortKey, direction] = sort.split(",") as ["id" | "name" | "discount", "asc" | "desc"] - const sorted = [...filteredCoupons].sort((left, right) => { - if (sortKey === "id") { - return left.couponId - right.couponId - } - if (sortKey === "name") { - return left.couponName.localeCompare(right.couponName, "ko") - } - return Number(left.discountAmount) - Number(right.discountAmount) - }) - - return direction === "asc" ? sorted : sorted.reverse() - }, [filteredCoupons, sort]) + const columns = useMemo[]>(() => { + return [ + { + id: "id", + accessorKey: "couponId", + header: "ID", + enableSorting: true, + }, + { + id: "name", + accessorKey: "couponName", + header: "쿠폰 이름", + enableSorting: true, + }, + { + id: "discount", + accessorKey: "discountAmount", + header: "할인 금액", + enableSorting: true, + meta: { + headerClassName: "text-right", + cellClassName: "text-right", + }, + cell: ({ row }) => `${Number(row.original.discountAmount).toLocaleString("ko-KR")}원`, + }, + { + id: "rename", + header: "이름 수정", + meta: { + headerClassName: "w-[280px]", + }, + cell: ({ row }) => ( +
+ onCouponNameDraftChange(row.original.couponId, event.target.value)} + /> + +
+ ), + }, + { + id: "delete", + header: "삭제", + meta: { + headerClassName: "w-28 text-right", + cellClassName: "text-right", + }, + cell: ({ row }) => ( + + ), + }, + ] + }, [couponNameDrafts, onCouponNameDraftChange, onDeleteCoupon, onUpdateCouponName]) return ( <> @@ -86,56 +130,20 @@ export const CouponTabSection: React.FC = ({ -
- - - - setSort(nextSort as typeof sort)} /> - setSort(nextSort as typeof sort)} /> - setSort(nextSort as typeof sort)} - className="text-right" - /> - 이름 수정 - 삭제 - - - - {sortedCoupons.map((coupon) => ( - - {coupon.couponId} - {coupon.couponName} - - {Number(coupon.discountAmount).toLocaleString("ko-KR")}원 - - -
- onCouponNameDraftChange(coupon.couponId, event.target.value)} - /> - -
-
- - - -
- ))} -
-
-
+ String(row.couponId)} + />
diff --git a/src/page/event.tsx b/src/page/event.tsx index b0b84c9..10eb63d 100644 --- a/src/page/event.tsx +++ b/src/page/event.tsx @@ -9,16 +9,23 @@ const Event = () => { return (
> + appliedKeyword: string + sorting: SortingState + pagination: PaginationState rows: EventRow[] + pageData: AdminActivityPageResponse | null showEditDialog: boolean setShowEditDialog: Dispatch> editingActivityId: number | null @@ -27,8 +46,9 @@ interface UseEventPageStateResult { pointAmount: string setPointAmount: Dispatch> qrActivityId: number | null - setSearchText: Dispatch> - toggleSortOrder: () => void + handleApplySearch: () => void + handleSortingChange: (updater: Updater) => void + handlePaginationChange: (updater: Updater) => void handleCloseQR: () => void handleOpenQR: (eventId: number) => void handleOpenCreateDialog: () => void @@ -38,10 +58,16 @@ interface UseEventPageStateResult { } export const useEventPageState = (): UseEventPageStateResult => { - const [activities, setActivities] = useState([]) + const [activityPage, setActivityPage] = useState(null) const [isLoading, setIsLoading] = useState(true) - const [searchText, setSearchText] = useState("") - const [isSortAsc, setIsSortAsc] = useState(true) + + const [searchDraft, setSearchDraft] = useState("") + const [appliedKeyword, setAppliedKeyword] = useState("") + const [sorting, setSorting] = useState(DEFAULT_EVENT_SORTING) + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: PAGE_SIZE, + }) const [showEditDialog, setShowEditDialog] = useState(false) const [editingActivityId, setEditingActivityId] = useState(null) @@ -50,50 +76,93 @@ export const useEventPageState = (): UseEventPageStateResult => { const [qrActivityId, setQrActivityId] = useState(null) - const loadActivities = useCallback(async () => { - setIsLoading(true) - const response = await GetActivities() - if (!response.ok || !response.data) { - setActivities([]) - showError(resolveAdminErrorMessage(response.errorName, { fallback: "활동 목록을 불러올 수 없습니다." })) - setIsLoading(false) - return - } + const loadActivities = useCallback( + async (nextPagination: PaginationState, nextSorting: SortingState, keyword: string) => { + setIsLoading(true) - setActivities(response.data) - setIsLoading(false) - }, []) + const response = await GetActivities({ + page: nextPagination.pageIndex, + size: nextPagination.pageSize, + sort: serializeSortingState(nextSorting, EVENT_SORT_MAP, "id,asc"), + keyword: keyword || undefined, + }) + if (!response.ok) { + setActivityPage(null) + showError(resolveAdminErrorMessage(response.errorName, { fallback: "활동 목록을 불러올 수 없습니다." })) + setIsLoading(false) + return + } + const data = response.data + if (!data) { + setActivityPage(null) + showError(resolveAdminErrorMessage(undefined, { fallback: "활동 목록을 불러올 수 없습니다." })) + setIsLoading(false) + return + } + + setActivityPage(data) + setPagination((prev) => { + if (prev.pageIndex === data.page && prev.pageSize === data.size) { + return prev + } + + return { + ...prev, + pageIndex: data.page, + pageSize: data.size, + } + }) + setIsLoading(false) + }, + [], + ) useEffect(() => { - void loadActivities() - }, [loadActivities]) + void loadActivities(pagination, sorting, appliedKeyword) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pagination.pageIndex, pagination.pageSize, sorting, appliedKeyword]) const rows = useMemo(() => { - const keyword = searchText.trim().toLowerCase() - - const mapped = activities.map((activity) => ({ + return (activityPage?.content ?? []).map((activity: Activity) => ({ id: activity.activityId, name: activity.name, amount: activity.pointAmount, })) + }, [activityPage]) - const filtered = keyword - ? mapped.filter((row) => { - return ( - row.name.toLowerCase().includes(keyword) || - String(row.id).includes(keyword) || - String(row.amount).includes(keyword) - ) - }) - : mapped - - return filtered.sort((a, b) => (isSortAsc ? a.id - b.id : b.id - a.id)) - }, [activities, isSortAsc, searchText]) - - const toggleSortOrder = useCallback(() => { - setIsSortAsc((prev) => !prev) + const handleApplySearch = useCallback(() => { + setAppliedKeyword(searchDraft.trim()) + setPagination((prev) => ({ + ...prev, + pageIndex: 0, + })) + }, [searchDraft]) + + const handleSortingChange = useCallback((updater: Updater) => { + setSorting((prev) => normalizeSingleSorting(updater, prev, DEFAULT_EVENT_SORTING)) + setPagination((prev) => ({ + ...prev, + pageIndex: 0, + })) }, []) + const handlePaginationChange = useCallback( + (updater: Updater) => { + const nextPagination = functionalUpdate(updater, pagination) + + if (nextPagination.pageIndex < 0) { + return + } + + if (activityPage && nextPagination.pageIndex >= activityPage.totalPages) { + return + } + + setPagination(nextPagination) + }, + [activityPage, pagination], + ) + const handleCloseQR = useCallback(() => { const url = new URL(window.location.href) url.searchParams.delete("id") @@ -157,9 +226,9 @@ export const useEventPageState = (): UseEventPageStateResult => { } showSuccess("활동이 삭제되었습니다.") - await loadActivities() + await loadActivities(pagination, sorting, appliedKeyword) }, - [loadActivities], + [appliedKeyword, loadActivities, pagination, sorting], ) const handleSubmitEvent = useCallback( @@ -196,16 +265,20 @@ export const useEventPageState = (): UseEventPageStateResult => { } setShowEditDialog(false) - await loadActivities() + await loadActivities(pagination, sorting, appliedKeyword) }, - [editingActivityId, eventName, loadActivities, pointAmount], + [appliedKeyword, editingActivityId, eventName, loadActivities, pagination, pointAmount, sorting], ) return { isLoading, - searchText, - isSortAsc, + searchDraft, + setSearchDraft, + appliedKeyword, + sorting, + pagination, rows, + pageData: activityPage, showEditDialog, setShowEditDialog, editingActivityId, @@ -214,8 +287,9 @@ export const useEventPageState = (): UseEventPageStateResult => { pointAmount, setPointAmount, qrActivityId, - setSearchText, - toggleSortOrder, + handleApplySearch, + handleSortingChange, + handlePaginationChange, handleCloseQR, handleOpenQR, handleOpenCreateDialog, diff --git a/src/page/event/sections/EventTableSection.tsx b/src/page/event/sections/EventTableSection.tsx index c5089b6..e4a21c8 100644 --- a/src/page/event/sections/EventTableSection.tsx +++ b/src/page/event/sections/EventTableSection.tsx @@ -1,22 +1,25 @@ +import { useMemo } from "react" + import { QrCode } from "lucide-react" +import type { ColumnDef, PaginationState, SortingState, Updater } from "@tanstack/react-table" + +import { AdminDataTable } from "@/components/admin" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" import type { EventRow } from "../hooks/useEventPageState" interface EventTableSectionProps { isLoading: boolean rows: EventRow[] + sorting: SortingState + onSortingChange: (updater: Updater) => void + pagination: PaginationState + onPaginationChange: (updater: Updater) => void + totalPages: number + totalElements: number onOpenEditDialog: (activityId: number) => Promise onDelete: (activityId: number) => Promise onOpenQR: (eventId: number) => void @@ -25,71 +28,87 @@ interface EventTableSectionProps { export const EventTableSection = ({ isLoading, rows, + sorting, + onSortingChange, + pagination, + onPaginationChange, + totalPages, + totalElements, onOpenEditDialog, onDelete, onOpenQR, }: EventTableSectionProps) => { + const columns = useMemo[]>(() => { + return [ + { + id: "id", + accessorKey: "id", + header: "ID", + enableSorting: true, + meta: { + headerClassName: "w-20 text-center", + cellClassName: "text-center font-medium", + }, + }, + { + id: "name", + accessorKey: "name", + header: "행사 이름", + enableSorting: true, + }, + { + id: "pointAmount", + accessorKey: "amount", + header: "포인트", + enableSorting: true, + meta: { + headerClassName: "w-28 text-right", + cellClassName: "text-right", + }, + cell: ({ row }) => {row.original.amount}, + }, + { + id: "actions", + header: "동작", + meta: { + headerClassName: "w-[320px] text-right", + cellClassName: "text-right", + }, + cell: ({ row }) => ( +
+ + + +
+ ), + }, + ] + }, [onDelete, onOpenEditDialog, onOpenQR]) + return ( -
- - - - ID - 행사 이름 - 포인트 - 동작 - - - - {isLoading && ( - - - 데이터를 불러오는 중입니다. - - - )} - - {!isLoading && rows.length === 0 && ( - - - 조건에 맞는 행사가 없습니다. - - - )} - - {!isLoading && - rows.map((row) => ( - - {row.id} - -
- {row.name} -
-
- - {row.amount} - - -
- - - -
-
-
- ))} -
-
-
+ String(row.id)} + />
) diff --git a/src/page/event/sections/EventToolbarSection.tsx b/src/page/event/sections/EventToolbarSection.tsx index d0972b8..c4175c4 100644 --- a/src/page/event/sections/EventToolbarSection.tsx +++ b/src/page/event/sections/EventToolbarSection.tsx @@ -5,18 +5,20 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Input } from "@/components/ui/input" interface EventToolbarSectionProps { - searchText: string - isSortAsc: boolean + searchDraft: string + appliedKeyword: string + isLoading: boolean onSearchTextChange: (value: string) => void - onToggleSortOrder: () => void + onApplySearch: () => void onOpenCreateDialog: () => void } export const EventToolbarSection = ({ - searchText, - isSortAsc, + searchDraft, + appliedKeyword, + isLoading, onSearchTextChange, - onToggleSortOrder, + onApplySearch, onOpenCreateDialog, }: EventToolbarSectionProps) => { return ( @@ -24,6 +26,7 @@ export const EventToolbarSection = ({ 행사 관리 행사 CRUD와 QR 출석 체크를 관리합니다. + 적용된 검색어: {appliedKeyword ? `"${appliedKeyword}"` : "(없음)"}
@@ -31,12 +34,23 @@ export const EventToolbarSection = ({ onSearchTextChange(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Enter") { + return + } + + if (event.nativeEvent.isComposing || isLoading) { + return + } + + onApplySearch() + }} />
-
diff --git a/src/page/member-management.tsx b/src/page/member-management.tsx index 34fd49b..3646870 100644 --- a/src/page/member-management.tsx +++ b/src/page/member-management.tsx @@ -31,17 +31,20 @@ const MemberManagementPage: React.FC = () => { void handleChangeSemester: (yearSemester: string) => void handleRoleFilterChange: (value: MemberRoleFilter) => void - handleSortFilterChange: (value: SortFilter) => void + handleSortPresetChange: (value: MemberSortPreset) => void + handleSortingChange: (updater: Updater) => void + handlePaginationChange: (updater: Updater) => void handleSelectMember: (record: AdminMemberRecordItem) => void handleDetailSemesterChange: (yearSemester: string) => void - movePage: (nextPage: number) => void } export function useMemberManagementPageState(): MemberManagementPageState { @@ -101,9 +145,12 @@ export function useMemberManagementPageState(): MemberManagementPageState { const [keywordInput, setKeywordInput] = useState("") const [appliedKeyword, setAppliedKeyword] = useState("") const [roleFilter, setRoleFilter] = useState("ALL") - const [sortFilter, setSortFilter] = useState("id,asc") + const [sorting, setSorting] = useState(DEFAULT_MEMBER_SORTING) + const [recordPagination, setRecordPagination] = useState({ + pageIndex: 0, + pageSize: PAGE_SIZE, + }) - const [recordPageIndex, setRecordPageIndex] = useState(0) const [recordPage, setRecordPage] = useState(null) const [isRecordLoading, setIsRecordLoading] = useState(false) @@ -122,6 +169,8 @@ export function useMemberManagementPageState(): MemberManagementPageState { return nextMap }, [semesterOptions]) + const sortPreset = useMemo(() => sortingToPreset(sorting), [sorting]) + const resolveSemesterLabel = (yearSemester: string): string => { return semesterLabelMap.get(yearSemester) ?? yearSemester } @@ -181,23 +230,34 @@ export function useMemberManagementPageState(): MemberManagementPageState { try { const response = await getMemberRecords({ yearSemester: selectedYearSemester, - page: recordPageIndex, - size: 50, + page: recordPagination.pageIndex, + size: recordPagination.pageSize, keyword: appliedKeyword || undefined, role: roleFilter === "ALL" ? undefined : roleFilter, - sort: sortFilter, + sort: serializeSortingState(sorting, MEMBER_SORT_MAP, "id,asc"), }) if (stale) { return } - if (!response.ok || !response.data) { + if (!response.ok) { showError(resolveMemberManagementErrorMessage(response.errorName)) setRecordPage(null) return } + const data = response.data + if (!data) { + showError(resolveMemberManagementErrorMessage()) + setRecordPage(null) + return + } - setRecordPage(response.data) + setRecordPage(data) + setRecordPagination((prev) => ({ + ...prev, + pageIndex: data.page, + pageSize: data.size, + })) } catch { if (stale) { return @@ -215,7 +275,7 @@ export function useMemberManagementPageState(): MemberManagementPageState { return () => { stale = true } - }, [selectedYearSemester, recordPageIndex, appliedKeyword, roleFilter, sortFilter]) + }, [selectedYearSemester, recordPagination.pageIndex, recordPagination.pageSize, appliedKeyword, roleFilter, sorting]) useEffect(() => { if (!selectedYearSemester) { @@ -280,13 +340,19 @@ export function useMemberManagementPageState(): MemberManagementPageState { } const handleSearch = (): void => { - setRecordPageIndex(0) + setRecordPagination((prev) => ({ + ...prev, + pageIndex: 0, + })) setAppliedKeyword(keywordInput.trim()) } const handleChangeSemester = (yearSemester: string): void => { setSelectedYearSemester(yearSemester) - setRecordPageIndex(0) + setRecordPagination((prev) => ({ + ...prev, + pageIndex: 0, + })) setSelectedMember(null) setTimelineItems([]) setActivityDetail(null) @@ -294,31 +360,49 @@ export function useMemberManagementPageState(): MemberManagementPageState { const handleRoleFilterChange = (value: MemberRoleFilter): void => { setRoleFilter(value) - setRecordPageIndex(0) + setRecordPagination((prev) => ({ + ...prev, + pageIndex: 0, + })) } - const handleSortFilterChange = (value: SortFilter): void => { - setSortFilter(value) - setRecordPageIndex(0) + const handleSortPresetChange = (value: MemberSortPreset): void => { + setSorting(presetToSorting(value)) + setRecordPagination((prev) => ({ + ...prev, + pageIndex: 0, + })) } - const handleSelectMember = (record: AdminMemberRecordItem): void => { - setSelectedMember(record) - setDetailYearSemester(selectedYearSemester) + const handleSortingChange = (updater: Updater): void => { + setSorting((prev) => normalizeSingleSorting(updater, prev, DEFAULT_MEMBER_SORTING)) + setRecordPagination((prev) => ({ + ...prev, + pageIndex: 0, + })) } - const handleDetailSemesterChange = (yearSemester: string): void => { - setDetailYearSemester(yearSemester) - } + const handlePaginationChange = (updater: Updater): void => { + const nextPagination = functionalUpdate(updater, recordPagination) - const movePage = (nextPage: number): void => { - if (nextPage < 0) { + if (nextPagination.pageIndex < 0) { return } - if (recordPage && nextPage >= recordPage.totalPages) { + + if (recordPage && nextPagination.pageIndex >= recordPage.totalPages) { return } - setRecordPageIndex(nextPage) + + setRecordPagination(nextPagination) + } + + const handleSelectMember = (record: AdminMemberRecordItem): void => { + setSelectedMember(record) + setDetailYearSemester(selectedYearSemester) + } + + const handleDetailSemesterChange = (yearSemester: string): void => { + setDetailYearSemester(yearSemester) } return { @@ -327,7 +411,9 @@ export function useMemberManagementPageState(): MemberManagementPageState { selectedYearSemester, keywordInput, roleFilter, - sortFilter, + sortPreset, + sorting, + recordPagination, recordPage, isRecordLoading, selectedMember, @@ -340,9 +426,10 @@ export function useMemberManagementPageState(): MemberManagementPageState { handleSearch, handleChangeSemester, handleRoleFilterChange, - handleSortFilterChange, + handleSortPresetChange, + handleSortingChange, + handlePaginationChange, handleSelectMember, handleDetailSemesterChange, - movePage, } } diff --git a/src/page/member-management/sections/MemberRecordsSection.tsx b/src/page/member-management/sections/MemberRecordsSection.tsx index 75c6ba6..adafc29 100644 --- a/src/page/member-management/sections/MemberRecordsSection.tsx +++ b/src/page/member-management/sections/MemberRecordsSection.tsx @@ -1,57 +1,105 @@ +import { useMemo } from "react" + +import type { ColumnDef, PaginationState, SortingState, Updater } from "@tanstack/react-table" + import type { AdminMemberRecordItem, AdminMemberRecordPage } from "@/api/member-management/types" -import { AdminSortableTableHead } from "@/components/admin" +import { AdminDataTable } from "@/components/admin" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import type { MemberRoleFilter, SortFilter } from "../hooks/useMemberManagementPageState" +import type { MemberRoleFilter, MemberSortPreset } from "../hooks/useMemberManagementPageState" interface MemberRecordsSectionProps { keywordInput: string roleFilter: MemberRoleFilter - sortFilter: SortFilter + sortPreset: MemberSortPreset + sorting: SortingState + recordPagination: PaginationState recordPage: AdminMemberRecordPage | null isRecordLoading: boolean selectedMemberId: number | null formatDateTime: (value: string | null) => string onKeywordInputChange: (value: string) => void onRoleFilterChange: (value: MemberRoleFilter) => void - onSortFilterChange: (value: SortFilter) => void + onSortPresetChange: (value: MemberSortPreset) => void + onSortingChange: (updater: Updater) => void + onPaginationChange: (updater: Updater) => void onSearch: () => void onSelectMember: (record: AdminMemberRecordItem) => void - onMovePage: (nextPage: number) => void } export const MemberRecordsSection: React.FC = ({ keywordInput, roleFilter, - sortFilter, + sortPreset, + sorting, + recordPagination, recordPage, isRecordLoading, selectedMemberId, formatDateTime, onKeywordInputChange, onRoleFilterChange, - onSortFilterChange, + onSortPresetChange, + onSortingChange, + onPaginationChange, onSearch, onSelectMember, - onMovePage, }) => { + const columns = useMemo[]>(() => { + return [ + { + id: "id", + accessorKey: "snapshotStudentId", + header: "학번", + enableSorting: true, + cell: ({ row }) => row.original.snapshotStudentId ?? "-", + }, + { + id: "name", + accessorKey: "snapshotName", + header: "이름", + enableSorting: true, + }, + { + id: "email", + accessorKey: "snapshotEmail", + header: "이메일", + cell: ({ row }) =>
{row.original.snapshotEmail}
, + }, + { + id: "role", + accessorKey: "snapshotRole", + header: "역할", + }, + { + id: "department", + accessorKey: "snapshotDepartment", + header: "학과", + cell: ({ row }) => row.original.snapshotDepartment ?? "-", + }, + { + id: "grade", + accessorKey: "snapshotGrade", + header: "학년", + cell: ({ row }) => row.original.snapshotGrade ?? "-", + }, + { + id: "recordSource", + accessorKey: "recordSource", + header: "기록 출처", + }, + { + id: "paymentCompletedAt", + accessorKey: "paymentCompletedAt", + header: "결제 완료 시각", + cell: ({ row }) => formatDateTime(row.original.paymentCompletedAt), + }, + ] + }, [formatDateTime]) + return ( @@ -80,7 +128,7 @@ export const MemberRecordsSection: React.FC = ({ - onSortPresetChange(value as MemberSortPreset)}> @@ -95,96 +143,22 @@ export const MemberRecordsSection: React.FC = ({
-
- - - - onSortFilterChange(value as SortFilter)} - /> - onSortFilterChange(value as SortFilter)} - /> - 이메일 - 역할 - 학과 - 학년 - 기록 출처 - 결제 완료 시각 - - - - {!isRecordLoading && (recordPage?.content.length ?? 0) === 0 && ( - - - 조회 결과가 없습니다. - - - )} - - {isRecordLoading && ( - - - 회원 기록을 조회하는 중입니다. - - - )} - - {!isRecordLoading && - recordPage?.content.map((record) => { - const isSelected = selectedMemberId === record.memberRecordId - - return ( - onSelectMember(record)} - > - {record.snapshotStudentId ?? "-"} - {record.snapshotName} - {record.snapshotEmail} - {record.snapshotRole} - {record.snapshotDepartment ?? "-"} - {record.snapshotGrade ?? "-"} - {record.recordSource} - {formatDateTime(record.paymentCompletedAt)} - - ) - })} - -
-
- -
- - 총 {recordPage?.totalElements ?? 0}건, 페이지 {recordPage ? recordPage.page + 1 : 1} / {recordPage?.totalPages ?? 1} - - -
- - -
-
+ String(row.memberRecordId)} + onRowClick={onSelectMember} + rowClassName={(row) => (selectedMemberId === row.memberRecordId ? "bg-accent" : undefined)} + /> ) diff --git a/src/page/payment/hooks/usePaymentPageState.ts b/src/page/payment/hooks/usePaymentPageState.ts index 915a221..7631d2c 100644 --- a/src/page/payment/hooks/usePaymentPageState.ts +++ b/src/page/payment/hooks/usePaymentPageState.ts @@ -1,5 +1,10 @@ import { useEffect, useState } from "react" +import type { PaginationState, SortingState, Updater } from "@tanstack/react-table" +import { functionalUpdate } from "@tanstack/react-table" + +import { createSortingState, normalizeSingleSorting, serializeSortingState } from "@/components/admin" +import type { ColumnSortMap } from "@/components/admin" import { getAdminPayments } from "@/api/payment/get-admin-payments" import { getAdminTransactions } from "@/api/payment/get-admin-transactions" import { patchForceCompletePayment } from "@/api/payment/patch-force-complete-payment" @@ -15,28 +20,6 @@ import { showConfirm, showError, showSuccess } from "@/utils/alert" export type YearSemesterFilter = "ALL" | string export type PaymentStatusFilter = "ALL" | PaymentStatus export type TransactionTypeFilter = "ALL" | TransactionType -export type PaymentSort = - | "id,asc" - | "id,desc" - | "memberName,asc" - | "memberName,desc" - | "status,asc" - | "status,desc" - | "finalPrice,asc" - | "finalPrice,desc" - | "createdAt,asc" - | "createdAt,desc" -export type TransactionSort = - | "id,asc" - | "id,desc" - | "transactionTime,asc" - | "transactionTime,desc" - | "depositorName,asc" - | "depositorName,desc" - | "amount,asc" - | "amount,desc" - | "balance,asc" - | "balance,desc" export interface YearSemesterOption { value: string @@ -46,10 +29,10 @@ export interface YearSemesterOption { export interface PaymentSectionState { isLoading: boolean data: AdminPaymentPage | null - page: number + pagination: PaginationState yearSemester: YearSemesterFilter status: PaymentStatusFilter - sort: PaymentSort + sorting: SortingState memberKeyword: string forceCompletingPaymentId: number | null } @@ -58,19 +41,19 @@ export interface PaymentSectionActions { setYearSemester: (value: YearSemesterFilter) => void setStatus: (value: PaymentStatusFilter) => void setMemberKeyword: (value: string) => void - setSort: (value: PaymentSort) => Promise + onSortingChange: (updater: Updater) => void + onPaginationChange: (updater: Updater) => void search: () => Promise - movePage: (nextPage: number) => Promise forceComplete: (paymentId: number) => Promise } export interface TransactionSectionState { isLoading: boolean data: AdminTransactionPage | null - page: number + pagination: PaginationState yearSemester: YearSemesterFilter type: TransactionTypeFilter - sort: TransactionSort + sorting: SortingState depositorKeyword: string from: string to: string @@ -80,11 +63,11 @@ export interface TransactionSectionActions { setYearSemester: (value: YearSemesterFilter) => void setType: (value: TransactionTypeFilter) => void setDepositorKeyword: (value: string) => void - setSort: (value: TransactionSort) => Promise + onSortingChange: (updater: Updater) => void + onPaginationChange: (updater: Updater) => void setFrom: (value: string) => void setTo: (value: string) => void search: () => Promise - movePage: (nextPage: number) => Promise } interface UsePaymentPageStateResult { @@ -95,6 +78,27 @@ interface UsePaymentPageStateResult { transactionActions: TransactionSectionActions } +const PAGE_SIZE = 50 + +const PAYMENT_SORT_MAP: ColumnSortMap = { + id: "id", + memberName: "memberName", + status: "status", + finalPrice: "finalPrice", + createdAt: "createdAt", +} + +const TRANSACTION_SORT_MAP: ColumnSortMap = { + transactionTime: "transactionTime", + id: "id", + depositorName: "depositorName", + amount: "amount", + balance: "balance", +} + +const DEFAULT_PAYMENT_SORTING = createSortingState("id", true) +const DEFAULT_TRANSACTION_SORTING = createSortingState("transactionTime", true) + const YEAR_SEMESTER_OPTIONS: YearSemesterOption[] = [ { value: "YEAR_SEMESTER_2025_1", label: "2025-1" }, { value: "YEAR_SEMESTER_2025_2", label: "2025-2" }, @@ -113,47 +117,63 @@ const resolvePaymentErrorMessage = (errorName?: string): string => { export function usePaymentPageState(): UsePaymentPageStateResult { const [isPaymentsLoading, setIsPaymentsLoading] = useState(false) const [paymentData, setPaymentData] = useState(null) - const [paymentPage, setPaymentPage] = useState(0) + const [paymentPagination, setPaymentPagination] = useState({ + pageIndex: 0, + pageSize: PAGE_SIZE, + }) const [paymentYearSemester, setPaymentYearSemester] = useState("ALL") const [paymentStatus, setPaymentStatus] = useState("ALL") - const [paymentSort, setPaymentSort] = useState("id,desc") + const [paymentSorting, setPaymentSorting] = useState(DEFAULT_PAYMENT_SORTING) const [paymentMemberKeyword, setPaymentMemberKeyword] = useState("") const [forceCompletingPaymentId, setForceCompletingPaymentId] = useState(null) const [isTransactionsLoading, setIsTransactionsLoading] = useState(false) const [transactionData, setTransactionData] = useState(null) - const [transactionPage, setTransactionPage] = useState(0) + const [transactionPagination, setTransactionPagination] = useState({ + pageIndex: 0, + pageSize: PAGE_SIZE, + }) const [transactionYearSemester, setTransactionYearSemester] = useState("ALL") const [transactionType, setTransactionType] = useState("ALL") - const [transactionSort, setTransactionSort] = useState("transactionTime,desc") + const [transactionSorting, setTransactionSorting] = useState(DEFAULT_TRANSACTION_SORTING) const [transactionDepositorKeyword, setTransactionDepositorKeyword] = useState("") const [transactionFrom, setTransactionFrom] = useState("") const [transactionTo, setTransactionTo] = useState("") const fetchPayments = async ( - page: number, + pagination: PaginationState, yearSemester: YearSemesterFilter, status: PaymentStatusFilter, - sort: PaymentSort, + sorting: SortingState, memberKeyword: string, ): Promise => { setIsPaymentsLoading(true) try { const response = await getAdminPayments({ - page, - size: 50, + page: pagination.pageIndex, + size: pagination.pageSize, yearSemester: yearSemester === "ALL" ? undefined : yearSemester, status: status === "ALL" ? undefined : status, - sort, + sort: serializeSortingState(sorting, PAYMENT_SORT_MAP, "id,desc"), memberKeyword, }) - if (!response.ok || !response.data) { + if (!response.ok) { showError(resolvePaymentErrorMessage(response.errorName)) return } + const data = response.data + if (!data) { + showError(resolvePaymentErrorMessage()) + return + } - setPaymentData(response.data) + setPaymentData(data) + setPaymentPagination((prev) => ({ + ...prev, + pageIndex: data.page, + pageSize: data.size, + })) } catch { showError(resolvePaymentErrorMessage()) } finally { @@ -162,10 +182,10 @@ export function usePaymentPageState(): UsePaymentPageStateResult { } const fetchTransactions = async ( - page: number, + pagination: PaginationState, yearSemester: YearSemesterFilter, type: TransactionTypeFilter, - sort: TransactionSort, + sorting: SortingState, depositorKeyword: string, from: string, to: string, @@ -173,22 +193,32 @@ export function usePaymentPageState(): UsePaymentPageStateResult { setIsTransactionsLoading(true) try { const response = await getAdminTransactions({ - page, - size: 50, + page: pagination.pageIndex, + size: pagination.pageSize, yearSemester: yearSemester === "ALL" ? undefined : yearSemester, transactionType: type === "ALL" ? undefined : type, - sort, + sort: serializeSortingState(sorting, TRANSACTION_SORT_MAP, "transactionTime,desc"), depositorKeyword, from: from || undefined, to: to || undefined, }) - if (!response.ok || !response.data) { + if (!response.ok) { showError(resolvePaymentErrorMessage(response.errorName)) return } + const data = response.data + if (!data) { + showError(resolvePaymentErrorMessage()) + return + } - setTransactionData(response.data) + setTransactionData(data) + setTransactionPagination((prev) => ({ + ...prev, + pageIndex: data.page, + pageSize: data.size, + })) } catch { showError(resolvePaymentErrorMessage()) } finally { @@ -197,12 +227,18 @@ export function usePaymentPageState(): UsePaymentPageStateResult { } useEffect(() => { - void fetchPayments(0, paymentYearSemester, paymentStatus, paymentSort, paymentMemberKeyword) + void fetchPayments( + paymentPagination, + paymentYearSemester, + paymentStatus, + paymentSorting, + paymentMemberKeyword, + ) void fetchTransactions( - 0, + transactionPagination, transactionYearSemester, transactionType, - transactionSort, + transactionSorting, transactionDepositorKeyword, transactionFrom, transactionTo, @@ -211,22 +247,57 @@ export function usePaymentPageState(): UsePaymentPageStateResult { }, []) const handlePaymentSearch = async (): Promise => { - setPaymentPage(0) - await fetchPayments(0, paymentYearSemester, paymentStatus, paymentSort, paymentMemberKeyword) + const nextPagination = { + ...paymentPagination, + pageIndex: 0, + } + setPaymentPagination(nextPagination) + await fetchPayments( + nextPagination, + paymentYearSemester, + paymentStatus, + paymentSorting, + paymentMemberKeyword, + ) } - const movePaymentPage = async (nextPage: number): Promise => { - if (nextPage < 0) { - return + const handlePaymentSortingChange = (updater: Updater): void => { + const nextSorting = normalizeSingleSorting(updater, paymentSorting, DEFAULT_PAYMENT_SORTING) + const nextPagination = { + ...paymentPagination, + pageIndex: 0, } - setPaymentPage(nextPage) - await fetchPayments(nextPage, paymentYearSemester, paymentStatus, paymentSort, paymentMemberKeyword) + + setPaymentSorting(nextSorting) + setPaymentPagination(nextPagination) + void fetchPayments( + nextPagination, + paymentYearSemester, + paymentStatus, + nextSorting, + paymentMemberKeyword, + ) } - const changePaymentSort = async (nextSort: PaymentSort): Promise => { - setPaymentSort(nextSort) - setPaymentPage(0) - await fetchPayments(0, paymentYearSemester, paymentStatus, nextSort, paymentMemberKeyword) + const handlePaymentPaginationChange = (updater: Updater): void => { + const nextPagination = functionalUpdate(updater, paymentPagination) + + if (nextPagination.pageIndex < 0) { + return + } + + if (paymentData && nextPagination.pageIndex >= paymentData.totalPages) { + return + } + + setPaymentPagination(nextPagination) + void fetchPayments( + nextPagination, + paymentYearSemester, + paymentStatus, + paymentSorting, + paymentMemberKeyword, + ) } const handleForceComplete = async (paymentId: number): Promise => { @@ -254,12 +325,18 @@ export function usePaymentPageState(): UsePaymentPageStateResult { showSuccess(`결제 #${response.data.paymentId}를 완료 처리했습니다.`) await Promise.all([ - fetchPayments(paymentPage, paymentYearSemester, paymentStatus, paymentSort, paymentMemberKeyword), + fetchPayments( + paymentPagination, + paymentYearSemester, + paymentStatus, + paymentSorting, + paymentMemberKeyword, + ), fetchTransactions( - transactionPage, + transactionPagination, transactionYearSemester, transactionType, - transactionSort, + transactionSorting, transactionDepositorKeyword, transactionFrom, transactionTo, @@ -278,42 +355,59 @@ export function usePaymentPageState(): UsePaymentPageStateResult { return } - setTransactionPage(0) + const nextPagination = { + ...transactionPagination, + pageIndex: 0, + } + setTransactionPagination(nextPagination) await fetchTransactions( - 0, + nextPagination, transactionYearSemester, transactionType, - transactionSort, + transactionSorting, transactionDepositorKeyword, transactionFrom, transactionTo, ) } - const moveTransactionPage = async (nextPage: number): Promise => { - if (nextPage < 0) { - return + const handleTransactionSortingChange = (updater: Updater): void => { + const nextSorting = normalizeSingleSorting(updater, transactionSorting, DEFAULT_TRANSACTION_SORTING) + const nextPagination = { + ...transactionPagination, + pageIndex: 0, } - setTransactionPage(nextPage) - await fetchTransactions( - nextPage, + + setTransactionSorting(nextSorting) + setTransactionPagination(nextPagination) + void fetchTransactions( + nextPagination, transactionYearSemester, transactionType, - transactionSort, + nextSorting, transactionDepositorKeyword, transactionFrom, transactionTo, ) } - const changeTransactionSort = async (nextSort: TransactionSort): Promise => { - setTransactionSort(nextSort) - setTransactionPage(0) - await fetchTransactions( - 0, + const handleTransactionPaginationChange = (updater: Updater): void => { + const nextPagination = functionalUpdate(updater, transactionPagination) + + if (nextPagination.pageIndex < 0) { + return + } + + if (transactionData && nextPagination.pageIndex >= transactionData.totalPages) { + return + } + + setTransactionPagination(nextPagination) + void fetchTransactions( + nextPagination, transactionYearSemester, transactionType, - nextSort, + transactionSorting, transactionDepositorKeyword, transactionFrom, transactionTo, @@ -325,10 +419,10 @@ export function usePaymentPageState(): UsePaymentPageStateResult { paymentState: { isLoading: isPaymentsLoading, data: paymentData, - page: paymentPage, + pagination: paymentPagination, yearSemester: paymentYearSemester, status: paymentStatus, - sort: paymentSort, + sorting: paymentSorting, memberKeyword: paymentMemberKeyword, forceCompletingPaymentId, }, @@ -336,18 +430,18 @@ export function usePaymentPageState(): UsePaymentPageStateResult { setYearSemester: (value) => setPaymentYearSemester(value), setStatus: (value) => setPaymentStatus(value), setMemberKeyword: (value) => setPaymentMemberKeyword(value), - setSort: changePaymentSort, + onSortingChange: handlePaymentSortingChange, + onPaginationChange: handlePaymentPaginationChange, search: handlePaymentSearch, - movePage: movePaymentPage, forceComplete: handleForceComplete, }, transactionState: { isLoading: isTransactionsLoading, data: transactionData, - page: transactionPage, + pagination: transactionPagination, yearSemester: transactionYearSemester, type: transactionType, - sort: transactionSort, + sorting: transactionSorting, depositorKeyword: transactionDepositorKeyword, from: transactionFrom, to: transactionTo, @@ -356,11 +450,11 @@ export function usePaymentPageState(): UsePaymentPageStateResult { setYearSemester: (value) => setTransactionYearSemester(value), setType: (value) => setTransactionType(value), setDepositorKeyword: (value) => setTransactionDepositorKeyword(value), - setSort: changeTransactionSort, + onSortingChange: handleTransactionSortingChange, + onPaginationChange: handleTransactionPaginationChange, setFrom: (value) => setTransactionFrom(value), setTo: (value) => setTransactionTo(value), search: handleTransactionSearch, - movePage: moveTransactionPage, }, } } diff --git a/src/page/payment/sections/PaymentManagementSection.tsx b/src/page/payment/sections/PaymentManagementSection.tsx index b0517c5..76af338 100644 --- a/src/page/payment/sections/PaymentManagementSection.tsx +++ b/src/page/payment/sections/PaymentManagementSection.tsx @@ -1,30 +1,15 @@ -import { - AdminFilterBar, - AdminSectionCard, - AdminSortableTableHead, - AdminTableEmptyRow, -} from "@/components/admin" +import { useMemo } from "react" + +import type { ColumnDef } from "@tanstack/react-table" + +import type { AdminPaymentItem } from "@/api/payment/types" +import { AdminDataTable, AdminFilterBar, AdminSectionCard } from "@/components/admin" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import type { PaymentSectionActions, - PaymentSort, PaymentSectionState, PaymentStatusFilter, YearSemesterOption, @@ -49,6 +34,77 @@ export const PaymentManagementSection: React.FC = state, actions, }) => { + const columns = useMemo[]>(() => { + return [ + { + id: "id", + accessorKey: "paymentId", + header: "ID", + enableSorting: true, + }, + { + id: "memberName", + accessorKey: "memberName", + header: "회원", + enableSorting: true, + }, + { + id: "studentId", + accessorKey: "studentId", + header: "학번", + cell: ({ row }) => row.original.studentId ?? "-", + }, + { + id: "yearSemester", + accessorKey: "yearSemester", + header: "학기", + }, + { + id: "status", + accessorKey: "status", + header: "상태", + enableSorting: true, + }, + { + id: "finalPrice", + accessorKey: "finalPrice", + header: "금액", + enableSorting: true, + meta: { + headerClassName: "text-right", + cellClassName: "text-right", + }, + }, + { + id: "createdAt", + accessorKey: "createdAt", + header: "생성일", + enableSorting: true, + cell: ({ row }) => formatDateTime(row.original.createdAt), + }, + { + id: "actions", + header: "동작", + cell: ({ row }) => { + const payment = row.original + + return ( + + ) + }, + }, + ] + }, [actions, state.forceCompletingPaymentId]) + return ( @@ -66,10 +122,7 @@ export const PaymentManagementSection: React.FC = - actions.setStatus(value as PaymentStatusFilter)}> @@ -91,107 +144,20 @@ export const PaymentManagementSection: React.FC = -
- - - - void actions.setSort(nextSort as PaymentSort)} - /> - void actions.setSort(nextSort as PaymentSort)} - /> - 학번 - 학기 - void actions.setSort(nextSort as PaymentSort)} - /> - void actions.setSort(nextSort as PaymentSort)} - className="text-right" - /> - void actions.setSort(nextSort as PaymentSort)} - /> - 동작 - - - - {state.data?.content.map((payment) => ( - - {payment.paymentId} - {payment.memberName} - {payment.studentId ?? "-"} - {payment.yearSemester} - {payment.status} - {payment.finalPrice} - {formatDateTime(payment.createdAt)} - - - - - ))} - - {(state.data?.content.length ?? 0) === 0 && ( - - )} - -
-
- -
- - 총 {state.data?.totalElements ?? 0}건 / {state.data ? state.data.page + 1 : 1}페이지 - -
- - -
-
+ String(row.paymentId)} + />
) } diff --git a/src/page/payment/sections/TransactionManagementSection.tsx b/src/page/payment/sections/TransactionManagementSection.tsx index 37215fa..f19fb2b 100644 --- a/src/page/payment/sections/TransactionManagementSection.tsx +++ b/src/page/payment/sections/TransactionManagementSection.tsx @@ -1,30 +1,15 @@ -import { - AdminFilterBar, - AdminSectionCard, - AdminSortableTableHead, - AdminTableEmptyRow, -} from "@/components/admin" +import { useMemo } from "react" + +import type { ColumnDef } from "@tanstack/react-table" + +import type { AdminTransactionItem } from "@/api/payment/types" +import { AdminDataTable, AdminFilterBar, AdminSectionCard } from "@/components/admin" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import type { TransactionSectionActions, - TransactionSort, TransactionSectionState, TransactionTypeFilter, YearSemesterOption, @@ -49,6 +34,62 @@ export const TransactionManagementSection: React.FC { + const columns = useMemo[]>(() => { + return [ + { + id: "transactionTime", + accessorKey: "transactionTime", + header: "시간", + enableSorting: true, + sortDescFirst: true, + cell: ({ row }) => formatDateTime(row.original.transactionTime), + }, + { + id: "id", + accessorKey: "transactionId", + header: "ID", + enableSorting: true, + }, + { + id: "yearSemester", + accessorKey: "yearSemester", + header: "학기", + }, + { + id: "depositorName", + accessorKey: "depositorName", + header: "입금자", + enableSorting: true, + }, + { + id: "transactionType", + accessorKey: "transactionType", + header: "유형", + cell: ({ row }) => (row.original.transactionType === "DEPOSIT" ? "입금" : "출금"), + }, + { + id: "amount", + accessorKey: "amount", + header: "금액", + enableSorting: true, + meta: { + headerClassName: "text-right", + cellClassName: "text-right", + }, + }, + { + id: "balance", + accessorKey: "balance", + header: "잔액", + enableSorting: true, + meta: { + headerClassName: "text-right", + cellClassName: "text-right", + }, + }, + ] + }, []) + return ( @@ -66,10 +107,7 @@ export const TransactionManagementSection: React.FC - actions.setType(value as TransactionTypeFilter)}> @@ -94,94 +132,20 @@ export const TransactionManagementSection: React.FC -
- - - - void actions.setSort(nextSort as TransactionSort)} - /> - void actions.setSort(nextSort as TransactionSort)} - /> - 학기 - void actions.setSort(nextSort as TransactionSort)} - /> - 유형 - void actions.setSort(nextSort as TransactionSort)} - className="text-right" - /> - void actions.setSort(nextSort as TransactionSort)} - className="text-right" - /> - - - - {state.data?.content.map((transaction) => ( - - {formatDateTime(transaction.transactionTime)} - {transaction.transactionId} - {transaction.yearSemester} - {transaction.depositorName} - {transaction.transactionType === "DEPOSIT" ? "입금" : "출금"} - {transaction.amount} - {transaction.balance} - - ))} - - {(state.data?.content.length ?? 0) === 0 && ( - - )} - -
-
- -
- - 총 {state.data?.totalElements ?? 0}건 / {state.data ? state.data.page + 1 : 1}페이지 - -
- - -
-
+ String(row.transactionId)} + />
) } diff --git a/src/page/point.tsx b/src/page/point.tsx index c10cde9..4363ef2 100644 --- a/src/page/point.tsx +++ b/src/page/point.tsx @@ -14,19 +14,19 @@ const PointPage: React.FC = () => { diff --git a/src/page/point/hooks/usePointPageState.ts b/src/page/point/hooks/usePointPageState.ts index 82019a1..6a0fc30 100644 --- a/src/page/point/hooks/usePointPageState.ts +++ b/src/page/point/hooks/usePointPageState.ts @@ -1,5 +1,10 @@ import { useEffect, useMemo, useState } from "react" +import type { PaginationState, SortingState, Updater } from "@tanstack/react-table" +import { functionalUpdate } from "@tanstack/react-table" + +import { createSortingState, normalizeSingleSorting, serializeSortingState } from "@/components/admin" +import type { ColumnSortMap } from "@/components/admin" import { getPointLedger } from "@/api/point/get-point-ledger" import { getPointMember } from "@/api/point/get-point-member" import { postPointBatchGrant } from "@/api/point/post-point-batch-grant" @@ -16,17 +21,18 @@ import { resolveAdminErrorMessage } from "@/lib/errors/admin-error" import { showConfirm, showError, showSuccess } from "@/utils/alert" export type PointTransactionFilter = "ALL" | PointTransactionType -export type PointLedgerSort = - | "id,asc" - | "id,desc" - | "createdAt,asc" - | "createdAt,desc" - | "memberName,asc" - | "memberName,desc" - | "transactionType,asc" - | "transactionType,desc" - | "amount,asc" - | "amount,desc" + +const PAGE_SIZE = 50 + +const LEDGER_SORT_MAP: ColumnSortMap = { + id: "id", + createdAt: "createdAt", + memberName: "memberName", + transactionType: "transactionType", + amount: "amount", +} + +const DEFAULT_LEDGER_SORTING = createSortingState("id", true) const pointErrorOverrides: Record = { POINT_ACCOUNT_NOT_FOUND: "포인트 계정을 찾을 수 없습니다.", @@ -60,10 +66,13 @@ const createRequestId = (): string => { export const usePointPageState = () => { const [isLedgerLoading, setIsLedgerLoading] = useState(false) const [ledgerData, setLedgerData] = useState(null) - const [ledgerPage, setLedgerPage] = useState(0) + const [ledgerPagination, setLedgerPagination] = useState({ + pageIndex: 0, + pageSize: PAGE_SIZE, + }) const [ledgerMemberKeyword, setLedgerMemberKeyword] = useState("") const [ledgerTransactionType, setLedgerTransactionType] = useState("ALL") - const [ledgerSort, setLedgerSort] = useState("id,desc") + const [ledgerSorting, setLedgerSorting] = useState(DEFAULT_LEDGER_SORTING) const [ledgerFrom, setLedgerFrom] = useState("") const [ledgerTo, setLedgerTo] = useState("") @@ -88,31 +97,43 @@ export const usePointPageState = () => { const [batchResult, setBatchResult] = useState(null) const fetchLedger = async ( - page: number, + pagination: PaginationState, memberKeyword: string, transactionType: PointTransactionFilter, - sort: PointLedgerSort, + sorting: SortingState, from: string, to: string, ): Promise => { setIsLedgerLoading(true) + const response = await getPointLedger({ - page, - size: 50, + page: pagination.pageIndex, + size: pagination.pageSize, memberKeyword, transactionType: transactionType === "ALL" ? undefined : transactionType, - sort, + sort: serializeSortingState(sorting, LEDGER_SORT_MAP, "id,desc"), from: from || undefined, to: to || undefined, }) - if (!response.ok || !response.data) { + if (!response.ok) { showError(resolvePointErrorMessage(response.errorName)) setIsLedgerLoading(false) return } + const data = response.data + if (!data) { + showError(resolvePointErrorMessage()) + setIsLedgerLoading(false) + return + } - setLedgerData(response.data) + setLedgerData(data) + setLedgerPagination((prev) => ({ + ...prev, + pageIndex: data.page, + pageSize: data.size, + })) setIsLedgerLoading(false) } @@ -132,7 +153,14 @@ export const usePointPageState = () => { } useEffect(() => { - void fetchLedger(0, ledgerMemberKeyword, ledgerTransactionType, ledgerSort, ledgerFrom, ledgerTo) + void fetchLedger( + ledgerPagination, + ledgerMemberKeyword, + ledgerTransactionType, + ledgerSorting, + ledgerFrom, + ledgerTo, + ) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -149,22 +177,62 @@ export const usePointPageState = () => { showError("조회 시작일은 종료일보다 늦을 수 없습니다.") return } - setLedgerPage(0) - await fetchLedger(0, ledgerMemberKeyword, ledgerTransactionType, ledgerSort, ledgerFrom, ledgerTo) + + const nextPagination = { + ...ledgerPagination, + pageIndex: 0, + } + + setLedgerPagination(nextPagination) + await fetchLedger( + nextPagination, + ledgerMemberKeyword, + ledgerTransactionType, + ledgerSorting, + ledgerFrom, + ledgerTo, + ) } - const moveLedgerPage = async (nextPage: number): Promise => { - if (nextPage < 0) { - return + const handleLedgerSortingChange = (updater: Updater): void => { + const nextSorting = normalizeSingleSorting(updater, ledgerSorting, DEFAULT_LEDGER_SORTING) + const nextPagination = { + ...ledgerPagination, + pageIndex: 0, } - setLedgerPage(nextPage) - await fetchLedger(nextPage, ledgerMemberKeyword, ledgerTransactionType, ledgerSort, ledgerFrom, ledgerTo) + + setLedgerSorting(nextSorting) + setLedgerPagination(nextPagination) + void fetchLedger( + nextPagination, + ledgerMemberKeyword, + ledgerTransactionType, + nextSorting, + ledgerFrom, + ledgerTo, + ) } - const changeLedgerSort = async (nextSort: PointLedgerSort): Promise => { - setLedgerSort(nextSort) - setLedgerPage(0) - await fetchLedger(0, ledgerMemberKeyword, ledgerTransactionType, nextSort, ledgerFrom, ledgerTo) + const handleLedgerPaginationChange = (updater: Updater): void => { + const nextPagination = functionalUpdate(updater, ledgerPagination) + + if (nextPagination.pageIndex < 0) { + return + } + + if (ledgerData && nextPagination.pageIndex >= ledgerData.totalPages) { + return + } + + setLedgerPagination(nextPagination) + void fetchLedger( + nextPagination, + ledgerMemberKeyword, + ledgerTransactionType, + ledgerSorting, + ledgerFrom, + ledgerTo, + ) } const handleSearchMembers = async (): Promise => { @@ -240,7 +308,14 @@ export const usePointPageState = () => { } setSingleAmount("") - await fetchLedger(ledgerPage, ledgerMemberKeyword, ledgerTransactionType, ledgerSort, ledgerFrom, ledgerTo) + await fetchLedger( + ledgerPagination, + ledgerMemberKeyword, + ledgerTransactionType, + ledgerSorting, + ledgerFrom, + ledgerTo, + ) if (selectedDetailMemberId !== null) { await fetchMemberPoint(selectedDetailMemberId) } @@ -290,7 +365,14 @@ export const usePointPageState = () => { ) setBatchAmount("") - await fetchLedger(ledgerPage, ledgerMemberKeyword, ledgerTransactionType, ledgerSort, ledgerFrom, ledgerTo) + await fetchLedger( + ledgerPagination, + ledgerMemberKeyword, + ledgerTransactionType, + ledgerSorting, + ledgerFrom, + ledgerTo, + ) if (selectedDetailMemberId !== null) { await fetchMemberPoint(selectedDetailMemberId) } @@ -302,19 +384,19 @@ export const usePointPageState = () => { isLedgerLoading, ledgerData, - ledgerPage, + ledgerPagination, ledgerMemberKeyword, setLedgerMemberKeyword, ledgerTransactionType, setLedgerTransactionType, - ledgerSort, - setLedgerSort: changeLedgerSort, + ledgerSorting, + onLedgerSortingChange: handleLedgerSortingChange, + onLedgerPaginationChange: handleLedgerPaginationChange, ledgerFrom, setLedgerFrom, ledgerTo, setLedgerTo, handleLedgerSearch, - moveLedgerPage, memberSearchKeyword, setMemberSearchKeyword, diff --git a/src/page/point/sections/PointLedgerSection.tsx b/src/page/point/sections/PointLedgerSection.tsx index 43d3b75..a8d3062 100644 --- a/src/page/point/sections/PointLedgerSection.tsx +++ b/src/page/point/sections/PointLedgerSection.tsx @@ -1,50 +1,100 @@ -import type { AdminPointLedgerPage } from "@/api/point/types" -import { AdminSortableTableHead } from "@/components/admin" +import { useMemo } from "react" + +import type { ColumnDef, PaginationState, SortingState, Updater } from "@tanstack/react-table" + +import type { AdminPointLedgerItem, AdminPointLedgerPage } from "@/api/point/types" +import { AdminDataTable } from "@/components/admin" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import type { PointLedgerSort, PointTransactionFilter } from "../hooks/usePointPageState" +import type { PointTransactionFilter } from "../hooks/usePointPageState" type PointLedgerSectionProps = { isLedgerLoading: boolean ledgerData: AdminPointLedgerPage | null - ledgerPage: number + ledgerPagination: PaginationState ledgerMemberKeyword: string ledgerTransactionType: PointTransactionFilter - ledgerSort: PointLedgerSort + ledgerSorting: SortingState ledgerFrom: string ledgerTo: string onLedgerMemberKeywordChange: (value: string) => void onLedgerTransactionTypeChange: (value: PointTransactionFilter) => void - onLedgerSortChange: (value: PointLedgerSort) => Promise + onLedgerSortingChange: (updater: Updater) => void + onLedgerPaginationChange: (updater: Updater) => void onLedgerFromChange: (value: string) => void onLedgerToChange: (value: string) => void onLedgerSearch: () => Promise - onMoveLedgerPage: (nextPage: number) => Promise formatDateTime: (value: string) => string } export const PointLedgerSection: React.FC = ({ isLedgerLoading, ledgerData, - ledgerPage, + ledgerPagination, ledgerMemberKeyword, ledgerTransactionType, - ledgerSort, + ledgerSorting, ledgerFrom, ledgerTo, onLedgerMemberKeywordChange, onLedgerTransactionTypeChange, - onLedgerSortChange, + onLedgerSortingChange, + onLedgerPaginationChange, onLedgerFromChange, onLedgerToChange, onLedgerSearch, - onMoveLedgerPage, formatDateTime, }) => { + const columns = useMemo[]>(() => { + return [ + { + id: "createdAt", + accessorKey: "createdAt", + header: "시간", + enableSorting: true, + sortDescFirst: true, + cell: ({ row }) => formatDateTime(row.original.createdAt), + }, + { + id: "memberName", + accessorKey: "memberName", + header: "회원", + enableSorting: true, + }, + { + id: "studentId", + accessorKey: "studentId", + header: "학번", + cell: ({ row }) => row.original.studentId ?? "-", + }, + { + id: "transactionType", + accessorKey: "transactionType", + header: "유형", + enableSorting: true, + cell: ({ row }) => (row.original.transactionType === "EARN" ? "적립" : "차감"), + }, + { + id: "amount", + accessorKey: "amount", + header: "금액", + enableSorting: true, + meta: { + headerClassName: "text-right", + cellClassName: "text-right", + }, + }, + { + id: "reason", + accessorKey: "reason", + header: "사유", + }, + ] + }, [formatDateTime]) + return ( @@ -79,85 +129,20 @@ export const PointLedgerSection: React.FC = ({ -
- - - - void onLedgerSortChange(nextSort as PointLedgerSort)} - /> - void onLedgerSortChange(nextSort as PointLedgerSort)} - /> - 학번 - void onLedgerSortChange(nextSort as PointLedgerSort)} - /> - void onLedgerSortChange(nextSort as PointLedgerSort)} - className="text-right" - /> - 사유 - - - - {ledgerData?.content.map((row) => ( - - {formatDateTime(row.createdAt)} - {row.memberName} - {row.studentId ?? "-"} - {row.transactionType === "EARN" ? "적립" : "차감"} - {row.amount} - {row.reason} - - ))} - - {(ledgerData?.content.length ?? 0) === 0 && ( - - - {isLedgerLoading ? "원장을 불러오는 중..." : "조회 결과가 없습니다."} - - - )} - -
-
- -
- - 총 {ledgerData?.totalElements ?? 0}건 / {ledgerData ? ledgerData.page + 1 : 1}페이지 - -
- - -
-
+ String(row.pointTransactionId)} + />
)