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.
This commit is contained in:
kjs
2026-04-07 10:16:25 +09:00
parent 2494a96bad
commit c48dd95045
2 changed files with 61 additions and 9 deletions
+50 -2
View File
@@ -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<T extends Record<string, any> = any>({
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("");
@@ -428,10 +438,27 @@ export function EDataTable<T extends Record<string, any> = 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<T extends Record<string, any> = any>({
</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 />}
+11 -7
View File
@@ -161,35 +161,39 @@ export function useTableSettings<T extends { key: string }>(
* 소계 행은 _isGroupSummary: true, _groupKey, _groupValue 속성을 가집니다.
*/
const groupData = useCallback(
<R extends Record<string, any>>(rows: R[]): (R & { _isGroupSummary?: boolean; _groupKey?: string; _groupValue?: string })[] => {
<R extends Record<string, any>>(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<string, R[]>();
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);
}
}