최초커밋
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,
|
||||
});
|
||||
Reference in New Issue
Block a user