diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 367f95e0..9ee28aac 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -19,6 +19,8 @@ import { TableIcon, Settings, X, + Layers, + ChevronDown, } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; @@ -36,6 +38,18 @@ import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearc import { SingleTableWithSticky } from "./SingleTableWithSticky"; import { CardModeRenderer } from "./CardModeRenderer"; +// ======================================== +// 인터페이스 +// ======================================== + +// 그룹화된 데이터 인터페이스 +interface GroupedData { + groupKey: string; + groupValues: Record; + items: any[]; + count: number; +} + // ======================================== // 캐시 및 유틸리티 // ======================================== @@ -255,6 +269,11 @@ export const TableListComponent: React.FC = ({ const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); const [visibleFilterColumns, setVisibleFilterColumns] = useState>(new Set()); + // 그룹 설정 관련 상태 + const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); + const [groupByColumns, setGroupByColumns] = useState([]); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, { enableBatchLoading: true, preloadCommonCodes: true, @@ -715,6 +734,12 @@ export const TableListComponent: React.FC = ({ return `tableList_filterSettings_${tableConfig.selectedTable}`; }, [tableConfig.selectedTable]); + // 그룹 설정 localStorage 키 생성 + const groupSettingKey = useMemo(() => { + if (!tableConfig.selectedTable) return null; + return `tableList_groupSettings_${tableConfig.selectedTable}`; + }, [tableConfig.selectedTable]); + // 저장된 필터 설정 불러오기 useEffect(() => { if (!filterSettingKey || visibleColumns.length === 0) return; @@ -789,6 +814,105 @@ export const TableListComponent: React.FC = ({ })); }, [visibleColumns, visibleFilterColumns, columnLabels]); + // 그룹 설정 저장 + const saveGroupSettings = useCallback(() => { + if (!groupSettingKey) return; + + try { + localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); + setIsGroupSettingOpen(false); + toast.success("그룹 설정이 저장되었습니다"); + } catch (error) { + console.error("그룹 설정 저장 실패:", error); + toast.error("설정 저장에 실패했습니다"); + } + }, [groupSettingKey, groupByColumns]); + + // 그룹 컬럼 토글 + const toggleGroupColumn = useCallback((columnName: string) => { + setGroupByColumns((prev) => { + if (prev.includes(columnName)) { + return prev.filter((col) => col !== columnName); + } else { + return [...prev, columnName]; + } + }); + }, []); + + // 그룹 펼치기/접기 토글 + const toggleGroupCollapse = useCallback((groupKey: string) => { + setCollapsedGroups((prev) => { + const newSet = new Set(prev); + if (newSet.has(groupKey)) { + newSet.delete(groupKey); + } else { + newSet.add(groupKey); + } + return newSet; + }); + }, []); + + // 그룹 해제 + const clearGrouping = useCallback(() => { + setGroupByColumns([]); + setCollapsedGroups(new Set()); + if (groupSettingKey) { + localStorage.removeItem(groupSettingKey); + } + toast.success("그룹이 해제되었습니다"); + }, [groupSettingKey]); + + // 데이터 그룹화 + const groupedData = useMemo((): GroupedData[] => { + if (groupByColumns.length === 0 || data.length === 0) return []; + + const grouped = new Map(); + + data.forEach((item) => { + // 그룹 키 생성: "통화:KRW > 단위:EA" + const keyParts = groupByColumns.map((col) => { + const value = item[col]; + const label = columnLabels[col] || col; + return `${label}:${value !== null && value !== undefined ? value : "-"}`; + }); + const groupKey = keyParts.join(" > "); + + if (!grouped.has(groupKey)) { + grouped.set(groupKey, []); + } + grouped.get(groupKey)!.push(item); + }); + + return Array.from(grouped.entries()).map(([groupKey, items]) => { + const groupValues: Record = {}; + groupByColumns.forEach((col) => { + groupValues[col] = items[0]?.[col]; + }); + + return { + groupKey, + groupValues, + items, + count: items.length, + }; + }); + }, [data, groupByColumns, columnLabels]); + + // 저장된 그룹 설정 불러오기 + useEffect(() => { + if (!groupSettingKey || visibleColumns.length === 0) return; + + try { + const saved = localStorage.getItem(groupSettingKey); + if (saved) { + const savedGroups = JSON.parse(saved); + setGroupByColumns(savedGroups); + } + } catch (error) { + console.error("그룹 설정 불러오기 실패:", error); + } + }, [groupSettingKey, visibleColumns]); + useEffect(() => { fetchColumnLabels(); fetchTableLabel(); @@ -980,15 +1104,52 @@ export const TableListComponent: React.FC = ({ onClear={handleClearAdvancedFilters} /> - + + + + + )} + + {/* 그룹 표시 배지 */} + {groupByColumns.length > 0 && ( +
+
+ 그룹: +
+ {groupByColumns.map((col, idx) => ( + + {idx > 0 && } + + {columnLabels[col] || col} + + + ))} +
+ + +
)} @@ -1045,15 +1206,52 @@ export const TableListComponent: React.FC = ({ onClear={handleClearAdvancedFilters} /> - + + + + + )} + + {/* 그룹 표시 배지 */} + {groupByColumns.length > 0 && ( +
+
+ 그룹: +
+ {groupByColumns.map((col, idx) => ( + + {idx > 0 && } + + {columnLabels[col] || col} + + + ))} +
+ + +
)} @@ -1209,7 +1407,81 @@ export const TableListComponent: React.FC = ({ + ) : groupByColumns.length > 0 && groupedData.length > 0 ? ( + // 그룹화된 렌더링 + groupedData.map((group) => { + const isCollapsed = collapsedGroups.has(group.groupKey); + return ( + + {/* 그룹 헤더 */} + + +
toggleGroupCollapse(group.groupKey)} + > + {isCollapsed ? ( + + ) : ( + + )} + {group.groupKey} + ({group.count}건) +
+ + + {/* 그룹 데이터 */} + {!isCollapsed && + group.items.map((row, index) => ( + handleRowDragStart(e, row, index)} + onDragEnd={handleRowDragEnd} + className={cn( + "h-14 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-16" + )} + onClick={() => handleRowClick(row)} + > + {visibleColumns.map((column) => { + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; + const cellValue = row[mappedColumnName]; + + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || column.inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + return ( + + {column.columnName === "__checkbox__" + ? renderCheckboxCell(row, index) + : formatCellValue(cellValue, column, row)} + + ); + })} + + ))} +
+ ); + }) ) : ( + // 일반 렌더링 (그룹 없음) data.map((row, index) => ( = ({ + + {/* 그룹 설정 다이얼로그 */} + + + + 그룹 설정 + + 데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다. + + + +
+ {/* 컬럼 목록 */} +
+ {visibleColumns + .filter((col) => col.columnName !== "__checkbox__") + .map((col) => ( +
+ toggleGroupColumn(col.columnName)} + /> + +
+ ))} +
+ + {/* 선택된 그룹 안내 */} +
+ {groupByColumns.length === 0 ? ( + 그룹화할 컬럼을 선택하세요 + ) : ( + + 선택된 그룹:{" "} + + {groupByColumns.map((col) => columnLabels[col] || col).join(" → ")} + + + )} +
+
+ + + + + +
+
); }; diff --git a/테이블_그룹핑_기능_구현_계획서.md b/테이블_그룹핑_기능_구현_계획서.md new file mode 100644 index 00000000..b6b86afa --- /dev/null +++ b/테이블_그룹핑_기능_구현_계획서.md @@ -0,0 +1,365 @@ +# 테이블 그룹핑 기능 구현 계획서 + +## 📋 개요 + +테이블 리스트 컴포넌트와 플로우 위젯에 그룹핑 기능을 추가하여, 사용자가 선택한 컬럼(들)을 기준으로 데이터를 그룹화하여 표시합니다. + +## 🎯 핵심 요구사항 + +### 1. 기능 요구사항 +- ✅ 그룹핑할 컬럼을 다중 선택 가능 +- ✅ 선택한 컬럼 순서대로 계층적 그룹화 +- ✅ 그룹 헤더에 그룹 정보와 데이터 개수 표시 +- ✅ 그룹 펼치기/접기 기능 +- ✅ localStorage에 그룹 설정 저장/복원 +- ✅ 그룹 해제 기능 + +### 2. 적용 대상 +- TableListComponent (`frontend/lib/registry/components/table-list/TableListComponent.tsx`) +- FlowWidget (`frontend/components/screen/widgets/FlowWidget.tsx`) + +## 🎨 UI 디자인 + +### 그룹 설정 다이얼로그 + +```tsx +┌─────────────────────────────────────┐ +│ 📊 그룹 설정 │ +│ 데이터를 그룹화할 컬럼을 선택하세요 │ +├─────────────────────────────────────┤ +│ │ +│ [x] 통화 │ +│ [ ] 단위 │ +│ [ ] 품목코드 │ +│ [ ] 품목명 │ +│ [ ] 규격 │ +│ │ +│ 💡 선택된 그룹: 통화 │ +│ │ +├─────────────────────────────────────┤ +│ [취소] [적용] │ +└─────────────────────────────────────┘ +``` + +### 그룹화된 테이블 표시 + +```tsx +┌─────────────────────────────────────────────────────┐ +│ 📦 판매품목 목록 총 3개 [🎨 그룹: 통화 ×] │ +├─────────────────────────────────────────────────────┤ +│ │ +│ ▼ 통화: KRW > 단위: EA (2건) │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ 품목코드 │ 품목명 │ 규격 │ 단위 │ │ +│ ├─────────────────────────────────────────────┤ │ +│ │ SALE-001 │ 볼트 M8x20 │ M8x20 │ EA │ │ +│ │ SALE-004 │ 스프링 와셔 │ M10 │ EA │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ ▼ 통화: USD > 단위: EA (1건) │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ 품목코드 │ 품목명 │ 규격 │ 단위 │ │ +│ ├─────────────────────────────────────────────┤ │ +│ │ SALE-002 │ 너트 M8 │ M8 │ EA │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +## 🔧 기술 구현 + +### 1. 상태 관리 + +```typescript +// 그룹 설정 관련 상태 +const [groupByColumns, setGroupByColumns] = useState([]); // 그룹화할 컬럼 목록 +const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그 +const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); // 접힌 그룹 +``` + +### 2. 데이터 그룹화 로직 + +```typescript +interface GroupedData { + groupKey: string; // "통화:KRW > 단위:EA" + groupValues: Record; // { 통화: "KRW", 단위: "EA" } + items: any[]; // 그룹에 속한 데이터 + count: number; // 항목 개수 +} + +const groupDataByColumns = ( + data: any[], + groupColumns: string[] +): GroupedData[] => { + if (groupColumns.length === 0) return []; + + const grouped = new Map(); + + data.forEach(item => { + // 그룹 키 생성: "통화:KRW > 단위:EA" + const keyParts = groupColumns.map(col => `${col}:${item[col] || '-'}`); + const groupKey = keyParts.join(' > '); + + if (!grouped.has(groupKey)) { + grouped.set(groupKey, []); + } + grouped.get(groupKey)!.push(item); + }); + + return Array.from(grouped.entries()).map(([groupKey, items]) => { + const groupValues: Record = {}; + groupColumns.forEach(col => { + groupValues[col] = items[0]?.[col]; + }); + + return { + groupKey, + groupValues, + items, + count: items.length, + }; + }); +}; +``` + +### 3. localStorage 저장/로드 + +```typescript +// 저장 키 +const groupSettingKey = useMemo(() => { + if (!tableConfig.selectedTable) return null; + return `table-list-group-${tableConfig.selectedTable}`; +}, [tableConfig.selectedTable]); + +// 그룹 설정 저장 +const saveGroupSettings = useCallback(() => { + if (!groupSettingKey) return; + + try { + localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); + setIsGroupSettingOpen(false); + toast.success("그룹 설정이 저장되었습니다"); + } catch (error) { + console.error("그룹 설정 저장 실패:", error); + toast.error("설정 저장에 실패했습니다"); + } +}, [groupSettingKey, groupByColumns]); + +// 그룹 설정 로드 +useEffect(() => { + if (!groupSettingKey || visibleColumns.length === 0) return; + + try { + const saved = localStorage.getItem(groupSettingKey); + if (saved) { + const savedGroups = JSON.parse(saved); + setGroupByColumns(savedGroups); + } + } catch (error) { + console.error("그룹 설정 불러오기 실패:", error); + } +}, [groupSettingKey, visibleColumns]); +``` + +### 4. 그룹 헤더 렌더링 + +```tsx +const renderGroupHeader = (group: GroupedData) => { + const isCollapsed = collapsedGroups.has(group.groupKey); + + return ( +
toggleGroupCollapse(group.groupKey)} + > + {/* 펼치기/접기 아이콘 */} + {isCollapsed ? ( + + ) : ( + + )} + + {/* 그룹 정보 */} + + {groupByColumns.map((col, idx) => ( + + {idx > 0 && > } + {columnLabels[col] || col}: + {" "} + {group.groupValues[col]} + + ))} + + + {/* 항목 개수 */} + + ({group.count}건) + +
+ ); +}; +``` + +### 5. 그룹 설정 다이얼로그 + +```tsx + + + + 그룹 설정 + + 데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다. + + + +
+ {/* 컬럼 목록 */} +
+ {visibleColumns + .filter((col) => col.columnName !== "__checkbox__") + .map((col) => ( +
+ toggleGroupColumn(col.columnName)} + /> + +
+ ))} +
+ + {/* 선택된 그룹 안내 */} +
+ {groupByColumns.length === 0 ? ( + 그룹화할 컬럼을 선택하세요 + ) : ( + + 선택된 그룹: + {groupByColumns.map((col) => columnLabels[col] || col).join(" → ")} + + + )} +
+
+ + + + + +
+
+``` + +### 6. 그룹 해제 버튼 + +```tsx +{/* 헤더 영역 */} +
+

