diff --git a/apps/admin/src/components/ApplicantPagination/getPaginationItems.test.ts b/apps/admin/src/components/ApplicantPagination/getPaginationItems.test.ts new file mode 100644 index 00000000..fb237f04 --- /dev/null +++ b/apps/admin/src/components/ApplicantPagination/getPaginationItems.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { getPaginationItems } from './getPaginationItems'; + +test('전체 1페이지면 1만 표시한다', () => { + assert.deepEqual(getPaginationItems(1, 1), [1]); +}); + +test('전체 9페이지 이하면 말줄임 없이 모두 표시한다', () => { + assert.deepEqual(getPaginationItems(5, 9), [1, 2, 3, 4, 5, 6, 7, 8, 9]); +}); + +test('앞쪽 페이지에서는 앞 7개와 마지막 페이지를 표시한다', () => { + for (const current of [1, 3, 5]) { + assert.deepEqual(getPaginationItems(current, 23), [1, 2, 3, 4, 5, 6, 7, 'ellipsis', 23]); + } +}); + +test('가운데 페이지에서는 현재 페이지 앞뒤 2개와 첫·끝 페이지를 표시한다', () => { + assert.deepEqual(getPaginationItems(6, 23), [1, 'ellipsis', 4, 5, 6, 7, 8, 'ellipsis', 23]); + assert.deepEqual(getPaginationItems(18, 23), [1, 'ellipsis', 16, 17, 18, 19, 20, 'ellipsis', 23]); +}); + +test('뒤쪽 페이지에서는 첫 페이지와 뒤 7개를 표시한다', () => { + for (const current of [19, 21, 23]) { + assert.deepEqual(getPaginationItems(current, 23), [1, 'ellipsis', 17, 18, 19, 20, 21, 22, 23]); + } +}); + +test('말줄임표가 페이지 하나만 가리지 않는다', () => { + for (let total = 10; total <= 30; total++) { + for (let current = 1; current <= total; current++) { + const items = getPaginationItems(current, total); + + items.forEach((item, index) => { + if (item !== 'ellipsis') return; + const prev = items[index - 1] as number; + const next = items[index + 1] as number; + assert.ok(next - prev > 2, `current=${current}, total=${total}: ${items.join(' ')}`); + }); + assert.equal(items.length, 9, `current=${current}, total=${total}`); + assert.ok(items.includes(current), `current=${current}, total=${total}`); + } + } +}); + +test('범위를 벗어난 현재 페이지는 첫·끝 페이지로 맞춘다', () => { + assert.deepEqual(getPaginationItems(0, 23), getPaginationItems(1, 23)); + assert.deepEqual(getPaginationItems(99, 23), getPaginationItems(23, 23)); +}); diff --git a/apps/admin/src/components/ApplicantPagination/getPaginationItems.ts b/apps/admin/src/components/ApplicantPagination/getPaginationItems.ts new file mode 100644 index 00000000..e1adf958 --- /dev/null +++ b/apps/admin/src/components/ApplicantPagination/getPaginationItems.ts @@ -0,0 +1,43 @@ +export type PaginationItemType = number | 'ellipsis'; + +/** 현재 페이지 앞뒤로 표시할 페이지 수 */ +const SIBLING_COUNT = 2; + +/** 첫·끝 페이지, 현재 페이지와 앞뒤 페이지, 말줄임표 2개를 합친 최대 항목 수 */ +const MAX_ITEM_COUNT = SIBLING_COUNT * 2 + 5; + +const range = (start: number, end: number) => + Array.from({ length: end - start + 1 }, (_, i) => start + i); + +/** + * 페이지네이션에 표시할 페이지 번호(1부터 시작)와 말줄임표 목록을 만듭니다. + * + * 첫·끝 페이지는 항상 표시하고 현재 페이지 앞뒤로 2개씩 표시합니다. + * 말줄임표가 페이지 하나만 가리게 되면 그 페이지 번호를 대신 표시해 항목 수를 일정하게 유지합니다. + */ +export const getPaginationItems = ( + currentPage: number, + totalPages: number, +): PaginationItemType[] => { + if (totalPages <= MAX_ITEM_COUNT) return range(1, totalPages); + + // 말줄임표 없이 이어서 보여줄 앞·뒤 구간의 길이 (첫 또는 끝 페이지 포함) + const edgeLength = MAX_ITEM_COUNT - 2; + const current = Math.min(Math.max(currentPage, 1), totalPages); + + if (current <= edgeLength - SIBLING_COUNT) { + return [...range(1, edgeLength), 'ellipsis', totalPages]; + } + + if (current > totalPages - (edgeLength - SIBLING_COUNT)) { + return [1, 'ellipsis', ...range(totalPages - edgeLength + 1, totalPages)]; + } + + return [ + 1, + 'ellipsis', + ...range(current - SIBLING_COUNT, current + SIBLING_COUNT), + 'ellipsis', + totalPages, + ]; +}; diff --git a/apps/admin/src/components/ApplicantPagination/index.tsx b/apps/admin/src/components/ApplicantPagination/index.tsx new file mode 100644 index 00000000..5b138470 --- /dev/null +++ b/apps/admin/src/components/ApplicantPagination/index.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@repo/ui/shadcn'; +import { cn } from '@repo/utils'; + +import { getPaginationItems } from './getPaginationItems'; + +interface ApplicantPaginationProps { + /** 0부터 시작하는 현재 페이지 */ + page: number; + totalPages: number; + totalElements: number; + /** 0부터 시작하는 페이지의 목록 URL */ + getPageHref: (page: number) => string; + /** 0부터 시작하는 페이지로 이동 */ + onPageChange: (page: number) => void; +} + +const disabledStyle = ['pointer-events-none', 'opacity-50'] as const; + +const ApplicantPagination = ({ + page, + totalPages, + totalElements, + getPageHref, + onPageChange, +}: ApplicantPaginationProps) => { + const lastPage = totalPages - 1; + const isFirstPage = page <= 0; + const isLastPage = page >= lastPage; + + // href를 두어 키보드로 이동·선택하고 새 탭으로 열 수 있게 하되, 일반 클릭은 전체 새로고침 없이 처리합니다. + const handleLinkClick = (event: React.MouseEvent, nextPage: number) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) + return; + + event.preventDefault(); + onPageChange(nextPage); + }; + + return ( +
+ 총 {totalElements.toLocaleString()}명 + + + + !isFirstPage && handleLinkClick(event, page - 1)} + className={cn(isFirstPage && disabledStyle)} + /> + + {getPaginationItems(page + 1, totalPages).map((item, index) => + item === 'ellipsis' ? ( + + + + ) : ( + + handleLinkClick(event, item - 1)} + > + {item} + + + ), + )} + + !isLastPage && handleLinkClick(event, page + 1)} + className={cn(isLastPage && disabledStyle)} + /> + + + +
+ ); +}; + +export default ApplicantPagination; diff --git a/apps/admin/src/components/index.ts b/apps/admin/src/components/index.ts index 94901b38..fb80f75f 100644 --- a/apps/admin/src/components/index.ts +++ b/apps/admin/src/components/index.ts @@ -1,3 +1,4 @@ +export { default as ApplicantPagination } from './ApplicantPagination'; export { default as ApplicantTH } from './ApplicantTH'; export { default as ApplicantTR } from './ApplicantTR'; export { default as TextField } from './TextField'; diff --git a/apps/admin/src/pageContainer/MainPage/index.tsx b/apps/admin/src/pageContainer/MainPage/index.tsx index a6457692..a4dfd930 100644 --- a/apps/admin/src/pageContainer/MainPage/index.tsx +++ b/apps/admin/src/pageContainer/MainPage/index.tsx @@ -1,25 +1,15 @@ 'use client'; import { keepPreviousData } from '@tanstack/react-query'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; import { useGetEditability, useGetOneseoList } from '@repo/api/hooks'; import { OneseoListType } from '@repo/types'; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, - Table, - TableBody, - TableHeader, -} from '@repo/ui/shadcn'; +import { Table, TableBody, TableHeader } from '@repo/ui/shadcn'; import { cn } from '@repo/utils'; -import { ApplicantTH, ApplicantTR, FilterBar, SideMenu } from '@/components'; +import { ApplicantPagination, ApplicantTH, ApplicantTR, FilterBar, SideMenu } from '@/components'; import { SideBarType } from '@/schemas'; import { @@ -84,6 +74,7 @@ const MainPage = ({ const [isOpen, setIsOpen] = useState(true); const router = useRouter(); + const pathname = usePathname(); const searchParams = useSearchParams(); const query = parseOneseoListQuery(Object.fromEntries(searchParams)); const { testResultTag, screeningTag, isSubmitted, status: requested, page } = query; @@ -91,6 +82,7 @@ const MainPage = ({ // 입력창은 바로 반영하고, 디바운스된 검색어만 URL에 반영합니다. const [keywordInput, setKeywordInput] = useState(query.keyword); const keywordTimerRef = useRef>(undefined); + const tableContainerRef = useRef(null); // 화면을 떠난 뒤 남은 타이머가 다른 페이지의 URL을 바꾸지 않도록 정리합니다. useEffect(() => () => clearTimeout(keywordTimerRef.current), []); @@ -162,16 +154,13 @@ const MainPage = ({ const handlePageChange = (nextPage: number) => { replaceOneseoListQuery({ page: nextPage }); + tableContainerRef.current?.scrollTo({ top: 0 }); }; - const startPage = totalPages ? Math.max(0, Math.min(page, data.info.totalPages - 3)) : 0; - - const pageNumbers = totalPages - ? Array.from( - { length: Math.min(3, data.info.totalPages - startPage) }, - (_, i) => startPage + i + 1, - ) - : []; + const getPageHref = (targetPage: number) => { + const search = toOneseoListSearchString({ ...query, page: targetPage }); + return `${pathname}${search ? `?${search}` : ''}`; + }; return (
{totalPages ? ( - - - - handlePageChange(Math.max(page - 1, 0))} /> - - {pageNumbers.map((pageNumber: number) => ( - - handlePageChange(pageNumber - 1)} - > - {pageNumber} - - - ))} - - handlePageChange(Math.min(page + 1, data.info.totalPages - 1))} - /> - - - + ) : ( 조회된 지원자 데이터가 없습니다. )}