diff --git a/docs/V2_컴포넌트_분석_가이드.md b/docs/V2_컴포넌트_분석_가이드.md index 89d18a40..451cf602 100644 --- a/docs/V2_컴포넌트_분석_가이드.md +++ b/docs/V2_컴포넌트_분석_가이드.md @@ -26,11 +26,12 @@ frontend/lib/registry/components/ --- -## V2 컴포넌트 목록 (17개) +## V2 컴포넌트 목록 (18개) | 컴포넌트 ID | 이름 | 카테고리 | 용도 | |------------|------|----------|------| | `v2-table-list` | 테이블 리스트 | DISPLAY | 데이터 목록 표시 (테이블/카드 모드) | +| `v2-table-grouped` | 그룹화 테이블 | DISPLAY | **NEW** 그룹별 접기/펼치기 테이블 | | `v2-split-panel-layout` | 분할 패널 | DISPLAY | 마스터-디테일 레이아웃 | | `v2-unified-repeater` | 통합 리피터 | UNIFIED | 반복 데이터 관리 (인라인/모달/버튼) | | `v2-pivot-grid` | 피벗 그리드 | DISPLAY | 다차원 데이터 분석 피벗 테이블 | diff --git a/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md b/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md index 1282e6c3..a0ce50b3 100644 --- a/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md +++ b/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md @@ -518,19 +518,18 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul ### 3.3 체크리스트 -#### v2-table-grouped +#### v2-table-grouped ✅ 구현 완료 (2026-01-30) -- [ ] 타입 정의 완료 -- [ ] 기본 구조 생성 -- [ ] useGroupedData 훅 구현 -- [ ] GroupHeader 컴포넌트 -- [ ] 메인 컴포넌트 구현 -- [ ] 그룹 체크박스 연동 -- [ ] 그룹 요약 (합계/개수) -- [ ] 설정 패널 구현 -- [ ] 레지스트리 등록 -- [ ] 테스트 완료 -- [ ] 문서화 +- [x] 타입 정의 완료 +- [x] 기본 구조 생성 +- [x] useGroupedData 훅 구현 +- [x] GroupHeader 컴포넌트 +- [x] 메인 컴포넌트 구현 +- [x] 그룹 체크박스 연동 +- [x] 그룹 요약 (합계/개수) +- [x] 설정 패널 구현 +- [x] 레지스트리 등록 +- [x] 문서화 (README.md) #### v2-timeline-scheduler diff --git a/docs/screen-implementation-guide/03_production/production-plan.md b/docs/screen-implementation-guide/03_production/production-plan.md index 1cbfda4f..aaba82b6 100644 --- a/docs/screen-implementation-guide/03_production/production-plan.md +++ b/docs/screen-implementation-guide/03_production/production-plan.md @@ -265,10 +265,10 @@ interface TabInlineComponent { #### 신규 컴포넌트 개발 (2개) - 대체 불가 -| 컴포넌트 | 필요 이유 | 난이도 | +| 컴포넌트 | 필요 이유 | 상태 | |----------|----------|:------:| -| **`v2-table-grouped`** | 품목별 접기/펼치기 그룹화 테이블 | 중 | -| **`v2-timeline-scheduler`** | 간트차트 형태의 타임라인 스케줄러 | 상 | +| ~~**`v2-table-grouped`**~~ | 품목별 접기/펼치기 그룹화 테이블 | ✅ **완료** | +| **`v2-timeline-scheduler`** | 간트차트 형태의 타임라인 스케줄러 | ⏳ 대기 | #### 불필요한 컴포넌트 (이전 분석에서 제외) @@ -280,7 +280,7 @@ interface TabInlineComponent { ``` 최종 필요 작업: 1. ✅ v2-split-panel-layout 확장 - displayMode: "custom" 추가 [완료] -2. v2-table-grouped 신규 개발 - 그룹화 테이블 (접기/펼치기) +2. ✅ v2-table-grouped 신규 개발 - 그룹화 테이블 (접기/펼치기) [완료] 3. v2-timeline-scheduler 신규 개발 - 타임라인/간트차트 (핵심!) ``` @@ -642,10 +642,11 @@ V2 컴포넌트로 기본 CRUD 화면 구현: - ✅ 디자인 모드에서 컴포넌트 드래그&드롭 지원 - ✅ 컴포넌트 클릭 시 좌측 설정 패널에서 속성 편집 -2. **`v2-table-grouped` 신규 개발** - - 품목별 그룹핑 (접기/펼치기) - - 그룹 행과 상세 행 구분 - - 체크박스 선택 (그룹 단위 / 개별 단위) +2. ~~**`v2-table-grouped` 신규 개발**~~ ✅ **완료** + - ✅ 품목별 그룹핑 (접기/펼치기) + - ✅ 그룹 행과 상세 행 구분 + - ✅ 체크박스 선택 (그룹 단위 / 개별 단위) + - ✅ 그룹 요약 (개수, 합계, 평균 등) ### Phase 3: 타임라인 스케줄러 @@ -1199,7 +1200,7 @@ v2-table-list (생산계획 목록) | 체크 | 항목 | 설명 | |:----:|------|------| | ✅ | ~~**v2-split-panel-layout 확장**~~ | `displayMode: "custom"` + `components[]` 추가 **완료** | -| ☐ | **v2-table-grouped 개발** | 그룹화 테이블 컴포넌트 (접기/펼치기) | +| ✅ | ~~**v2-table-grouped 개발**~~ | 그룹화 테이블 컴포넌트 **완료** | | ☐ | **v2-timeline-scheduler 개발** | 타임라인/간트차트 스케줄러 | | ☐ | **드래그&드롭 기능** | 타임라인 바 드래그 이동/크기 조정 | | ☐ | **자동 스케줄 로직** | 자동 스케줄 생성 백엔드 API | diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 8a2ac932..45d6e15d 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -106,6 +106,7 @@ import "./v2-table-search-widget"; import "./v2-tabs-widget/tabs-component"; import "./v2-category-manager/V2CategoryManagerRenderer"; import "./v2-media"; // 통합 미디어 컴포넌트 +import "./v2-table-grouped/TableGroupedRenderer"; // 그룹화 테이블 /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/v2-table-grouped/README.md b/frontend/lib/registry/components/v2-table-grouped/README.md new file mode 100644 index 00000000..fc39733e --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/README.md @@ -0,0 +1,162 @@ +# v2-table-grouped (그룹화 테이블) + +## 개요 + +데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능을 제공하는 테이블 컴포넌트입니다. + +## 주요 기능 + +- **그룹화**: 지정된 컬럼 기준으로 데이터를 그룹핑 +- **접기/펼치기**: 그룹 헤더 클릭으로 하위 항목 토글 +- **그룹 요약**: 그룹별 개수, 합계, 평균, 최대/최소값 표시 +- **체크박스 선택**: 그룹 단위 또는 개별 항목 선택 +- **전체 펼치기/접기**: 모든 그룹 일괄 토글 버튼 + +## 사용 예시 + +```tsx +import { TableGroupedComponent } from "./TableGroupedComponent"; + + console.log("선택:", event.selectedItems)} + onRowClick={(event) => console.log("행 클릭:", event.row)} +/> +``` + +## 설정 옵션 + +### 기본 설정 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `selectedTable` | string | - | 데이터 테이블명 | +| `useCustomTable` | boolean | false | 커스텀 테이블 사용 여부 | +| `customTableName` | string | - | 커스텀 테이블명 | +| `columns` | ColumnConfig[] | [] | 표시할 컬럼 설정 | + +### 그룹화 설정 (groupConfig) + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `groupByColumn` | string | - | 그룹화 기준 컬럼 (필수) | +| `groupLabelFormat` | string | "{value}" | 그룹 라벨 형식 | +| `defaultExpanded` | boolean | true | 초기 펼침 상태 | +| `sortDirection` | "asc" \| "desc" | "asc" | 그룹 정렬 방향 | +| `summary.showCount` | boolean | true | 개수 표시 여부 | +| `summary.sumColumns` | string[] | [] | 합계 컬럼 목록 | +| `summary.avgColumns` | string[] | [] | 평균 컬럼 목록 | + +### 표시 설정 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `showCheckbox` | boolean | false | 체크박스 표시 | +| `checkboxMode` | "single" \| "multi" | "multi" | 선택 모드 | +| `showExpandAllButton` | boolean | true | 전체 펼치기/접기 버튼 | +| `groupHeaderStyle` | "default" \| "compact" \| "card" | "default" | 그룹 헤더 스타일 | +| `rowClickable` | boolean | true | 행 클릭 가능 여부 | +| `maxHeight` | number | 600 | 최대 높이 (px) | +| `emptyMessage` | string | "데이터가 없습니다." | 빈 데이터 메시지 | + +## 이벤트 + +### onSelectionChange + +선택 상태가 변경될 때 호출됩니다. + +```typescript +interface SelectionChangeEvent { + selectedGroups: string[]; // 선택된 그룹 키 목록 + selectedItems: any[]; // 선택된 아이템 전체 + isAllSelected: boolean; // 전체 선택 여부 +} +``` + +### onGroupToggle + +그룹 펼치기/접기 시 호출됩니다. + +```typescript +interface GroupToggleEvent { + groupKey: string; // 그룹 키 + expanded: boolean; // 펼침 상태 +} +``` + +### onRowClick + +행 클릭 시 호출됩니다. + +```typescript +interface RowClickEvent { + row: any; // 클릭된 행 데이터 + groupKey: string; // 그룹 키 + indexInGroup: number; // 그룹 내 인덱스 +} +``` + +## 그룹 라벨 형식 + +`groupLabelFormat`에서 사용 가능한 플레이스홀더: + +- `{value}`: 그룹화 컬럼의 값 +- `{컬럼명}`: 해당 컬럼의 값 + +**예시:** +``` +groupLabelFormat: "{item_name} ({item_code}) - {category}" +// 결과: "제품A (P001) - 완제품" +``` + +## 파일 구조 + +``` +v2-table-grouped/ +├── index.ts # Definition +├── types.ts # 타입 정의 +├── config.ts # 기본 설정값 +├── TableGroupedComponent.tsx # 메인 컴포넌트 +├── TableGroupedConfigPanel.tsx # 설정 패널 +├── TableGroupedRenderer.tsx # 레지스트리 등록 +├── components/ +│ └── GroupHeader.tsx # 그룹 헤더 +├── hooks/ +│ └── useGroupedData.ts # 그룹화 로직 훅 +└── README.md +``` + +## v2-table-list와의 차이점 + +| 항목 | v2-table-list | v2-table-grouped | +|------|---------------|------------------| +| 데이터 구조 | 플랫 리스트 | 계층 구조 (그룹 > 아이템) | +| 렌더링 | 행 단위 | 그룹 헤더 + 상세 행 | +| 선택 | 개별 행 | 그룹 단위 / 개별 단위 | +| 요약 | 전체 합계 (선택) | 그룹별 요약 | +| 용도 | 일반 데이터 목록 | 카테고리별 분류 데이터 | + +## 관련 컴포넌트 + +- `v2-table-list`: 기본 테이블 (그룹화 없음) +- `v2-pivot-grid`: 피벗 테이블 (다차원 집계) +- `v2-split-panel-layout`: 마스터-디테일 레이아웃 diff --git a/frontend/lib/registry/components/v2-table-grouped/TableGroupedComponent.tsx b/frontend/lib/registry/components/v2-table-grouped/TableGroupedComponent.tsx new file mode 100644 index 00000000..5d33187a --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/TableGroupedComponent.tsx @@ -0,0 +1,529 @@ +"use client"; + +import React, { useCallback, useEffect, useState, useMemo } from "react"; +import { Loader2, FoldVertical, UnfoldVertical } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { TableGroupedComponentProps } from "./types"; +import { useGroupedData } from "./hooks/useGroupedData"; +import { GroupHeader } from "./components/GroupHeader"; +import { useScreenContextOptional } from "@/contexts/ScreenContext"; +import { useTableOptions } from "@/contexts/TableOptionsContext"; +import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer"; + +/** + * v2-table-grouped 메인 컴포넌트 + * + * 데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능을 제공합니다. + */ +export function TableGroupedComponent({ + config, + isDesignMode = false, + formData, + onSelectionChange, + onGroupToggle, + onRowClick, + externalData, + isLoading: externalLoading, + error: externalError, + componentId, +}: TableGroupedComponentProps) { + // 화면 컨텍스트 (데이터 제공자로 등록) + const screenContext = useScreenContextOptional(); + + // TableOptions Context (검색필터 연동) + const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions(); + + // 연결된 필터 상태 (다른 컴포넌트 값으로 필터링) + const [linkedFilterValues, setLinkedFilterValues] = useState>({}); + + // 필터 및 그룹 설정 상태 (검색필터 연동용) + const [filters, setFilters] = useState([]); + const [grouping, setGrouping] = useState([]); + const [columnVisibility, setColumnVisibility] = useState([]); + + // 그룹화 데이터 훅 (검색 필터 전달) + const { + groups, + isLoading: hookLoading, + error: hookError, + toggleGroup, + expandAll, + collapseAll, + toggleItemSelection, + toggleGroupSelection, + toggleAllSelection, + selectedItems, + isAllSelected, + isIndeterminate, + refresh, + rawData, + totalCount, + groupCount, + } = useGroupedData(config, externalData, linkedFilterValues); + + const isLoading = externalLoading ?? hookLoading; + const error = externalError ?? hookError; + + // 필터링된 데이터 (훅에서 이미 필터 적용됨) + const filteredData = rawData; + + // 연결된 필터 감시 + useEffect(() => { + const linkedFilters = config.linkedFilters; + + if (!linkedFilters || linkedFilters.length === 0 || !screenContext) { + return; + } + + // 연결된 소스 컴포넌트들의 값을 주기적으로 확인 + const checkLinkedFilters = () => { + const newFilterValues: Record = {}; + let hasChanges = false; + + linkedFilters.forEach((filter) => { + if (filter.enabled === false) return; + + const sourceProvider = screenContext.getDataProvider(filter.sourceComponentId); + if (sourceProvider) { + const selectedData = sourceProvider.getSelectedData(); + if (selectedData && selectedData.length > 0) { + const sourceField = filter.sourceField || "value"; + const value = selectedData[0][sourceField]; + + if (value !== linkedFilterValues[filter.targetColumn]) { + newFilterValues[filter.targetColumn] = value; + hasChanges = true; + } else { + newFilterValues[filter.targetColumn] = linkedFilterValues[filter.targetColumn]; + } + } + } + }); + + if (hasChanges) { + setLinkedFilterValues(newFilterValues); + } + }; + + // 초기 확인 + checkLinkedFilters(); + + // 주기적 확인 (100ms 간격) + const intervalId = setInterval(checkLinkedFilters, 100); + + return () => { + clearInterval(intervalId); + }; + }, [screenContext, config.linkedFilters, linkedFilterValues]); + + // DataProvidable 인터페이스 구현 + const dataProvider: DataProvidable = useMemo( + () => ({ + componentId: componentId || "", + componentType: "table-grouped", + + getSelectedData: () => { + return selectedItems; + }, + + getAllData: () => { + return filteredData; + }, + + clearSelection: () => { + toggleAllSelection(); + }, + }), + [componentId, selectedItems, filteredData, toggleAllSelection] + ); + + // DataReceivable 인터페이스 구현 + const dataReceiver: DataReceivable = useMemo( + () => ({ + componentId: componentId || "", + componentType: "table-grouped", + + receiveData: async (_receivedData: any[], _config: DataReceiverConfig) => { + // 현재는 외부 데이터 수신 시 새로고침만 수행 + refresh(); + }, + + clearData: async () => { + // 데이터 클리어 시 새로고침 + refresh(); + }, + + getConfig: () => { + return { + targetComponentId: componentId || "", + mode: "replace" as const, + }; + }, + }), + [componentId, refresh] + ); + + // 화면 컨텍스트에 데이터 제공자/수신자로 등록 + useEffect(() => { + if (screenContext && componentId) { + screenContext.registerDataProvider(componentId, dataProvider); + screenContext.registerDataReceiver(componentId, dataReceiver); + + return () => { + screenContext.unregisterDataProvider(componentId); + screenContext.unregisterDataReceiver(componentId); + }; + } + }, [screenContext, componentId, dataProvider, dataReceiver]); + + // 테이블 ID (검색필터 연동용) + const tableId = componentId || `table-grouped-${config.selectedTable || "default"}`; + + // TableOptionsContext에 테이블 등록 (검색필터가 테이블을 찾을 수 있도록) + useEffect(() => { + if (isDesignMode || !config.selectedTable) return; + + const columnsToRegister = config.columns || []; + + // 고유 값 조회 함수 + const getColumnUniqueValues = async (columnName: string) => { + const uniqueValues = new Set(); + rawData.forEach((row) => { + const value = row[columnName]; + if (value !== null && value !== undefined && value !== "") { + uniqueValues.add(String(value)); + } + }); + return Array.from(uniqueValues) + .map((value) => ({ value, label: value })) + .sort((a, b) => a.label.localeCompare(b.label)); + }; + + const registration = { + tableId, + label: config.selectedTable, + tableName: config.selectedTable, + dataCount: totalCount, + columns: columnsToRegister.map((col) => ({ + columnName: col.columnName, + columnLabel: col.displayName || col.columnName, + inputType: "text", + visible: col.visible !== false, + width: col.width || 150, + sortable: true, + filterable: true, + })), + onFilterChange: setFilters, + onGroupChange: setGrouping, + onColumnVisibilityChange: setColumnVisibility, + getColumnUniqueValues, + }; + + registerTable(registration); + + return () => { + unregisterTable(tableId); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tableId, config.selectedTable, config.columns, totalCount, rawData, registerTable, isDesignMode]); + + // 데이터 건수 변경 시 업데이트 + useEffect(() => { + if (!isDesignMode && config.selectedTable) { + updateTableDataCount(tableId, totalCount); + } + }, [tableId, totalCount, updateTableDataCount, config.selectedTable, isDesignMode]); + + // 필터 변경 시 검색 조건 적용 + useEffect(() => { + if (filters.length > 0) { + const newFilterValues: Record = {}; + filters.forEach((filter: any) => { + if (filter.value) { + newFilterValues[filter.columnName] = filter.value; + } + }); + setLinkedFilterValues((prev) => ({ ...prev, ...newFilterValues })); + } + }, [filters]); + + // 컬럼 설정 + const columns = config.columns || []; + const visibleColumns = columns.filter((col) => col.visible !== false); + + // 체크박스 컬럼 포함 시 총 컬럼 수 + const totalColumnCount = visibleColumns.length + (config.showCheckbox ? 1 : 0); + + // 아이템 ID 추출 함수 + const getItemId = useCallback( + (item: any): string => { + if (item.id !== undefined) return String(item.id); + const firstCol = columns[0]?.columnName; + if (firstCol && item[firstCol] !== undefined) return String(item[firstCol]); + return JSON.stringify(item); + }, + [columns] + ); + + // 선택 변경 시 콜백 + useEffect(() => { + if (onSelectionChange && selectedItems.length >= 0) { + onSelectionChange({ + selectedGroups: groups + .filter((g) => g.selected) + .map((g) => g.groupKey), + selectedItems, + isAllSelected, + }); + } + }, [selectedItems, groups, isAllSelected, onSelectionChange]); + + // 그룹 토글 핸들러 + const handleGroupToggle = useCallback( + (groupKey: string) => { + toggleGroup(groupKey); + if (onGroupToggle) { + const group = groups.find((g) => g.groupKey === groupKey); + onGroupToggle({ + groupKey, + expanded: !group?.expanded, + }); + } + }, + [toggleGroup, onGroupToggle, groups] + ); + + // 행 클릭 핸들러 + const handleRowClick = useCallback( + (row: any, groupKey: string, indexInGroup: number) => { + if (!config.rowClickable) return; + if (onRowClick) { + onRowClick({ row, groupKey, indexInGroup }); + } + }, + [config.rowClickable, onRowClick] + ); + + // refreshTable 이벤트 구독 + useEffect(() => { + const handleRefresh = () => { + refresh(); + }; + + window.addEventListener("refreshTable", handleRefresh); + return () => { + window.removeEventListener("refreshTable", handleRefresh); + }; + }, [refresh]); + + // 디자인 모드 렌더링 + if (isDesignMode) { + return ( +
+
+ + 그룹화 테이블 + {config.groupConfig?.groupByColumn && ( + + (그룹: {config.groupConfig.groupByColumn}) + + )} +
+
+ 테이블: {config.useCustomTable ? config.customTableName : config.selectedTable || "(미설정)"} +
+
+ ); + } + + // 로딩 상태 + if (isLoading) { + return ( +
+ + 로딩 중... +
+ ); + } + + // 에러 상태 + if (error) { + return ( +
+ {error} +
+ ); + } + + // 데이터 없음 + if (groups.length === 0) { + return ( +
+ {config.emptyMessage || "데이터가 없습니다."} +
+ ); + } + + return ( +
+ {/* 툴바 */} + {config.showExpandAllButton && ( +
+
+ + +
+
+ {groupCount}개 그룹 | 총 {totalCount}건 +
+
+ )} + + {/* 테이블 */} +
+ + {/* 테이블 헤더 */} + + + {/* 전체 선택 체크박스 */} + {config.showCheckbox && ( + + )} + {/* 컬럼 헤더 */} + {visibleColumns.map((col) => ( + + ))} + + + + {/* 테이블 바디 */} + + {groups.map((group) => ( + + {/* 그룹 헤더 */} + handleGroupToggle(group.groupKey)} + onSelectToggle={ + config.showCheckbox + ? () => toggleGroupSelection(group.groupKey) + : undefined + } + style={config.groupHeaderStyle} + columnCount={totalColumnCount} + /> + + {/* 그룹 아이템 (펼쳐진 경우만) */} + {group.expanded && + group.items.map((item, idx) => { + const itemId = getItemId(item); + const isSelected = group.selectedItemIds?.includes(itemId); + + return ( + handleRowClick(item, group.groupKey, idx)} + > + {/* 체크박스 */} + {config.showCheckbox && ( + + )} + + {/* 데이터 컬럼 */} + {visibleColumns.map((col) => { + const value = item[col.columnName]; + let displayValue: React.ReactNode = value; + + // 포맷 적용 + if (col.format === "number" && typeof value === "number") { + displayValue = value.toLocaleString(); + } else if (col.format === "currency" && typeof value === "number") { + displayValue = `₩${value.toLocaleString()}`; + } else if (col.format === "date" && value) { + displayValue = new Date(value).toLocaleDateString("ko-KR"); + } else if (col.format === "boolean") { + displayValue = value ? "예" : "아니오"; + } + + return ( + + ); + })} + + ); + })} + + ))} + +
+ + + {col.displayName || col.columnName} +
e.stopPropagation()} + > + + toggleItemSelection(group.groupKey, itemId) + } + /> + + {displayValue ?? "-"} +
+
+
+ ); +} + +export default TableGroupedComponent; diff --git a/frontend/lib/registry/components/v2-table-grouped/TableGroupedConfigPanel.tsx b/frontend/lib/registry/components/v2-table-grouped/TableGroupedConfigPanel.tsx new file mode 100644 index 00000000..bf96f665 --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/TableGroupedConfigPanel.tsx @@ -0,0 +1,717 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { cn } from "@/lib/utils"; +import { tableTypeApi } from "@/lib/api/screen"; +import { TableGroupedConfig, ColumnConfig, LinkedFilterConfig } from "./types"; +import { + groupHeaderStyleOptions, + checkboxModeOptions, + sortDirectionOptions, +} from "./config"; +import { Trash2, Plus } from "lucide-react"; + +interface TableGroupedConfigPanelProps { + config: TableGroupedConfig; + onConfigChange: (newConfig: TableGroupedConfig) => void; +} + +/** + * v2-table-grouped 설정 패널 + */ +// 테이블 정보 타입 +interface TableInfo { + tableName: string; + displayName: string; +} + +export function TableGroupedConfigPanel({ + config, + onConfigChange, +}: TableGroupedConfigPanelProps) { + // 테이블 목록 (라벨명 포함) + const [tables, setTables] = useState([]); + const [tableColumns, setTableColumns] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingColumns, setLoadingColumns] = useState(false); + const [tableSelectOpen, setTableSelectOpen] = useState(false); + + // 테이블 목록 로드 + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + const tableList = await tableTypeApi.getTables(); + if (tableList && Array.isArray(tableList)) { + setTables( + tableList.map((t: any) => ({ + tableName: t.tableName || t.table_name, + displayName: t.displayName || t.display_name || t.tableName || t.table_name, + })) + ); + } + } catch (err) { + console.error("테이블 목록 로드 실패:", err); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + // 선택된 테이블의 컬럼 로드 + useEffect(() => { + const tableName = config.useCustomTable + ? config.customTableName + : config.selectedTable; + + if (!tableName) { + setTableColumns([]); + return; + } + + const loadColumns = async () => { + setLoadingColumns(true); + try { + const columns = await tableTypeApi.getColumns(tableName); + if (columns && Array.isArray(columns)) { + const cols: ColumnConfig[] = columns.map( + (col: any, idx: number) => ({ + columnName: col.column_name || col.columnName, + displayName: col.display_name || col.displayName || col.column_name || col.columnName, + visible: true, + sortable: true, + searchable: false, + align: "left" as const, + order: idx, + }) + ); + setTableColumns(cols); + + // 컬럼 설정이 없으면 자동 설정 + if (!config.columns || config.columns.length === 0) { + onConfigChange({ ...config, columns: cols }); + } + } + } catch (err) { + console.error("컬럼 로드 실패:", err); + } finally { + setLoadingColumns(false); + } + }; + loadColumns(); + }, [config.selectedTable, config.customTableName, config.useCustomTable]); + + // 설정 업데이트 헬퍼 + const updateConfig = (updates: Partial) => { + onConfigChange({ ...config, ...updates }); + }; + + // 그룹 설정 업데이트 헬퍼 + const updateGroupConfig = ( + updates: Partial + ) => { + onConfigChange({ + ...config, + groupConfig: { ...config.groupConfig, ...updates }, + }); + }; + + // 컬럼 가시성 토글 + const toggleColumnVisibility = (columnName: string) => { + const updatedColumns = (config.columns || []).map((col) => + col.columnName === columnName ? { ...col, visible: !col.visible } : col + ); + updateConfig({ columns: updatedColumns }); + }; + + // 합계 컬럼 토글 + const toggleSumColumn = (columnName: string) => { + const currentSumCols = config.groupConfig?.summary?.sumColumns || []; + const newSumCols = currentSumCols.includes(columnName) + ? currentSumCols.filter((c) => c !== columnName) + : [...currentSumCols, columnName]; + + updateGroupConfig({ + summary: { + ...config.groupConfig?.summary, + sumColumns: newSumCols, + }, + }); + }; + + // 연결 필터 추가 + const addLinkedFilter = () => { + const newFilter: LinkedFilterConfig = { + sourceComponentId: "", + sourceField: "value", + targetColumn: "", + enabled: true, + }; + updateConfig({ + linkedFilters: [...(config.linkedFilters || []), newFilter], + }); + }; + + // 연결 필터 제거 + const removeLinkedFilter = (index: number) => { + const filters = [...(config.linkedFilters || [])]; + filters.splice(index, 1); + updateConfig({ linkedFilters: filters }); + }; + + // 연결 필터 업데이트 + const updateLinkedFilter = ( + index: number, + updates: Partial + ) => { + const filters = [...(config.linkedFilters || [])]; + filters[index] = { ...filters[index], ...updates }; + updateConfig({ linkedFilters: filters }); + }; + + return ( +
+ + {/* 테이블 설정 */} + + + 테이블 설정 + + + {/* 커스텀 테이블 사용 */} +
+ + + updateConfig({ useCustomTable: checked }) + } + /> +
+ + {/* 테이블 선택 */} + {config.useCustomTable ? ( +
+ + + updateConfig({ customTableName: e.target.value }) + } + placeholder="테이블명 입력" + className="h-8 text-xs" + /> +
+ ) : ( +
+ + + + + + + { + // 테이블명 또는 라벨명에 검색어가 포함되면 1, 아니면 0 + const lowerSearch = search.toLowerCase(); + if (value.toLowerCase().includes(lowerSearch)) { + return 1; + } + return 0; + }} + > + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + { + updateConfig({ selectedTable: table.tableName }); + setTableSelectOpen(false); + }} + className="text-xs" + > + +
+ {table.displayName} + + {table.tableName} + +
+
+ ))} +
+
+
+
+
+
+ )} +
+
+ + {/* 그룹화 설정 */} + + + 그룹화 설정 + + + {/* 그룹화 기준 컬럼 */} +
+ + +
+ + {/* 그룹 라벨 형식 */} +
+ + + updateGroupConfig({ groupLabelFormat: e.target.value }) + } + placeholder="{value} ({컬럼명})" + className="h-8 text-xs" + /> +