{tableLabel}

+
+ {/* 그룹 표시 배지 */} + {groupByColumns.length > 0 && ( +
+ 그룹: {groupByColumns.map(col => columnLabels[col] || col).join(", ")} + +
+ )} + + {/* 그룹 설정 버튼 */} + +
+
+``` + +## 📝 구현 순서 + +### Phase 1: TableListComponent 구현 +1. ✅ 상태 관리 추가 (groupByColumns, isGroupSettingOpen, collapsedGroups) +2. ✅ 그룹화 로직 구현 (groupDataByColumns 함수) +3. ✅ localStorage 저장/로드 로직 +4. ✅ 그룹 설정 다이얼로그 UI +5. ✅ 그룹 헤더 렌더링 +6. ✅ 그룹별 데이터 렌더링 +7. ✅ 그룹 해제 기능 + +### Phase 2: FlowWidget 구현 +1. ✅ TableListComponent와 동일한 로직 적용 +2. ✅ 스텝 데이터에 그룹화 적용 +3. ✅ UI 통일성 유지 + +### Phase 3: 테스트 및 최적화 +1. ✅ 다중 그룹 계층 테스트 +2. ✅ 대량 데이터 성능 테스트 +3. ✅ localStorage 저장/복원 테스트 +4. ✅ 그룹 펼치기/접기 테스트 + +## 🎯 예상 효과 + +### 사용자 경험 개선 +- 데이터를 논리적으로 그룹화하여 가독성 향상 +- 대량 데이터를 효율적으로 탐색 가능 +- 사용자 정의 뷰 제공 + +### 데이터 분석 지원 +- 카테고리별 데이터 분석 용이 +- 통계 정보 제공 (그룹별 개수) +- 계층적 데이터 구조 시각화 + +## ⚠️ 주의사항 + +### 성능 고려사항 +- 그룹화는 클라이언트 측에서 수행 +- 대량 데이터의 경우 성능 저하 가능 +- 필요시 서버 측 그룹화로 전환 검토 + +### 사용성 +- 그룹화 해제가 쉽게 가능해야 함 +- 그룹 설정이 직관적이어야 함 +- 모바일에서도 사용 가능한 UI + +## 📊 구현 상태 + +- [ ] Phase 1: TableListComponent 구현 + - [ ] 상태 관리 추가 + - [ ] 그룹화 로직 구현 + - [ ] localStorage 연동 + - [ ] UI 구현 +- [ ] Phase 2: FlowWidget 구현 +- [ ] Phase 3: 테스트 및 최적화 + +--- + +**작성일**: 2025-11-03 +**버전**: 1.0 +**상태**: 구현 예정 +