227 lines
6.8 KiB
TypeScript
227 lines
6.8 KiB
TypeScript
"use client";
|
|
|
|
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export interface PaginationInfo {
|
|
currentPage: number;
|
|
totalPages: number;
|
|
totalItems: number;
|
|
itemsPerPage: number;
|
|
startItem: number;
|
|
endItem: number;
|
|
}
|
|
|
|
export interface PaginationProps {
|
|
paginationInfo: PaginationInfo;
|
|
onPageChange: (page: number) => void;
|
|
onPageSizeChange?: (pageSize: number) => void;
|
|
showPageSizeSelector?: boolean;
|
|
pageSizeOptions?: number[];
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* 재사용 가능한 페이지네이션 컴포넌트
|
|
*
|
|
* @example
|
|
* <Pagination
|
|
* paginationInfo={{
|
|
* currentPage: 1,
|
|
* totalPages: 10,
|
|
* totalItems: 200,
|
|
* itemsPerPage: 20,
|
|
* startItem: 1,
|
|
* endItem: 20
|
|
* }}
|
|
* onPageChange={(page) => console.log('Page changed:', page)}
|
|
* onPageSizeChange={(size) => console.log('Page size changed:', size)}
|
|
* showPageSizeSelector={true}
|
|
* pageSizeOptions={[10, 20, 50, 100]}
|
|
* />
|
|
*/
|
|
export function Pagination({
|
|
paginationInfo,
|
|
onPageChange,
|
|
onPageSizeChange,
|
|
showPageSizeSelector = false,
|
|
pageSizeOptions = [10, 20, 50, 100],
|
|
className,
|
|
}: PaginationProps) {
|
|
const { currentPage, totalPages, totalItems, itemsPerPage, startItem, endItem } = paginationInfo;
|
|
|
|
// 페이지 버튼 범위 계산 (현재 페이지 기준으로 앞뒤 2개씩)
|
|
const getPageNumbers = () => {
|
|
const delta = 2;
|
|
const range = [];
|
|
const rangeWithDots = [];
|
|
|
|
// 시작과 끝 계산
|
|
let start = Math.max(1, currentPage - delta);
|
|
let end = Math.min(totalPages, currentPage + delta);
|
|
|
|
// 범위 조정
|
|
if (end - start < delta * 2) {
|
|
if (start === 1) {
|
|
end = Math.min(totalPages, start + delta * 2);
|
|
} else if (end === totalPages) {
|
|
start = Math.max(1, end - delta * 2);
|
|
}
|
|
}
|
|
|
|
// 페이지 번호 배열 생성
|
|
for (let i = start; i <= end; i++) {
|
|
range.push(i);
|
|
}
|
|
|
|
// 첫 페이지와 점 추가
|
|
if (start > 1) {
|
|
rangeWithDots.push(1);
|
|
if (start > 2) {
|
|
rangeWithDots.push("...");
|
|
}
|
|
}
|
|
|
|
// 중간 범위 추가
|
|
rangeWithDots.push(...range);
|
|
|
|
// 마지막 페이지와 점 추가
|
|
if (end < totalPages) {
|
|
if (end < totalPages - 1) {
|
|
rangeWithDots.push("...");
|
|
}
|
|
rangeWithDots.push(totalPages);
|
|
}
|
|
|
|
return rangeWithDots;
|
|
};
|
|
|
|
const pageNumbers = getPageNumbers();
|
|
|
|
// 페이지 변경 핸들러
|
|
const handlePageChange = (page: number) => {
|
|
if (page >= 1 && page <= totalPages && page !== currentPage) {
|
|
onPageChange(page);
|
|
}
|
|
};
|
|
|
|
// 페이지 크기 변경 핸들러
|
|
const handlePageSizeChange = (newPageSize: string) => {
|
|
const size = parseInt(newPageSize, 10);
|
|
if (onPageSizeChange && size !== itemsPerPage) {
|
|
onPageSizeChange(size);
|
|
}
|
|
};
|
|
|
|
// 항상 페이지네이션을 표시 (1페이지일 때도 표시)
|
|
|
|
return (
|
|
<div className={cn("flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between", className)}>
|
|
{/* 페이지 정보 */}
|
|
<div className="text-muted-foreground text-center text-sm lg:text-left">
|
|
<span className="font-medium">{startItem.toLocaleString()}</span>
|
|
{" - "}
|
|
<span className="font-medium">{endItem.toLocaleString()}</span>
|
|
{" / "}
|
|
<span className="font-medium">{totalItems.toLocaleString()}</span>개 항목
|
|
</div>
|
|
|
|
{/* 페이지네이션 컨트롤 */}
|
|
<div className="flex flex-col items-center gap-4 lg:flex-row">
|
|
{/* 페이지 크기 선택 */}
|
|
{showPageSizeSelector && onPageSizeChange && (
|
|
<div className="flex items-center justify-center gap-2">
|
|
<span className="text-muted-foreground text-sm">페이지당</span>
|
|
<Select value={itemsPerPage.toString()} onValueChange={handlePageSizeChange}>
|
|
<SelectTrigger className="w-20">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{pageSizeOptions.map((size) => (
|
|
<SelectItem key={size} value={size.toString()}>
|
|
{size}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<span className="text-muted-foreground text-sm">개</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 페이지 버튼들 */}
|
|
<div className="flex items-center justify-center gap-1">
|
|
{/* 첫 페이지로 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handlePageChange(1)}
|
|
disabled={currentPage === 1}
|
|
className="h-8 w-8 p-0 lg:h-9 lg:w-9"
|
|
title="첫 페이지"
|
|
>
|
|
<ChevronsLeft className="h-3 w-3 lg:h-4 lg:w-4" />
|
|
</Button>
|
|
|
|
{/* 이전 페이지 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
|
disabled={currentPage === 1}
|
|
className="h-8 w-8 p-0 lg:h-9 lg:w-9"
|
|
title="이전 페이지"
|
|
>
|
|
<ChevronLeft className="h-3 w-3 lg:h-4 lg:w-4" />
|
|
</Button>
|
|
|
|
{/* 페이지 번호들 */}
|
|
{pageNumbers.map((page, index) => (
|
|
<div key={index}>
|
|
{page === "..." ? (
|
|
<span className="text-muted-foreground flex h-8 w-8 items-center justify-center text-sm lg:h-9 lg:w-9">
|
|
...
|
|
</span>
|
|
) : (
|
|
<Button
|
|
variant={page === currentPage ? "default" : "ghost"}
|
|
size="sm"
|
|
onClick={() => handlePageChange(page as number)}
|
|
className="h-8 w-8 p-0 lg:h-9 lg:w-9"
|
|
>
|
|
{page}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{/* 다음 페이지 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
|
disabled={currentPage === totalPages}
|
|
className="h-8 w-8 p-0 lg:h-9 lg:w-9"
|
|
title="다음 페이지"
|
|
>
|
|
<ChevronRight className="h-3 w-3 lg:h-4 lg:w-4" />
|
|
</Button>
|
|
|
|
{/* 마지막 페이지로 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handlePageChange(totalPages)}
|
|
disabled={currentPage === totalPages}
|
|
className="h-8 w-8 p-0 lg:h-9 lg:w-9"
|
|
title="마지막 페이지"
|
|
>
|
|
<ChevronsRight className="h-3 w-3 lg:h-4 lg:w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|