feat: 그룹 설정 및 데이터 그룹핑 기능 추가123
This commit is contained in:
@@ -0,0 +1,795 @@
|
||||
"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, 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",
|
||||
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 [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 paginatedData = showPagination
|
||||
? processedData.slice(pageOffset, pageOffset + pageSize)
|
||||
: processedData;
|
||||
|
||||
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>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<SortableContext items={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{/* 체크박스 */}
|
||||
{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)._isGroupSummary) {
|
||||
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 === 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",
|
||||
highlighted
|
||||
? "border-l-primary bg-primary/5"
|
||||
: "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="w-[180px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user