/** * DataTable Component * * 범용 데이터 테이블 컴포넌트 * - 검색/필터링 * - 정렬 * - 페이지네이션 * - 행 선택 * - 반응형 (데스크톱: 테이블, 모바일: 카드) * * @example * */ 'use client'; import { useState, useMemo, useCallback } from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { Button } from '@/components/ui/button'; import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'; import { cn } from '@/lib/utils'; import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; import { Pagination } from './Pagination'; import { TabFilter } from './TabFilter'; import { SearchFilter } from './SearchFilter'; import type { DataTableProps, BaseDataItem, SortState, ColumnDef, } from './types'; export function DataTable({ data, columns, loading = false, // 검색/필터 search, tabFilter, defaultFilterValue = 'all', // 선택 selection, onSelectionChange, // 페이지네이션 pagination = { pageSize: 20 }, // 정렬 defaultSort, onSortChange, // 액션 rowActions = [], bulkActions = [], // 스타일 striped = false, hoverable = true, // 빈 상태 emptyState, // 기타 onRowClick, getRowKey = (row) => row.id, }: DataTableProps) { // 상태 const [searchTerm, setSearchTerm] = useState(''); const [filterValue, setFilterValue] = useState(defaultFilterValue); const [selectedIds, setSelectedIds] = useState>(new Set()); const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(pagination.pageSize); const [sort, setSort] = useState(defaultSort || { column: null, direction: null }); // 검색/필터/정렬 적용된 데이터 const processedData = useMemo(() => { let result = [...data]; // 탭 필터 적용 if (tabFilter && filterValue !== 'all') { result = result.filter((item) => { const value = item[tabFilter.key]; return value === filterValue; }); } // 검색 적용 if (search && searchTerm) { const searchLower = searchTerm.toLowerCase(); const searchFields = search.searchFields || columns.map((c) => c.key); result = result.filter((item) => searchFields.some((field) => { const value = item[field]; if (value == null) return false; return String(value).toLowerCase().includes(searchLower); }) ); } // 정렬 적용 if (sort.column && sort.direction) { result.sort((a, b) => { const aVal = a[sort.column!]; const bVal = b[sort.column!]; if (aVal == null && bVal == null) return 0; if (aVal == null) return 1; if (bVal == null) return -1; let comparison = 0; if (typeof aVal === 'number' && typeof bVal === 'number') { comparison = aVal - bVal; } else { comparison = String(aVal).localeCompare(String(bVal)); } return sort.direction === 'desc' ? -comparison : comparison; }); } return result; }, [data, tabFilter, filterValue, search, searchTerm, sort, columns]); // 페이지네이션 적용 const totalPages = Math.ceil(processedData.length / pageSize); const startIndex = (currentPage - 1) * pageSize; const endIndex = startIndex + pageSize; const paginatedData = processedData.slice(startIndex, endIndex); // 전체 선택 상태 const selectAll = useMemo(() => { if (paginatedData.length === 0) return false; return paginatedData.every((row) => selectedIds.has(getRowKey(row))); }, [paginatedData, selectedIds, getRowKey]); // 핸들러 const handleSearchChange = useCallback((value: string) => { setSearchTerm(value); setCurrentPage(1); }, []); const handleFilterChange = useCallback((value: string) => { setFilterValue(value); setCurrentPage(1); }, []); const handleSort = useCallback((column: string) => { setSort((prev) => { let newDirection: SortState['direction'] = 'asc'; if (prev.column === column) { if (prev.direction === 'asc') newDirection = 'desc'; else if (prev.direction === 'desc') newDirection = null; } const newSort: SortState = { column: newDirection ? column : null, direction: newDirection, }; onSortChange?.(newSort); return newSort; }); }, [onSortChange]); const handleSelectAll = useCallback(() => { const newSelected = new Set(selectedIds); if (selectAll) { paginatedData.forEach((row) => newSelected.delete(getRowKey(row))); } else { paginatedData.forEach((row) => newSelected.add(getRowKey(row))); } setSelectedIds(newSelected); onSelectionChange?.(newSelected); }, [selectAll, paginatedData, selectedIds, getRowKey, onSelectionChange]); const handleSelectRow = useCallback((row: T) => { const key = getRowKey(row); const newSelected = new Set(selectedIds); if (selection?.single) { newSelected.clear(); if (!selectedIds.has(key)) { newSelected.add(key); } } else { if (newSelected.has(key)) { newSelected.delete(key); } else { newSelected.add(key); } } setSelectedIds(newSelected); onSelectionChange?.(newSelected); }, [selectedIds, selection, getRowKey, onSelectionChange]); const handlePageSizeChange = useCallback((size: number) => { setPageSize(size); setCurrentPage(1); }, []); // 정렬 아이콘 렌더링 const renderSortIcon = (column: ColumnDef) => { if (!column.sortable) return null; if (sort.column !== column.key) { return ; } if (sort.direction === 'asc') { return ; } if (sort.direction === 'desc') { return ; } return ; }; // 빈 상태 렌더링 const renderEmptyState = () => { const isFiltered = searchTerm || filterValue !== 'all'; const title = isFiltered ? emptyState?.filteredTitle || '검색 결과가 없습니다.' : emptyState?.title || '데이터가 없습니다.'; const description = isFiltered ? emptyState?.filteredDescription : emptyState?.description; return (
{emptyState?.icon && ( )}

