281 lines
9.4 KiB
TypeScript
281 lines
9.4 KiB
TypeScript
"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-destructive/10 text-red-700",
|
|
)}
|
|
>
|
|
{status || "-"}
|
|
</span>
|
|
</div>
|
|
);
|
|
},
|
|
});
|
|
|
|
export const createActionColumn = (actions: React.ReactNode) => ({
|
|
id: "actions",
|
|
header: "작업",
|
|
cell: () => actions,
|
|
});
|