+ {"{value}"} = 그룹값, {"{컬럼명}"} = 해당 컬럼 값 +

+
+ + {/* 기본 펼침 상태 */} +
+ + + updateGroupConfig({ defaultExpanded: checked }) + } + /> +
+ + {/* 그룹 정렬 */} +
+ + +
+ + {/* 개수 표시 */} +
+ + + updateGroupConfig({ + summary: { + ...config.groupConfig?.summary, + showCount: checked, + }, + }) + } + /> +
+ + {/* 합계 컬럼 */} +
+ +
+ {tableColumns.map((col) => ( +
+ toggleSumColumn(col.columnName)} + /> + +
+ ))} +
+
+
+
+ + {/* 표시 설정 */} + + + 표시 설정 + + + {/* 체크박스 표시 */} +
+ + + updateConfig({ showCheckbox: checked }) + } + /> +
+ + {/* 체크박스 모드 */} + {config.showCheckbox && ( +
+ + +
+ )} + + {/* 그룹 헤더 스타일 */} +
+ + +
+ + {/* 전체 펼치기/접기 버튼 */} +
+ + + updateConfig({ showExpandAllButton: checked }) + } + /> +
+ + {/* 행 클릭 가능 */} +
+ + + updateConfig({ rowClickable: checked }) + } + /> +
+ + {/* 최대 높이 */} +
+ + + updateConfig({ maxHeight: parseInt(e.target.value) || 600 }) + } + className="h-8 text-xs" + /> +
+ + {/* 빈 데이터 메시지 */} +
+ + + updateConfig({ emptyMessage: e.target.value }) + } + placeholder="데이터가 없습니다." + className="h-8 text-xs" + /> +
+
+
+ + {/* 컬럼 설정 */} + + + 컬럼 설정 + + +
+ {(config.columns || tableColumns).map((col) => ( +
+ + toggleColumnVisibility(col.columnName) + } + /> + +
+ ))} +
+
+
+ + {/* 연동 설정 */} + + + 연동 설정 + + +
+
+ + +
+ + {(config.linkedFilters || []).length === 0 ? ( +

+ 연결된 필터가 없습니다. +

+ ) : ( +
+ {(config.linkedFilters || []).map((filter, idx) => ( +
+
+ + 필터 #{idx + 1} + +
+ + updateLinkedFilter(idx, { enabled: checked }) + } + /> + +
+
+ +
+ + + updateLinkedFilter(idx, { + sourceComponentId: e.target.value, + }) + } + placeholder="예: search-filter-1" + className="h-7 text-xs" + /> +
+ +
+ + + updateLinkedFilter(idx, { + sourceField: e.target.value, + }) + } + placeholder="value" + className="h-7 text-xs" + /> +
+ +
+ + +
+
+ ))} +
+ )} + +