{title}

{description &&

{description}

}
); }; // 선택된 항목들 const selectedItems = useMemo(() => { return data.filter((item) => selectedIds.has(getRowKey(item))); }, [data, selectedIds, getRowKey]); // 필터 옵션에 카운트 추가 const filterOptionsWithCount = useMemo(() => { if (!tabFilter) return []; return tabFilter.options.map((opt) => ({ ...opt, count: opt.value === 'all' ? data.length : data.filter((item) => item[tabFilter.key] === opt.value).length, })); }, [tabFilter, data]); return (
{/* 검색/필터 */} {(search || tabFilter) && ( )} {/* 벌크 액션 */} {bulkActions.length > 0 && selectedIds.size > 0 && ( {selectedIds.size}개 선택됨
{bulkActions .filter((action) => !action.minSelected || selectedIds.size >= action.minSelected) .map((action) => ( ))} )} {/* 메인 테이블 카드 */} {tabFilter ? `${filterOptionsWithCount.find((o) => o.value === filterValue)?.label || ''} 목록` : '전체 목록'}{' '} ({processedData.length}개) {/* 탭 필터 */} {tabFilter && (
)} {/* 로딩 상태 */} {loading ? ( ) : paginatedData.length === 0 ? ( renderEmptyState() ) : ( <> {/* 모바일 카드 뷰 */}
{paginatedData.map((row, index) => (
onRowClick?.(row)} >
{selection?.enabled && ( handleSelectRow(row)} onClick={(e) => e.stopPropagation()} /> )}
{columns .filter((col) => col.renderMobile !== null) .map((col) => { const value = row[col.key]; const rendered = col.renderMobile ? col.renderMobile(value, row, index) : col.render ? col.render(value, row, index) : value; if (rendered === null || rendered === undefined) return null; return (
{rendered as React.ReactNode}
); })}
{rowActions.length > 0 && (
{rowActions .filter((action) => !action.visible || action.visible(row)) .map((action) => ( ))}
)}
))}
{/* 데스크톱 테이블 */}
{selection?.enabled && ( {!selection.single && ( )} )} {columns .filter((col) => !col.hideOnMobile || !col.hideOnTablet) .map((col) => ( col.sortable && handleSort(col.key)} >
{col.header} {renderSortIcon(col)}
))} {rowActions.length > 0 && ( 작업 )}
{paginatedData.map((row, index) => ( onRowClick?.(row)} > {selection?.enabled && ( handleSelectRow(row)} onClick={(e) => e.stopPropagation()} /> )} {columns .filter((col) => !col.hideOnMobile || !col.hideOnTablet) .map((col) => { const value = row[col.key]; return ( {col.render ? col.render(value, row, index) : String(value ?? '-')} ); })} {rowActions.length > 0 && (
{rowActions .filter((action) => !action.visible || action.visible(row)) .map((action) => ( ))}
)}
))}
{/* 페이지네이션 */} )}
); } export default DataTable;