Files
vexplor_dev/frontend/components/common/EDataTable.tsx
kjs c48dd95045 feat: Enhance EDataTable with group collapsing functionality
- Added support for collapsing and expanding grouped rows in the EDataTable component.
- Implemented a toggle mechanism for group headers, allowing users to hide or show group details.
- Updated the data processing logic to filter out collapsed group rows, improving data visibility and organization.

These changes aim to enhance the user experience by providing a more structured view of grouped data within the table.
2026-04-07 10:16:25 +09:00

870 lines
32 KiB
TypeScript

"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<T = any> {
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<T extends Record<string, any> = any> {
columns: EDataTableColumn<T>[];
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<T>[]) => 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<string>;
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 (
<TableHead
ref={setNodeRef}
style={style}
className={cn(
col.width, col.minWidth,
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none relative overflow-hidden",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
)}
>
<div className={cn(
"inline-flex items-center gap-1",
col.align === "right" && "justify-end w-full",
col.align === "center" && "justify-center w-full",
)}>
{/* 드래그 핸들 */}
{draggable && (
<div
{...attributes}
{...listeners}
className="cursor-grab text-muted-foreground/40 hover:text-muted-foreground shrink-0"
>
<GripVertical className="h-3 w-3" />
</div>
)}
{/* 컬럼 라벨 + 정렬 */}
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={(e) => {
e.stopPropagation();
if (col.sortable !== false) onSort(col.key);
}}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortDir === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{/* 필터 아이콘 + Popover */}
{col.filterable !== false && uniqueValues.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className={cn(
"hover:bg-primary/20 rounded p-0.5 transition-colors shrink-0",
hasFilter && "text-primary bg-primary/10",
)}
title="필터"
>
<Filter className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="start" onClick={(e) => e.stopPropagation()}>
<div className="space-y-2">
<div className="flex items-center justify-between border-b pb-2">
<span className="text-xs font-medium">: {col.label}</span>
{hasFilter && (
<button onClick={() => onClearFilter(col.key)} className="text-primary text-xs hover:underline">
</button>
)}
</div>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
value={filterSearch}
onChange={(e) => setFilterSearch(e.target.value)}
placeholder="검색..."
className="h-7 text-xs pl-7"
/>
</div>
<div className="max-h-52 space-y-0.5 overflow-y-auto">
{filteredUniqueValues.slice(0, 100).map((val) => {
const isSelected = headerFilterValues.has(val);
return (
<div
key={val}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => onToggleFilter(col.key, val)}
>
<div className={cn(
"flex h-4 w-4 items-center justify-center rounded border shrink-0",
isSelected ? "bg-primary border-primary" : "border-input",
)}>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{val || "(빈 값)"}</span>
</div>
);
})}
{filteredUniqueValues.length > 100 && (
<div className="text-muted-foreground px-2 py-1 text-xs">
... {filteredUniqueValues.length - 100}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</TableHead>
);
}
// ─── EDataTable ───
export function EDataTable<T extends Record<string, any> = 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<T>) {
const [columns, setColumns] = useState(initialColumns);
useEffect(() => { setColumns(initialColumns); }, [initialColumns]);
// 정렬
const [internalSort, setInternalSort] = useState<SortState | null>(null);
const sortState = externalSort !== undefined ? externalSort : internalSort;
// 헤더 필터
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
// 페이지네이션
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const [pageSizeInput, setPageSizeInput] = useState(String(defaultPageSize));
// 그룹 접기/펼치기
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(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<HTMLInputElement | HTMLSelectElement>(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<T>[];
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<string, string[]> = {};
for (const col of columns) {
if (col.filterable === false) continue;
const values = new Set<string>();
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<T>, 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 (
<select
ref={editRef as any}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={() => saveEdit()}
className="h-8 w-full rounded border border-primary bg-background px-2 text-[13px] focus:ring-1 focus:ring-primary"
>
<option value=""></option>
{col.selectOptions.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
}
return (
<input
ref={editRef as any}
type={col.inputType === "number" ? "number" : col.inputType === "date" ? "date" : "text"}
value={editValue}
onChange={(e) => 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 (
<span
className={cn(
col.truncate !== false && "block truncate",
col.align === "right" && "text-right w-full inline-block",
col.align === "center" && "text-center w-full inline-block",
)}
title={String(val ?? "")}
>
{display}
</span>
);
};
return (
<div className={cn("flex flex-col h-full flex-1 min-h-0", className)}>
<div className="flex-1 min-h-0 overflow-auto">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Table className="min-w-max">
<TableHeader className="sticky top-0 z-10">
<SortableContext items={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableRow className="bg-muted hover:bg-muted h-10">
{/* 체크박스 */}
{showCheckbox && (
<TableHead className="w-10 text-center">
<Checkbox
checked={allChecked}
onCheckedChange={(checked) => {
onCheckedChange?.(checked ? processedData.map((r) => getRowId(r, rowKey)) : []);
}}
/>
</TableHead>
)}
{/* 행번호 */}
{showRowNumber && (
<TableHead className="w-10 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
#
</TableHead>
)}
{/* 데이터 컬럼 */}
{columns.map((col) => (
<SortableHeaderCell
key={col.key}
col={col}
sortKey={sortState?.key ?? null}
sortDir={sortState?.direction ?? "asc"}
onSort={handleSort}
headerFilterValues={headerFilters[col.key] || new Set()}
uniqueValues={columnUniqueValues[col.key] || []}
onToggleFilter={toggleHeaderFilter}
onClearFilter={clearHeaderFilter}
draggable={draggableColumns}
/>
))}
</TableRow>
</SortableContext>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={colSpan} className="py-16 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={colSpan} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
{emptyIcon || <Inbox className="h-8 w-8 opacity-30" />}
<span className="text-sm">{emptyMessage}</span>
</div>
</TableCell>
</TableRow>
) : (
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 (
<TableRow key={`group-${gv}-${rowIdx}`} className="bg-primary/5 hover:bg-primary/10 cursor-pointer border-t-2 border-primary/20" onClick={() => toggleGroup(gv)}>
<TableCell colSpan={totalCols} className="py-2 px-3">
<div className="flex items-center gap-2">
{isCollapsed ? <ChevronRight className="h-4 w-4 text-primary" /> : <ChevronDown className="h-4 w-4 text-primary" />}
<span className="text-sm font-semibold text-primary">{gv}</span>
<Badge variant="secondary" className="text-[10px] bg-primary/10 text-primary">{gc}</Badge>
</div>
</TableCell>
</TableRow>
);
}
// 그룹 소계 행 처리
if ((row as any)._isGroupSummary) {
const gv = (row as any)._groupValue || "";
if (collapsedGroups.has(gv)) return null;
return (
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
{showCheckbox && <TableCell />}
{showRowNumber && <TableCell />}
{columns.map((col) => (
<TableCell
key={col.key}
className={cn(
typeof row[col.key] === "number" ? "text-right font-mono text-[13px]" : "text-[13px] text-primary",
col.width, col.minWidth,
)}
>
{typeof row[col.key] === "number" ? Number(row[col.key]).toLocaleString() : (row[col.key] || "")}
</TableCell>
))}
</TableRow>
);
}
const id = getRowId(row, rowKey);
const isSelected = selectedId != null && String(selectedId) === String(id);
const isChecked = checkedIds.includes(id);
const highlighted = isSelected || isChecked;
return (
<TableRow
key={id || rowIdx}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all h-[41px]",
highlighted
? "border-l-primary bg-primary/20 dark:bg-primary/15 row-selected"
: "hover:bg-accent"
)}
onClick={() => {
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 && (
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => {
const next = checked
? [...checkedIds, id]
: checkedIds.filter((cid) => cid !== id);
onCheckedChange?.(next);
}}
/>
</TableCell>
)}
{showRowNumber && (
<TableCell className="text-center text-[11px] text-muted-foreground font-mono">
{pageOffset + rowIdx + 1}
</TableCell>
)}
{columns.map((col) => (
<TableCell
key={col.key}
className={cn(
col.width, col.minWidth,
col.editable && "cursor-text",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
)}
onDoubleClick={(e) => {
if (col.editable) {
e.stopPropagation();
startEdit(id, col.key, row[col.key]);
}
}}
>
{renderCell(row, col, pageOffset + rowIdx)}
</TableCell>
))}
</TableRow>
);
})
)}
</TableBody>
</Table>
</DndContext>
</div>
{/* 페이지네이션 */}
{showPagination && (
<div className="flex items-center justify-between border-t px-4 py-2.5 text-xs text-muted-foreground shrink-0">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span></span>
<span className="font-medium text-foreground">{totalItems.toLocaleString()}</span>
<span></span>
</div>
<div className="flex items-center gap-1.5">
<Input
type="number"
min={1}
value={pageSizeInput}
onChange={(e) => setPageSizeInput(e.target.value)}
onBlur={applyPageSize}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }}
className="h-7 w-16 text-center text-xs"
/>
<span> </span>
</div>
</div>
<div className="flex items-center gap-0.5">
<button onClick={() => setCurrentPage(1)} disabled={safePage === 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
<ChevronsLeft className="h-3.5 w-3.5" />
</button>
<button onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} disabled={safePage === 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
<ChevronLeft className="h-3.5 w-3.5" />
</button>
{getPageNumbers().map((page, idx) =>
page === "..." ? (
<span key={`dot-${idx}`} className="h-7 w-7 flex items-center justify-center text-muted-foreground">...</span>
) : (
<button key={page} onClick={() => setCurrentPage(page as number)}
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
page === safePage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>
{page}
</button>
)
)}
<button onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} disabled={safePage === totalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
<ChevronRight className="h-3.5 w-3.5" />
</button>
<button onClick={() => setCurrentPage(totalPages)} disabled={safePage === totalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
<ChevronsRight className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex items-center gap-1.5">
<Input
type="number"
min={1}
max={totalPages}
placeholder={String(safePage)}
className="h-7 w-14 text-center text-xs"
onKeyDown={(e) => {
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 = "";
}}
/>
<span>/ {totalPages} </span>
</div>
</div>
)}
</div>
);
}