최초커밋
This commit is contained in:
280
frontend/components/common/DataTable.tsx
Normal file
280
frontend/components/common/DataTable.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
ColumnFiltersState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { ChevronDown, ChevronUp, Search, Download, Filter } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
showExport?: boolean;
|
||||
showFilter?: boolean;
|
||||
onExport?: () => void;
|
||||
onRowClick?: (row: TData) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
searchable = true,
|
||||
searchPlaceholder = "검색...",
|
||||
showExport = false,
|
||||
showFilter = false,
|
||||
onExport,
|
||||
onRowClick,
|
||||
className,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
||||
const [globalFilter, setGlobalFilter] = React.useState("");
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* 테이블 상단 툴바 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{searchable && (
|
||||
<div className="relative">
|
||||
<Search className="absolute top-2.5 left-2.5 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={globalFilter ?? ""}
|
||||
onChange={(event) => setGlobalFilter(event.target.value)}
|
||||
className="w-64 pl-8"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFilter && (
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
필터
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{showExport && (
|
||||
<Button variant="outline" size="sm" onClick={onExport}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
내보내기
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} className="bg-slate-50">
|
||||
{header.isPlaceholder ? null : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center space-x-2",
|
||||
header.column.getCanSort() ? "cursor-pointer select-none" : "",
|
||||
)}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
<span>{flexRender(header.column.columnDef.header, header.getContext())}</span>
|
||||
{header.column.getCanSort() && (
|
||||
<div className="flex flex-col">
|
||||
{header.column.getIsSorted() === "desc" ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : header.column.getIsSorted() === "asc" ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<div className="h-4 w-4 opacity-50">
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={cn(onRowClick ? "cursor-pointer hover:bg-slate-50" : "")}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex-1 text-sm text-slate-600">
|
||||
{table.getFilteredSelectedRowModel().rows.length}개 중 {table.getFilteredRowModel().rows.length}개 표시
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium">페이지당 행 수</p>
|
||||
<select
|
||||
className="h-8 w-16 rounded border border-slate-300 text-sm"
|
||||
value={table.getState().pagination.pageSize}
|
||||
onChange={(e) => {
|
||||
table.setPageSize(Number(e.target.value));
|
||||
}}
|
||||
>
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<option key={pageSize} value={pageSize}>
|
||||
{pageSize}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex w-24 items-center justify-center text-sm font-medium">
|
||||
페이지 {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">첫 페이지</span>
|
||||
{"<<"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">이전 페이지</span>
|
||||
{"<"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">다음 페이지</span>
|
||||
{">"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">마지막 페이지</span>
|
||||
{">>"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 컬럼 정의 헬퍼 함수들
|
||||
export const createTextColumn = (accessorKey: string, header: string) => ({
|
||||
accessorKey,
|
||||
header,
|
||||
cell: ({ row }: any) => <div className="text-left">{row.getValue(accessorKey)}</div>,
|
||||
});
|
||||
|
||||
export const createDateColumn = (accessorKey: string, header: string) => ({
|
||||
accessorKey,
|
||||
header,
|
||||
cell: ({ row }: any) => {
|
||||
const date = row.getValue(accessorKey);
|
||||
return <div className="text-left">{date ? new Date(date).toLocaleDateString("ko-KR") : "-"}</div>;
|
||||
},
|
||||
});
|
||||
|
||||
export const createStatusColumn = (accessorKey: string, header: string) => ({
|
||||
accessorKey,
|
||||
header,
|
||||
cell: ({ row }: any) => {
|
||||
const status = row.getValue(accessorKey);
|
||||
return (
|
||||
<div className="flex w-16">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2 py-1 text-xs font-medium",
|
||||
status === "active" || status === "활성"
|
||||
? "bg-green-50 text-green-700"
|
||||
: status === "inactive" || status === "비활성"
|
||||
? "bg-gray-50 text-gray-700"
|
||||
: status === "pending" || status === "대기"
|
||||
? "bg-yellow-50 text-yellow-700"
|
||||
: "bg-red-50 text-red-700",
|
||||
)}
|
||||
>
|
||||
{status || "-"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const createActionColumn = (actions: React.ReactNode) => ({
|
||||
id: "actions",
|
||||
header: "작업",
|
||||
cell: () => actions,
|
||||
});
|
||||
43
frontend/components/common/LanguageSelector.tsx
Normal file
43
frontend/components/common/LanguageSelector.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||
|
||||
interface LanguageSelectorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LanguageSelector({ className }: LanguageSelectorProps) {
|
||||
const { userLang, changeLang } = useMultiLang();
|
||||
|
||||
const languages = [
|
||||
{ code: "KR", name: "한국어", flag: "🇰🇷" },
|
||||
{ code: "US", name: "English", flag: "🇺🇸" },
|
||||
{ code: "JP", name: "日本語", flag: "🇯🇵" },
|
||||
{ code: "CN", name: "中文", flag: "🇨🇳" },
|
||||
];
|
||||
|
||||
const handleLanguageChange = (newLang: string) => {
|
||||
changeLang(newLang);
|
||||
// 언어 변경 시 메뉴 컨텍스트가 자동으로 새로고침됨
|
||||
console.log("언어 변경됨:", newLang);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select value={userLang} onValueChange={handleLanguageChange}>
|
||||
<SelectTrigger className={`${className}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
<span className="flex items-center space-x-2">
|
||||
<span>{lang.flag}</span>
|
||||
<span>{lang.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
54
frontend/components/common/LoadingSpinner.tsx
Normal file
54
frontend/components/common/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ size = "md", className, text = "로딩 중..." }: LoadingSpinnerProps) {
|
||||
const sizeClasses = {
|
||||
sm: "h-4 w-4",
|
||||
md: "h-6 w-6",
|
||||
lg: "h-8 w-8",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center", className)}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className={cn("animate-spin", sizeClasses[size])} />
|
||||
{text && <span className="text-sm text-slate-600">{text}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 페이지 전체 로딩 오버레이
|
||||
export function LoadingOverlay({
|
||||
isLoading,
|
||||
text = "로딩 중...",
|
||||
children,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
text?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (!isLoading) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none opacity-50">{children}</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50 backdrop-blur-sm">
|
||||
<LoadingSpinner size="lg" text={text} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 인라인 로딩 스피너 (버튼 내부 등에서 사용)
|
||||
export function InlineSpinner({ className }: { className?: string }) {
|
||||
return <Loader2 className={cn("h-4 w-4 animate-spin", className)} />;
|
||||
}
|
||||
58
frontend/components/common/PageHeader.tsx
Normal file
58
frontend/components/common/PageHeader.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, HelpCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
showBackButton?: boolean;
|
||||
onBack?: () => void;
|
||||
showHelpButton?: boolean;
|
||||
onHelp?: () => void;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
showBackButton = false,
|
||||
onBack,
|
||||
showHelpButton = false,
|
||||
onHelp,
|
||||
children,
|
||||
className,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between border-b border-slate-200 pb-6", className)}>
|
||||
<div className="flex items-center space-x-4">
|
||||
{showBackButton && (
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="h-8 w-8 p-0">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-sm text-slate-600">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{children}
|
||||
{showHelpButton && (
|
||||
<Button variant="outline" size="sm" onClick={onHelp} className="h-8 w-8 p-0">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 페이지 헤더 액션 버튼 컴포넌트
|
||||
export function PageHeaderActions({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return <div className={cn("flex items-center space-x-2", className)}>{children}</div>;
|
||||
}
|
||||
226
frontend/components/common/Pagination.tsx
Normal file
226
frontend/components/common/Pagination.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
"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);
|
||||
}
|
||||
};
|
||||
|
||||
if (totalPages <= 1) {
|
||||
return null; // 페이지가 1개 이하면 렌더링하지 않음
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between", className)}>
|
||||
{/* 페이지 정보 */}
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<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 gap-4 sm:flex-row sm:items-center">
|
||||
{/* 페이지 크기 선택 */}
|
||||
{showPageSizeSelector && onPageSizeChange && (
|
||||
<div className="flex items-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 gap-1">
|
||||
{/* 첫 페이지로 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="h-9 w-9 p-0"
|
||||
title="첫 페이지"
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* 이전 페이지 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="h-9 w-9 p-0"
|
||||
title="이전 페이지"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* 페이지 번호들 */}
|
||||
{pageNumbers.map((page, index) => (
|
||||
<div key={index}>
|
||||
{page === "..." ? (
|
||||
<span className="text-muted-foreground flex h-9 w-9 items-center justify-center text-sm">...</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={page === currentPage ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(page as number)}
|
||||
className="h-9 w-9 p-0"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 다음 페이지 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="h-9 w-9 p-0"
|
||||
title="다음 페이지"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* 마지막 페이지로 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="h-9 w-9 p-0"
|
||||
title="마지막 페이지"
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user