diff --git a/frontend/components/common/EDataTable.tsx b/frontend/components/common/EDataTable.tsx index 526d9117..8891de4d 100644 --- a/frontend/components/common/EDataTable.tsx +++ b/frontend/components/common/EDataTable.tsx @@ -20,7 +20,7 @@ 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, + ChevronLeft, ChevronRight, ChevronDown, ChevronsLeft, ChevronsRight, ArrowUp, ArrowDown, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -290,6 +290,16 @@ export function EDataTable = any>({ 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(""); @@ -428,10 +438,27 @@ export function EDataTable = any>({ }, [currentPage, totalPages]); const pageOffset = (safePage - 1) * pageSize; - const paginatedData = showPagination + 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) { @@ -641,8 +668,29 @@ export function EDataTable = any>({ ) : ( 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 && } diff --git a/frontend/hooks/useTableSettings.ts b/frontend/hooks/useTableSettings.ts index 7155887c..d29068ac 100644 --- a/frontend/hooks/useTableSettings.ts +++ b/frontend/hooks/useTableSettings.ts @@ -161,35 +161,39 @@ export function useTableSettings( * 소계 행은 _isGroupSummary: true, _groupKey, _groupValue 속성을 가집니다. */ const groupData = useCallback( - >(rows: R[]): (R & { _isGroupSummary?: boolean; _groupKey?: string; _groupValue?: string })[] => { + >(rows: R[]): (R & { _isGroupSummary?: boolean; _isGroupHeader?: boolean; _groupKey?: string; _groupValue?: string; _groupCount?: number })[] => { if (groupColumns.length === 0) return rows; - const groupCol = groupColumns[0]; // 첫 번째 그룹 컬럼 기준 + // 다중 그룹 컬럼 결합 키 + const makeKey = (row: R) => groupColumns.map((col) => String(row[col] ?? "(빈 값)")).join(" / "); const groups = new Map(); for (const row of rows) { - const key = String(row[groupCol] ?? "(빈 값)"); + const key = makeKey(row); if (!groups.has(key)) groups.set(key, []); groups.get(key)!.push(row); } - const result: (R & { _isGroupSummary?: boolean; _groupKey?: string; _groupValue?: string })[] = []; + const result: (R & { _isGroupSummary?: boolean; _isGroupHeader?: boolean; _groupKey?: string; _groupValue?: string; _groupCount?: number })[] = []; for (const [groupValue, groupRows] of groups) { + // 그룹 헤더 행 + const headerRow: any = { _isGroupHeader: true, _groupKey: groupColumns.join(","), _groupValue: groupValue, _groupCount: groupRows.length }; + result.push(headerRow); + // 그룹 내 데이터 행 result.push(...groupRows); // 소계 행 (groupSumEnabled일 때만) if (groupSumEnabled) { - const summaryRow: any = { _isGroupSummary: true, _groupKey: groupCol, _groupValue: groupValue }; - // 숫자 컬럼 합산 + const summaryRow: any = { _isGroupSummary: true, _groupKey: groupColumns.join(","), _groupValue: groupValue }; for (const col of defaultColumns) { const values = groupRows.map((r) => Number(r[col.key])).filter((v) => !isNaN(v)); if (values.length > 0 && values.some((v) => v !== 0)) { summaryRow[col.key] = values.reduce((a, b) => a + b, 0); } } - summaryRow[groupCol] = `${groupValue} 소계 (${groupRows.length}건)`; + summaryRow[groupColumns[0]] = `${groupValue} 소계 (${groupRows.length}건)`; result.push(summaryRow); } }