"use client"; /** * EDataTable — 직접 구현 페이지용 공통 데이터 테이블 컴포넌트 * * 프리셋 디자인 규격(Type A~F) 기반, shadcn/ui 위에 구축. * 기능: 정렬, 헤더 필터, 컬럼 드래그 이동, 인라인 편집, 체크박스, 페이지네이션 */ import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, horizontalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Input } from "@/components/ui/input"; import { Filter, Check, Search, X, Loader2, Inbox, GripVertical, ChevronLeft, ChevronRight, ChevronDown, ChevronsLeft, ChevronsRight, ArrowUp, ArrowDown, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { apiClient } from "@/lib/api/client"; // ─── 타입 ─── export interface EDataTableColumn { key: string; label: string; width?: string; minWidth?: string; align?: "left" | "center" | "right"; sortable?: boolean; filterable?: boolean; editable?: boolean; inputType?: "text" | "number" | "date" | "select"; selectOptions?: { value: string; label: string }[]; formatNumber?: boolean; truncate?: boolean; render?: (value: any, row: T, rowIndex: number) => React.ReactNode; } export interface SortState { key: string; direction: "asc" | "desc"; } export interface EDataTableProps = any> { columns: EDataTableColumn[]; data: T[]; rowKey?: (row: T) => string; loading?: boolean; emptyMessage?: string; emptyIcon?: React.ReactNode; selectedId?: string | null; onSelect?: (id: string | null) => void; showCheckbox?: boolean; checkedIds?: string[]; onCheckedChange?: (ids: string[]) => void; onRowClick?: (row: T, index: number) => void; onRowDoubleClick?: (row: T, index: number) => void; onCellEdit?: (rowId: string, columnKey: string, newValue: any, row: T) => void; tableName?: string; sort?: SortState | null; onSortChange?: (sort: SortState | null) => void; draggableColumns?: boolean; onColumnOrderChange?: (columns: EDataTableColumn[]) => void; columnOrderKey?: string; showRowNumber?: boolean; showPagination?: boolean; defaultPageSize?: number; className?: string; } // ─── 유틸 ─── const fmtNum = (val: any) => { if (val == null || val === "") return ""; const n = Number(String(val).replace(/,/g, "")); if (isNaN(n)) return String(val); return n.toLocaleString(); }; const getRowId = (row: any, rowKey?: (row: any) => string) => { if (rowKey) return rowKey(row); return row.id ?? row._id ?? ""; }; // ─── SortableHeaderCell ─── function SortableHeaderCell({ col, sortKey, sortDir, onSort, headerFilterValues, uniqueValues, onToggleFilter, onClearFilter, draggable, }: { col: EDataTableColumn; sortKey: string | null; sortDir: "asc" | "desc"; onSort: (key: string) => void; headerFilterValues: Set; uniqueValues: string[]; onToggleFilter: (colKey: string, value: string) => void; onClearFilter: (colKey: string) => void; draggable: boolean; }) { const [filterSearch, setFilterSearch] = useState(""); const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: col.key, disabled: !draggable }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; const isSorted = sortKey === col.key; const hasFilter = headerFilterValues.size > 0; const filteredUniqueValues = uniqueValues.filter( (v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase()) ); return (
{/* 드래그 핸들 */} {draggable && (
)} {/* 컬럼 라벨 + 정렬 */}
{ e.stopPropagation(); if (col.sortable !== false) onSort(col.key); }} > {col.label} {isSorted && ( sortDir === "asc" ? : )}
{/* 필터 아이콘 + Popover */} {col.filterable !== false && uniqueValues.length > 0 && ( e.stopPropagation()}>
필터: {col.label} {hasFilter && ( )}
setFilterSearch(e.target.value)} placeholder="검색..." className="h-7 text-xs pl-7" />
{filteredUniqueValues.slice(0, 100).map((val) => { const isSelected = headerFilterValues.has(val); return (
onToggleFilter(col.key, val)} >
{isSelected && }
{val || "(빈 값)"}
); })} {filteredUniqueValues.length > 100 && (
...외 {filteredUniqueValues.length - 100}개
)}
)}
); } // ─── EDataTable ─── export function EDataTable = any>({ columns: initialColumns, data, rowKey, loading = false, emptyMessage = "데이터가 없어요", emptyIcon, selectedId, onSelect, showCheckbox = false, checkedIds = [], onCheckedChange, onRowClick, onRowDoubleClick, onCellEdit, tableName, sort: externalSort, onSortChange, draggableColumns = true, onColumnOrderChange, columnOrderKey, showRowNumber = false, showPagination = true, defaultPageSize = 50, className, }: EDataTableProps) { const [columns, setColumns] = useState(initialColumns); useEffect(() => { setColumns(initialColumns); }, [initialColumns]); // 정렬 const [internalSort, setInternalSort] = useState(null); const sortState = externalSort !== undefined ? externalSort : internalSort; // 헤더 필터 const [headerFilters, setHeaderFilters] = useState>>({}); // 페이지네이션 const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(defaultPageSize); const [pageSizeInput, setPageSizeInput] = useState(String(defaultPageSize)); // 그룹 접기/펼치기 const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); const toggleGroup = (groupValue: string) => { setCollapsedGroups((prev) => { const next = new Set(prev); if (next.has(groupValue)) next.delete(groupValue); else next.add(groupValue); return next; }); }; // 인라인 편집 const [editingCell, setEditingCell] = useState<{ rowId: string; colKey: string } | null>(null); const [editValue, setEditValue] = useState(""); const editRef = useRef(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }) ); // localStorage에서 컬럼 순서 복원 useEffect(() => { if (!columnOrderKey) return; const saved = localStorage.getItem(`edatatable_col_order_${columnOrderKey}`); if (saved) { try { const order = JSON.parse(saved) as string[]; const reordered = order .map((key) => initialColumns.find((c) => c.key === key)) .filter(Boolean) as EDataTableColumn[]; const remaining = initialColumns.filter((c) => !order.includes(c.key)); setColumns([...reordered, ...remaining]); } catch { /* skip */ } } }, [columnOrderKey]); // eslint-disable-line react-hooks/exhaustive-deps // 컬럼별 고유값 const columnUniqueValues = useMemo(() => { const result: Record = {}; for (const col of columns) { if (col.filterable === false) continue; const values = new Set(); data.forEach((row) => { const val = row[col.key]; if (val !== null && val !== undefined && val !== "") { values.add(String(val)); } }); result[col.key] = Array.from(values).sort(); } return result; }, [data, columns]); // 드래그 완료 const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; setColumns((prev) => { const oldIndex = prev.findIndex((c) => c.key === active.id); const newIndex = prev.findIndex((c) => c.key === over.id); const next = arrayMove(prev, oldIndex, newIndex); if (columnOrderKey) { localStorage.setItem(`edatatable_col_order_${columnOrderKey}`, JSON.stringify(next.map((c) => c.key))); } onColumnOrderChange?.(next); return next; }); }; // 정렬 const handleSort = (key: string) => { const newSort: SortState | null = sortState?.key === key ? sortState.direction === "asc" ? { key, direction: "desc" } : null : { key, direction: "asc" }; if (onSortChange) { onSortChange(newSort); } else { setInternalSort(newSort); } }; // 헤더 필터 const toggleHeaderFilter = (colKey: string, value: string) => { setHeaderFilters((prev) => { const next = { ...prev }; const set = new Set(next[colKey] || []); if (set.has(value)) set.delete(value); else set.add(value); if (set.size === 0) delete next[colKey]; else next[colKey] = set; return next; }); }; const clearHeaderFilter = (colKey: string) => { setHeaderFilters((prev) => { const next = { ...prev }; delete next[colKey]; return next; }); }; // 필터 + 정렬 const processedData = useMemo(() => { let result = [...data]; // 헤더 필터 if (Object.keys(headerFilters).length > 0) { result = result.filter((row) => Object.entries(headerFilters).every(([colKey, values]) => { if (values.size === 0) return true; const cellVal = row[colKey] != null ? String(row[colKey]) : ""; return values.has(cellVal); }) ); } // 정렬 (외부 정렬이 아닌 경우만) if (sortState && !onSortChange) { const { key, direction } = sortState; result.sort((a, b) => { const av = a[key] ?? ""; const bv = b[key] ?? ""; const na = Number(av); const nb = Number(bv); if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na; return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av)); }); } return result; }, [data, headerFilters, sortState, onSortChange]); // 필터/데이터 변경 시 1페이지 리셋 useEffect(() => { setCurrentPage(1); }, [data, headerFilters]); // 페이지네이션 const totalItems = processedData.length; const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); const safePage = Math.min(currentPage, totalPages); useEffect(() => { if (currentPage > totalPages) setCurrentPage(totalPages); }, [currentPage, totalPages]); const pageOffset = (safePage - 1) * pageSize; const paginatedDataRaw = showPagination ? processedData.slice(pageOffset, pageOffset + pageSize) : processedData; // 접힌 그룹의 데이터 행 숨김 const paginatedData = useMemo(() => { if (collapsedGroups.size === 0) return paginatedDataRaw; let currentGroup: string | null = null; return paginatedDataRaw.filter((row) => { if ((row as any)._isGroupHeader) { currentGroup = (row as any)._groupValue; return true; // 헤더는 항상 표시 } if ((row as any)._isGroupSummary) { return !collapsedGroups.has((row as any)._groupValue); } // 일반 행: 현재 그룹이 접혀있으면 숨김 return !currentGroup || !collapsedGroups.has(currentGroup); }); }, [paginatedDataRaw, collapsedGroups]); const applyPageSize = () => { const n = parseInt(pageSizeInput, 10); if (!isNaN(n) && n >= 1) { setPageSize(n); setCurrentPage(1); setPageSizeInput(String(n)); } else { setPageSizeInput(String(pageSize)); } }; const getPageNumbers = () => { const delta = 2; let start = Math.max(1, safePage - delta); let end = Math.min(totalPages, safePage + 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); } const pages: (number | "...")[] = []; if (start > 1) { pages.push(1); if (start > 2) pages.push("..."); } for (let i = start; i <= end; i++) pages.push(i); if (end < totalPages) { if (end < totalPages - 1) pages.push("..."); pages.push(totalPages); } return pages; }; // 인라인 편집 const startEdit = (rowId: string, colKey: string, currentVal: any) => { const col = columns.find((c) => c.key === colKey); if (!col?.editable) return; setEditingCell({ rowId, colKey }); setEditValue(currentVal != null ? String(currentVal) : ""); }; const saveEdit = useCallback(async () => { if (!editingCell) return; const { rowId, colKey } = editingCell; const row = paginatedData.find((r) => getRowId(r, rowKey) === rowId); if (!row) { setEditingCell(null); return; } const originalVal = String(row[colKey] ?? ""); if (originalVal === editValue) { setEditingCell(null); return; } if (tableName && row.id) { try { await apiClient.put(`/table-management/tables/${tableName}/edit`, { originalData: { id: row.id }, updatedData: { [colKey]: editValue || null }, }); (row as any)[colKey] = editValue; toast.success("저장되었어요"); } catch { toast.error("저장에 실패했어요"); setEditingCell(null); return; } } onCellEdit?.(rowId, colKey, editValue, row as T); setEditingCell(null); }, [editingCell, editValue, paginatedData, tableName, onCellEdit, rowKey]); const cancelEdit = () => setEditingCell(null); const handleEditKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); saveEdit(); } else if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } else if (e.key === "Tab") { e.preventDefault(); saveEdit(); } }; useEffect(() => { if (editingCell && editRef.current) { editRef.current.focus(); if ("select" in editRef.current) editRef.current.select(); } }, [editingCell]); // 체크박스 const allChecked = processedData.length > 0 && checkedIds.length === processedData.length; // colSpan 계산 const colSpan = columns.length + (showCheckbox ? 1 : 0) + (showRowNumber ? 1 : 0); // 셀 렌더링 const renderCell = (row: T, col: EDataTableColumn, rowIdx: number) => { const id = getRowId(row, rowKey); const isEditing = editingCell?.rowId === id && editingCell?.colKey === col.key; const val = row[col.key]; // 편집 모드 if (isEditing) { if (col.inputType === "select" && col.selectOptions) { return ( ); } return ( setEditValue(e.target.value)} onKeyDown={handleEditKeyDown} onBlur={() => saveEdit()} className={cn( "h-8 w-full rounded border border-primary bg-background px-2 text-[13px] focus:ring-1 focus:ring-primary", col.align === "right" && "text-right" )} /> ); } // 커스텀 렌더러 if (col.render) { return col.render(val, row, rowIdx); } // 기본 렌더링 let display: React.ReactNode = val ?? ""; if (col.formatNumber || col.inputType === "number") display = fmtNum(val); return ( {display} ); }; return (
c.key)} strategy={horizontalListSortingStrategy}> {/* 체크박스 */} {showCheckbox && ( { onCheckedChange?.(checked ? processedData.map((r) => getRowId(r, rowKey)) : []); }} /> )} {/* 행번호 */} {showRowNumber && ( # )} {/* 데이터 컬럼 */} {columns.map((col) => ( ))} {loading ? ( ) : paginatedData.length === 0 ? (
{emptyIcon || } {emptyMessage}
) : ( paginatedData.map((row, rowIdx) => { // 그룹 헤더 행 처리 if ((row as any)._isGroupHeader) { const gv = (row as any)._groupValue || ""; const gc = (row as any)._groupCount || 0; const isCollapsed = collapsedGroups.has(gv); const totalCols = columns.length + (showCheckbox ? 1 : 0) + (showRowNumber ? 1 : 0); return ( toggleGroup(gv)}>
{isCollapsed ? : } {gv} {gc}건
); } // 그룹 소계 행 처리 if ((row as any)._isGroupSummary) { const gv = (row as any)._groupValue || ""; if (collapsedGroups.has(gv)) return null; return ( {showCheckbox && } {showRowNumber && } {columns.map((col) => ( {typeof row[col.key] === "number" ? Number(row[col.key]).toLocaleString() : (row[col.key] || "")} ))} ); } const id = getRowId(row, rowKey); const isSelected = selectedId != null && String(selectedId) === String(id); const isChecked = checkedIds.includes(id); const highlighted = isSelected || isChecked; return ( { onSelect?.(id); onRowClick?.(row, pageOffset + rowIdx); if (showCheckbox && onCheckedChange) { const next = checkedIds.includes(id) ? checkedIds.filter((cid) => cid !== id) : [...checkedIds, id]; onCheckedChange(next); } }} onDoubleClick={() => onRowDoubleClick?.(row, pageOffset + rowIdx)} > {showCheckbox && ( e.stopPropagation()}> { const next = checked ? [...checkedIds, id] : checkedIds.filter((cid) => cid !== id); onCheckedChange?.(next); }} /> )} {showRowNumber && ( {pageOffset + rowIdx + 1} )} {columns.map((col) => ( { if (col.editable) { e.stopPropagation(); startEdit(id, col.key, row[col.key]); } }} > {renderCell(row, col, pageOffset + rowIdx)} ))} ); }) )}
{/* 페이지네이션 */} {showPagination && (
전체 {totalItems.toLocaleString()}
setPageSizeInput(e.target.value)} onBlur={applyPageSize} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }} className="h-7 w-16 text-center text-xs" /> 건씩 보기
{getPageNumbers().map((page, idx) => page === "..." ? ( ... ) : ( ) )}
{ if (e.key === "Enter") { const val = parseInt((e.target as HTMLInputElement).value, 10); if (!isNaN(val) && val >= 1 && val <= totalPages) { setCurrentPage(val); (e.target as HTMLInputElement).value = ""; (e.target as HTMLInputElement).blur(); } } }} onBlur={(e) => { const val = parseInt(e.target.value, 10); if (!isNaN(val) && val >= 1 && val <= totalPages) { setCurrentPage(val); } e.target.value = ""; }} /> / {totalPages} 페이지
)}
); }