+ 다른 컴포넌트(검색필터 등)의 선택 값으로 이 테이블을 필터링합니다. +

+
+
+
+
+
+ ); +} + +export default TableGroupedConfigPanel; diff --git a/frontend/lib/registry/components/v2-table-grouped/TableGroupedRenderer.tsx b/frontend/lib/registry/components/v2-table-grouped/TableGroupedRenderer.tsx new file mode 100644 index 00000000..245d8ee6 --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/TableGroupedRenderer.tsx @@ -0,0 +1,57 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { V2TableGroupedDefinition } from "./index"; +import { TableGroupedComponent } from "./TableGroupedComponent"; + +/** + * TableGrouped 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class TableGroupedRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2TableGroupedDefinition; + + render(): React.ReactElement { + return ( + + ); + } + + // 설정 변경 핸들러 + protected handleConfigChange = (config: any) => { + console.log("📥 TableGroupedRenderer에서 설정 변경 받음:", config); + + // 상위 컴포넌트의 onConfigChange 호출 (화면 설계자에게 알림) + if (this.props.onConfigChange) { + this.props.onConfigChange(config); + } + + this.updateComponent({ config }); + }; + + // 값 변경 처리 + protected handleValueChange = (value: any) => { + this.updateComponent({ value }); + }; +} + +// 자동 등록 실행 +TableGroupedRenderer.registerSelf(); + +// 강제 등록 (디버깅용) +if (typeof window !== "undefined") { + setTimeout(() => { + try { + TableGroupedRenderer.registerSelf(); + } catch (error) { + console.error("❌ TableGrouped 강제 등록 실패:", error); + } + }, 1000); +} diff --git a/frontend/lib/registry/components/v2-table-grouped/components/GroupHeader.tsx b/frontend/lib/registry/components/v2-table-grouped/components/GroupHeader.tsx new file mode 100644 index 00000000..f7119f4e --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/components/GroupHeader.tsx @@ -0,0 +1,141 @@ +"use client"; + +import React from "react"; +import { ChevronDown, ChevronRight, Minus } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { cn } from "@/lib/utils"; +import { GroupState, TableGroupedConfig } from "../types"; + +interface GroupHeaderProps { + /** 그룹 상태 */ + group: GroupState; + /** 설정 */ + config: TableGroupedConfig; + /** 그룹 토글 핸들러 */ + onToggle: () => void; + /** 그룹 선택 토글 핸들러 */ + onSelectToggle?: () => void; + /** 그룹 헤더 스타일 */ + style?: "default" | "compact" | "card"; + /** 컬럼 개수 (colspan용) */ + columnCount?: number; +} + +/** + * 그룹 헤더 컴포넌트 + * 그룹 펼치기/접기, 체크박스, 요약 정보 표시 + */ +export function GroupHeader({ + group, + config, + onToggle, + onSelectToggle, + style = "default", + columnCount = 1, +}: GroupHeaderProps) { + const { showCheckbox } = config; + const { summary } = group; + + // 일부 선택 여부 + const isIndeterminate = + group.selectedItemIds && + group.selectedItemIds.length > 0 && + group.selectedItemIds.length < group.items.length; + + // 요약 텍스트 생성 + const summaryText = React.useMemo(() => { + const parts: string[] = []; + + // 개수 + if (config.groupConfig?.summary?.showCount !== false) { + parts.push(`${summary.count}건`); + } + + // 합계 + if (summary.sum) { + for (const [col, value] of Object.entries(summary.sum)) { + const displayName = + config.columns?.find((c) => c.columnName === col)?.displayName || col; + parts.push(`${displayName}: ${value.toLocaleString()}`); + } + } + + return parts.join(" | "); + }, [summary, config]); + + // 스타일별 클래스 + const headerClasses = cn( + "flex items-center gap-2 cursor-pointer select-none transition-colors", + { + // default 스타일 + "px-3 py-2 bg-muted/50 hover:bg-muted border-b": style === "default", + // compact 스타일 + "px-2 py-1 bg-muted/30 hover:bg-muted/50 border-b text-sm": + style === "compact", + // card 스타일 + "px-4 py-3 bg-card border rounded-t-lg shadow-sm hover:shadow": + style === "card", + } + ); + + return ( + + { + // 체크박스 클릭 시 토글 방지 + if ((e.target as HTMLElement).closest('[data-checkbox="true"]')) { + return; + } + onToggle(); + }} + > +
+ {/* 펼치기/접기 아이콘 */} + + {group.expanded ? ( + + ) : ( + + )} + + + {/* 체크박스 */} + {showCheckbox && onSelectToggle && ( + { + e.stopPropagation(); + onSelectToggle(); + }} + > + + {isIndeterminate && ( + + )} + + )} + + {/* 그룹 라벨 */} + {group.groupLabel} + + {/* 요약 정보 */} + {summaryText && ( + + {summaryText} + + )} +
+ + + ); +} + +export default GroupHeader; diff --git a/frontend/lib/registry/components/v2-table-grouped/config.ts b/frontend/lib/registry/components/v2-table-grouped/config.ts new file mode 100644 index 00000000..fb38744c --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/config.ts @@ -0,0 +1,64 @@ +import { TableGroupedConfig } from "./types"; + +/** + * v2-table-grouped 기본 설정값 + */ +export const defaultTableGroupedConfig: Partial = { + // 그룹화 기본 설정 + groupConfig: { + groupByColumn: "", + groupLabelFormat: "{value}", + defaultExpanded: true, + sortDirection: "asc", + summary: { + showCount: true, + sumColumns: [], + }, + }, + + // 체크박스 기본 설정 + showCheckbox: false, + checkboxMode: "multi", + + // 페이지네이션 기본 설정 + pagination: { + enabled: false, + pageSize: 10, + }, + + // UI 기본 설정 + isReadOnly: false, + rowClickable: true, + showExpandAllButton: true, + groupHeaderStyle: "default", + emptyMessage: "데이터가 없습니다.", + + // 높이 기본 설정 + height: "auto", + maxHeight: 600, +}; + +/** + * 그룹 헤더 스타일 옵션 + */ +export const groupHeaderStyleOptions = [ + { value: "default", label: "기본" }, + { value: "compact", label: "컴팩트" }, + { value: "card", label: "카드" }, +]; + +/** + * 체크박스 모드 옵션 + */ +export const checkboxModeOptions = [ + { value: "single", label: "단일 선택" }, + { value: "multi", label: "다중 선택" }, +]; + +/** + * 정렬 방향 옵션 + */ +export const sortDirectionOptions = [ + { value: "asc", label: "오름차순" }, + { value: "desc", label: "내림차순" }, +]; diff --git a/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts b/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts new file mode 100644 index 00000000..d9f40aca --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts @@ -0,0 +1,389 @@ +"use client"; + +import { useState, useCallback, useMemo, useEffect } from "react"; +import { + TableGroupedConfig, + GroupState, + GroupSummary, + UseGroupedDataResult, +} from "../types"; +import { apiClient } from "@/lib/api/client"; + +/** + * 그룹 요약 데이터 계산 + */ +function calculateSummary( + items: any[], + config: TableGroupedConfig +): GroupSummary { + const summary: GroupSummary = { + count: items.length, + }; + + const summaryConfig = config.groupConfig?.summary; + if (!summaryConfig) return summary; + + // 합계 계산 + if (summaryConfig.sumColumns && summaryConfig.sumColumns.length > 0) { + summary.sum = {}; + for (const col of summaryConfig.sumColumns) { + summary.sum[col] = items.reduce((acc, item) => { + const val = parseFloat(item[col]); + return acc + (isNaN(val) ? 0 : val); + }, 0); + } + } + + // 평균 계산 + if (summaryConfig.avgColumns && summaryConfig.avgColumns.length > 0) { + summary.avg = {}; + for (const col of summaryConfig.avgColumns) { + const validItems = items.filter( + (item) => item[col] !== null && item[col] !== undefined + ); + const sum = validItems.reduce((acc, item) => { + const val = parseFloat(item[col]); + return acc + (isNaN(val) ? 0 : val); + }, 0); + summary.avg[col] = validItems.length > 0 ? sum / validItems.length : 0; + } + } + + // 최대값 계산 + if (summaryConfig.maxColumns && summaryConfig.maxColumns.length > 0) { + summary.max = {}; + for (const col of summaryConfig.maxColumns) { + const values = items + .map((item) => parseFloat(item[col])) + .filter((v) => !isNaN(v)); + summary.max[col] = values.length > 0 ? Math.max(...values) : 0; + } + } + + // 최소값 계산 + if (summaryConfig.minColumns && summaryConfig.minColumns.length > 0) { + summary.min = {}; + for (const col of summaryConfig.minColumns) { + const values = items + .map((item) => parseFloat(item[col])) + .filter((v) => !isNaN(v)); + summary.min[col] = values.length > 0 ? Math.min(...values) : 0; + } + } + + return summary; +} + +/** + * 그룹 라벨 포맷팅 + */ +function formatGroupLabel( + groupValue: any, + item: any, + format?: string +): string { + if (!format) { + return String(groupValue ?? "(빈 값)"); + } + + // {value}를 그룹 값으로 치환 + let label = format.replace("{value}", String(groupValue ?? "(빈 값)")); + + // {컬럼명} 패턴을 해당 컬럼 값으로 치환 + const columnPattern = /\{([^}]+)\}/g; + label = label.replace(columnPattern, (match, columnName) => { + if (columnName === "value") return String(groupValue ?? ""); + return String(item?.[columnName] ?? ""); + }); + + return label; +} + +/** + * 데이터를 그룹화하는 훅 + */ +export function useGroupedData( + config: TableGroupedConfig, + externalData?: any[], + searchFilters?: Record +): UseGroupedDataResult { + // 원본 데이터 + const [rawData, setRawData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // 그룹 펼침 상태 관리 + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + // 사용자가 수동으로 펼침/접기를 조작했는지 여부 + const [isManuallyControlled, setIsManuallyControlled] = useState(false); + + // 선택 상태 관리 + const [selectedItemIds, setSelectedItemIds] = useState>( + new Set() + ); + + // 테이블명 결정 + const tableName = config.useCustomTable + ? config.customTableName + : config.selectedTable; + + // 데이터 로드 + const fetchData = useCallback(async () => { + if (externalData) { + setRawData(externalData); + return; + } + + if (!tableName) { + setRawData([]); + return; + } + + setIsLoading(true); + setError(null); + + try { + const response = await apiClient.post( + `/table-management/tables/${tableName}/data`, + { + page: 1, + size: 10000, // 그룹화를 위해 전체 데이터 로드 + autoFilter: true, + search: searchFilters || {}, + } + ); + + const responseData = response.data?.data?.data || response.data?.data || []; + setRawData(Array.isArray(responseData) ? responseData : []); + } catch (err: any) { + setError(err.message || "데이터 로드 중 오류 발생"); + setRawData([]); + } finally { + setIsLoading(false); + } + }, [tableName, externalData, searchFilters]); + + // 초기 데이터 로드 + useEffect(() => { + fetchData(); + }, [fetchData]); + + // 외부 데이터 변경 시 동기화 + useEffect(() => { + if (externalData) { + setRawData(externalData); + } + }, [externalData]); + + // 그룹화된 데이터 계산 + const groups = useMemo((): GroupState[] => { + const groupByColumn = config.groupConfig?.groupByColumn; + if (!groupByColumn || rawData.length === 0) { + return []; + } + + // 데이터를 그룹별로 분류 + const groupMap = new Map(); + + for (const item of rawData) { + const groupValue = item[groupByColumn]; + const groupKey = String(groupValue ?? "__null__"); + + if (!groupMap.has(groupKey)) { + groupMap.set(groupKey, []); + } + groupMap.get(groupKey)!.push(item); + } + + // 그룹 배열 생성 + const groupArray: GroupState[] = []; + const defaultExpanded = config.groupConfig?.defaultExpanded ?? true; + + for (const [groupKey, items] of groupMap.entries()) { + const firstItem = items[0]; + const groupValue = + groupKey === "__null__" ? null : firstItem[groupByColumn]; + + // 펼침 상태 결정: 수동 조작 전에는 defaultExpanded, 수동 조작 후에는 expandedGroups 참조 + const isExpanded = isManuallyControlled + ? expandedGroups.has(groupKey) + : defaultExpanded; + + groupArray.push({ + groupKey, + groupLabel: formatGroupLabel( + groupValue, + firstItem, + config.groupConfig?.groupLabelFormat + ), + expanded: isExpanded, + items, + summary: calculateSummary(items, config), + selected: items.every((item) => + selectedItemIds.has(getItemId(item, config)) + ), + selectedItemIds: items + .filter((item) => selectedItemIds.has(getItemId(item, config))) + .map((item) => getItemId(item, config)), + }); + } + + // 정렬 + const sortDirection = config.groupConfig?.sortDirection ?? "asc"; + groupArray.sort((a, b) => { + const comparison = a.groupLabel.localeCompare(b.groupLabel, "ko"); + return sortDirection === "asc" ? comparison : -comparison; + }); + + return groupArray; + }, [rawData, config, expandedGroups, selectedItemIds, isManuallyControlled]); + + // 아이템 ID 추출 + function getItemId(item: any, cfg: TableGroupedConfig): string { + // id 또는 첫 번째 컬럼을 ID로 사용 + if (item.id !== undefined) return String(item.id); + const firstCol = cfg.columns?.[0]?.columnName; + if (firstCol && item[firstCol] !== undefined) return String(item[firstCol]); + return JSON.stringify(item); + } + + // 그룹 토글 + const toggleGroup = useCallback((groupKey: string) => { + setIsManuallyControlled(true); + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(groupKey)) { + next.delete(groupKey); + } else { + next.add(groupKey); + } + return next; + }); + }, []); + + // 전체 펼치기 + const expandAll = useCallback(() => { + setIsManuallyControlled(true); + setExpandedGroups(new Set(groups.map((g) => g.groupKey))); + }, [groups]); + + // 전체 접기 + const collapseAll = useCallback(() => { + setIsManuallyControlled(true); + setExpandedGroups(new Set()); + }, []); + + // 아이템 선택 토글 + const toggleItemSelection = useCallback( + (groupKey: string, itemId: string) => { + setSelectedItemIds((prev) => { + const next = new Set(prev); + if (next.has(itemId)) { + next.delete(itemId); + } else { + // 단일 선택 모드 + if (config.checkboxMode === "single") { + next.clear(); + } + next.add(itemId); + } + return next; + }); + }, + [config.checkboxMode] + ); + + // 그룹 전체 선택 토글 + const toggleGroupSelection = useCallback( + (groupKey: string) => { + const group = groups.find((g) => g.groupKey === groupKey); + if (!group) return; + + setSelectedItemIds((prev) => { + const next = new Set(prev); + const groupItemIds = group.items.map((item) => getItemId(item, config)); + const allSelected = groupItemIds.every((id) => next.has(id)); + + if (allSelected) { + // 전체 해제 + for (const id of groupItemIds) { + next.delete(id); + } + } else { + // 전체 선택 + if (config.checkboxMode === "single") { + next.clear(); + next.add(groupItemIds[0]); + } else { + for (const id of groupItemIds) { + next.add(id); + } + } + } + return next; + }); + }, + [groups, config] + ); + + // 전체 선택 토글 + const toggleAllSelection = useCallback(() => { + const allItemIds = rawData.map((item) => getItemId(item, config)); + const allSelected = allItemIds.every((id) => selectedItemIds.has(id)); + + if (allSelected) { + setSelectedItemIds(new Set()); + } else { + if (config.checkboxMode === "single" && allItemIds.length > 0) { + setSelectedItemIds(new Set([allItemIds[0]])); + } else { + setSelectedItemIds(new Set(allItemIds)); + } + } + }, [rawData, config, selectedItemIds]); + + // 선택된 아이템 목록 + const selectedItems = useMemo(() => { + return rawData.filter((item) => + selectedItemIds.has(getItemId(item, config)) + ); + }, [rawData, selectedItemIds, config]); + + // 모두 선택 여부 + const isAllSelected = useMemo(() => { + if (rawData.length === 0) return false; + return rawData.every((item) => + selectedItemIds.has(getItemId(item, config)) + ); + }, [rawData, selectedItemIds, config]); + + // 일부 선택 여부 + const isIndeterminate = useMemo(() => { + if (rawData.length === 0) return false; + const selectedCount = rawData.filter((item) => + selectedItemIds.has(getItemId(item, config)) + ).length; + return selectedCount > 0 && selectedCount < rawData.length; + }, [rawData, selectedItemIds, config]); + + return { + groups, + isLoading, + error, + toggleGroup, + expandAll, + collapseAll, + toggleItemSelection, + toggleGroupSelection, + toggleAllSelection, + selectedItems, + isAllSelected, + isIndeterminate, + refresh: fetchData, + rawData, + totalCount: rawData.length, + groupCount: groups.length, + }; +} + +export default useGroupedData; diff --git a/frontend/lib/registry/components/v2-table-grouped/index.ts b/frontend/lib/registry/components/v2-table-grouped/index.ts new file mode 100644 index 00000000..7e984490 --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/index.ts @@ -0,0 +1,75 @@ +"use client"; + +import React from "react"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import type { WebType } from "@/types/screen"; +import { TableGroupedComponent } from "./TableGroupedComponent"; +import { TableGroupedConfigPanel } from "./TableGroupedConfigPanel"; +import { TableGroupedConfig } from "./types"; + +/** + * V2 그룹화 테이블 컴포넌트 Definition + * + * 데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능을 제공합니다. + * v2-table-list를 기반으로 확장된 컴포넌트입니다. + */ +export const V2TableGroupedDefinition = createComponentDefinition({ + id: "v2-table-grouped", + name: "그룹화 테이블", + nameEng: "Grouped Table Component", + description: "데이터를 그룹화하여 접기/펼치기 기능을 제공하는 테이블", + category: ComponentCategory.DISPLAY, + webType: "text", + component: TableGroupedComponent, + defaultConfig: { + // 테이블 설정 + selectedTable: "", + useCustomTable: false, + customTableName: "", + + // 그룹화 설정 + groupConfig: { + groupByColumn: "", + groupLabelFormat: "{value}", + defaultExpanded: true, + sortDirection: "asc", + summary: { + showCount: true, + sumColumns: [], + }, + }, + + // 컬럼 설정 + columns: [], + + // 체크박스 설정 + showCheckbox: false, + checkboxMode: "multi", + + // 페이지네이션 설정 + pagination: { + enabled: false, + pageSize: 10, + }, + + // UI 설정 + isReadOnly: false, + rowClickable: true, + showExpandAllButton: true, + groupHeaderStyle: "default", + emptyMessage: "데이터가 없습니다.", + height: "auto", + maxHeight: 600, + }, + defaultSize: { width: 800, height: 500 }, + configPanel: TableGroupedConfigPanel, + icon: "Layers", + tags: ["테이블", "그룹화", "접기", "펼치기", "목록"], + version: "1.0.0", + author: "개발팀", + documentation: "", +}); + +// 타입 내보내기 +export type { TableGroupedConfig } from "./types"; diff --git a/frontend/lib/registry/components/v2-table-grouped/types.ts b/frontend/lib/registry/components/v2-table-grouped/types.ts new file mode 100644 index 00000000..20bfc77b --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/types.ts @@ -0,0 +1,299 @@ +"use client"; + +import { ComponentConfig } from "@/types/component"; +import { ColumnConfig, EntityJoinInfo } from "../v2-table-list/types"; + +/** + * 그룹 요약 설정 + */ +export interface GroupSummaryConfig { + /** 합계를 계산할 컬럼 목록 */ + sumColumns?: string[]; + /** 개수 표시 여부 */ + showCount?: boolean; + /** 평균 컬럼 목록 */ + avgColumns?: string[]; + /** 최대값 컬럼 목록 */ + maxColumns?: string[]; + /** 최소값 컬럼 목록 */ + minColumns?: string[]; +} + +/** + * 그룹화 설정 + */ +export interface GroupConfig { + /** 그룹화 기준 컬럼 */ + groupByColumn: string; + + /** 그룹 표시 형식 (예: "{item_name} ({item_code})") */ + groupLabelFormat?: string; + + /** 그룹 요약 설정 */ + summary?: GroupSummaryConfig; + + /** 초기 펼침 상태 (기본값: true) */ + defaultExpanded?: boolean; + + /** 중첩 그룹 (다중 그룹화) - 향후 확장 */ + nestedGroup?: GroupConfig; + + /** 그룹 정렬 방식 */ + sortDirection?: "asc" | "desc"; + + /** 그룹 정렬 컬럼 (기본: groupByColumn) */ + sortColumn?: string; +} + +/** + * 그룹화 테이블 설정 (ComponentConfig 기반) + */ +export interface TableGroupedConfig extends ComponentConfig { + /** 테이블명 */ + selectedTable?: string; + + /** 커스텀 테이블 사용 여부 */ + useCustomTable?: boolean; + + /** 커스텀 테이블명 */ + customTableName?: string; + + /** 그룹화 설정 */ + groupConfig: GroupConfig; + + /** 컬럼 설정 */ + columns?: ColumnConfig[]; + + /** 체크박스 표시 여부 */ + showCheckbox?: boolean; + + /** 체크박스 모드 */ + checkboxMode?: "single" | "multi"; + + /** 페이지네이션 (그룹 단위) */ + pagination?: { + enabled: boolean; + pageSize: number; + }; + + /** 기본 정렬 설정 */ + defaultSort?: { + column: string; + direction: "asc" | "desc"; + }; + + /** 읽기 전용 */ + isReadOnly?: boolean; + + /** 행 클릭 가능 여부 */ + rowClickable?: boolean; + + /** 높이 설정 */ + height?: number | string; + + /** 최대 높이 */ + maxHeight?: number | string; + + /** 전체 펼치기/접기 버튼 표시 */ + showExpandAllButton?: boolean; + + /** 그룹 헤더 스타일 */ + groupHeaderStyle?: "default" | "compact" | "card"; + + /** 빈 데이터 메시지 */ + emptyMessage?: string; + + /** Entity 조인 컬럼 정보 */ + entityJoinColumns?: Array<{ + columnName: string; + entityJoinInfo: EntityJoinInfo; + }>; + + /** 데이터 필터 */ + dataFilter?: { + column: string; + operator: "eq" | "ne" | "gt" | "lt" | "gte" | "lte" | "like" | "in"; + value: any; + }[]; + + /** 연결된 필터 설정 (다른 컴포넌트와 연동) */ + linkedFilters?: LinkedFilterConfig[]; +} + +/** + * 연결된 필터 설정 + */ +export interface LinkedFilterConfig { + /** 소스 컴포넌트 ID */ + sourceComponentId: string; + /** 소스 필드 */ + sourceField?: string; + /** 대상 컬럼 */ + targetColumn: string; + /** 활성화 여부 */ + enabled?: boolean; +} + +/** + * 그룹 요약 데이터 + */ +export interface GroupSummary { + /** 개수 */ + count: number; + /** 합계 (컬럼별) */ + sum?: Record; + /** 평균 (컬럼별) */ + avg?: Record; + /** 최대값 (컬럼별) */ + max?: Record; + /** 최소값 (컬럼별) */ + min?: Record; +} + +/** + * 그룹 상태 + */ +export interface GroupState { + /** 그룹 키 (groupByColumn 값) */ + groupKey: string; + + /** 그룹 표시 라벨 */ + groupLabel: string; + + /** 펼침 여부 */ + expanded: boolean; + + /** 그룹 내 데이터 */ + items: any[]; + + /** 그룹 요약 데이터 */ + summary: GroupSummary; + + /** 그룹 선택 여부 */ + selected?: boolean; + + /** 그룹 내 선택된 아이템 ID 목록 */ + selectedItemIds?: string[]; +} + +/** + * 선택 이벤트 데이터 + */ +export interface SelectionChangeEvent { + /** 선택된 그룹 키 목록 */ + selectedGroups: string[]; + /** 선택된 아이템 (전체) */ + selectedItems: any[]; + /** 모두 선택 여부 */ + isAllSelected: boolean; +} + +/** + * 그룹 토글 이벤트 + */ +export interface GroupToggleEvent { + /** 그룹 키 */ + groupKey: string; + /** 펼침 상태 */ + expanded: boolean; +} + +/** + * 행 클릭 이벤트 + */ +export interface RowClickEvent { + /** 클릭된 행 데이터 */ + row: any; + /** 그룹 키 */ + groupKey: string; + /** 그룹 내 인덱스 */ + indexInGroup: number; +} + +/** + * TableGroupedComponent Props + */ +export interface TableGroupedComponentProps { + /** 컴포넌트 설정 */ + config: TableGroupedConfig; + + /** 디자인 모드 여부 */ + isDesignMode?: boolean; + + /** 폼 데이터 (formData 전달용) */ + formData?: Record; + + /** 선택 변경 이벤트 */ + onSelectionChange?: (event: SelectionChangeEvent) => void; + + /** 그룹 토글 이벤트 */ + onGroupToggle?: (event: GroupToggleEvent) => void; + + /** 행 클릭 이벤트 */ + onRowClick?: (event: RowClickEvent) => void; + + /** 외부에서 주입된 데이터 (선택) */ + externalData?: any[]; + + /** 로딩 상태 (외부 제어) */ + isLoading?: boolean; + + /** 에러 상태 (외부 제어) */ + error?: string; + + /** 컴포넌트 ID */ + componentId?: string; +} + +/** + * useGroupedData 훅 반환 타입 + */ +export interface UseGroupedDataResult { + /** 그룹화된 데이터 */ + groups: GroupState[]; + + /** 로딩 상태 */ + isLoading: boolean; + + /** 에러 */ + error: string | null; + + /** 그룹 펼치기/접기 토글 */ + toggleGroup: (groupKey: string) => void; + + /** 전체 펼치기 */ + expandAll: () => void; + + /** 전체 접기 */ + collapseAll: () => void; + + /** 아이템 선택 토글 */ + toggleItemSelection: (groupKey: string, itemId: string) => void; + + /** 그룹 전체 선택 토글 */ + toggleGroupSelection: (groupKey: string) => void; + + /** 전체 선택 토글 */ + toggleAllSelection: () => void; + + /** 선택된 아이템 목록 */ + selectedItems: any[]; + + /** 모두 선택 여부 */ + isAllSelected: boolean; + + /** 일부 선택 여부 */ + isIndeterminate: boolean; + + /** 데이터 새로고침 */ + refresh: () => void; + + /** 원본 데이터 */ + rawData: any[]; + + /** 전체 데이터 개수 */ + totalCount: number; + + /** 그룹 개수 */ + groupCount: number; +}