- Updated the BOM management page to streamline the layout by moving the edit button to the top right for better accessibility. - Enhanced the DataGrid and EDataTable components to support a no-wrapper option, allowing for sticky headers to function correctly with parent overflow settings. - Adjusted the Sales Order page to utilize the new noWrapper feature for the table, ensuring consistent styling and behavior. - Enabled sticky headers in the V2 table list definition for improved data visibility during scrolling. These changes aim to enhance the user experience by providing a more intuitive and organized interface for managing BOM and sales order data across multiple companies.
870 lines
32 KiB
TypeScript
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 noWrapper className="min-w-max">
|
|
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
|
|
<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>
|
|
);
|
|
}
|