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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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));
});
Original file line number Diff line number Diff line change
@@ -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,
];
};
93 changes: 93 additions & 0 deletions apps/admin/src/components/ApplicantPagination/index.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLAnchorElement>, nextPage: number) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0)
return;

event.preventDefault();
onPageChange(nextPage);
};

return (
<div className={cn('flex', 'flex-col', 'items-center', 'gap-2')}>
<span className={cn('text-sm', 'text-gray-500')}>총 {totalElements.toLocaleString()}명</span>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={isFirstPage ? undefined : getPageHref(page - 1)}
aria-disabled={isFirstPage}
onClick={(event) => !isFirstPage && handleLinkClick(event, page - 1)}
className={cn(isFirstPage && disabledStyle)}
/>
</PaginationItem>
{getPaginationItems(page + 1, totalPages).map((item, index) =>
item === 'ellipsis' ? (
<PaginationItem key={`ellipsis-${index}`}>
<PaginationEllipsis className={cn('cursor-default')} />
</PaginationItem>
) : (
<PaginationItem key={item}>
<PaginationLink
href={getPageHref(item - 1)}
isActive={item - 1 === page}
onClick={(event) => handleLinkClick(event, item - 1)}
>
{item}
</PaginationLink>
</PaginationItem>
),
)}
<PaginationItem>
<PaginationNext
href={isLastPage ? undefined : getPageHref(page + 1)}
aria-disabled={isLastPage}
onClick={(event) => !isLastPage && handleLinkClick(event, page + 1)}
className={cn(isLastPage && disabledStyle)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
};

export default ApplicantPagination;
1 change: 1 addition & 0 deletions apps/admin/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
61 changes: 18 additions & 43 deletions apps/admin/src/pageContainer/MainPage/index.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -84,13 +74,15 @@ const MainPage = ({
const [isOpen, setIsOpen] = useState<boolean>(true);

const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const query = parseOneseoListQuery(Object.fromEntries(searchParams));
const { testResultTag, screeningTag, isSubmitted, status: requested, page } = query;

// 입력창은 바로 반영하고, 디바운스된 검색어만 URL에 반영합니다.
const [keywordInput, setKeywordInput] = useState<string>(query.keyword);
const keywordTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const tableContainerRef = useRef<HTMLDivElement>(null);

// 화면을 떠난 뒤 남은 타이머가 다른 페이지의 URL을 바꾸지 않도록 정리합니다.
useEffect(() => () => clearTimeout(keywordTimerRef.current), []);
Expand Down Expand Up @@ -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 (
<main
Expand Down Expand Up @@ -209,6 +198,7 @@ const MainPage = ({
isAfterSecondResults={isAfterSecondResults}
/>
<div
ref={tableContainerRef}
className={cn(
'border',
'border-solid',
Expand Down Expand Up @@ -243,28 +233,13 @@ const MainPage = ({
</div>

{totalPages ? (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious onClick={() => handlePageChange(Math.max(page - 1, 0))} />
</PaginationItem>
{pageNumbers.map((pageNumber: number) => (
<PaginationItem key={pageNumber}>
<PaginationLink
isActive={pageNumber - 1 === page}
onClick={() => handlePageChange(pageNumber - 1)}
>
{pageNumber}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() => handlePageChange(Math.min(page + 1, data.info.totalPages - 1))}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
<ApplicantPagination
page={page}
totalPages={totalPages}
totalElements={data.info.totalElements}
getPageHref={getPageHref}
onPageChange={handlePageChange}
/>
) : (
<span className={cn('text-red-500')}>조회된 지원자 데이터가 없습니다.</span>
)}
Expand Down
Loading