docs: V2 컴포넌트 목록 및 v2-table-grouped 개발 상태 업데이트

- V2 컴포넌트 목록에 `v2-table-grouped`를 추가하여 총 18개로 업데이트하였습니다.
- `v2-table-grouped`의 구현 완료 상태를 체크리스트에 반영하였으며, 관련 문서화 작업도 완료하였습니다.
- 생산계획관리 화면의 신규 컴포넌트 개발 상태를 업데이트하여 `v2-table-grouped`의 완료를 명시하였습니다.
This commit is contained in:
kjs 2026-02-02 09:40:37 +09:00
parent 17e212118c
commit b9d6e5854d
13 changed files with 2457 additions and 22 deletions

View File

@ -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 | 다차원 데이터 분석 피벗 테이블 |

View File

@ -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

View File

@ -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 |

View File

@ -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"; // 그룹화 테이블
/**
*

View File

@ -0,0 +1,162 @@
# v2-table-grouped (그룹화 테이블)
## 개요
데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능을 제공하는 테이블 컴포넌트입니다.
## 주요 기능
- **그룹화**: 지정된 컬럼 기준으로 데이터를 그룹핑
- **접기/펼치기**: 그룹 헤더 클릭으로 하위 항목 토글
- **그룹 요약**: 그룹별 개수, 합계, 평균, 최대/최소값 표시
- **체크박스 선택**: 그룹 단위 또는 개별 항목 선택
- **전체 펼치기/접기**: 모든 그룹 일괄 토글 버튼
## 사용 예시
```tsx
import { TableGroupedComponent } from "./TableGroupedComponent";
<TableGroupedComponent
config={{
selectedTable: "production_plan_mng",
groupConfig: {
groupByColumn: "item_code",
groupLabelFormat: "{item_name} ({item_code})",
defaultExpanded: true,
summary: {
showCount: true,
sumColumns: ["quantity"],
},
},
columns: [
{ columnName: "plan_no", displayName: "계획번호", visible: true },
{ columnName: "plan_date", displayName: "계획일", visible: true },
{ columnName: "quantity", displayName: "수량", visible: true, format: "number" },
],
showCheckbox: true,
checkboxMode: "multi",
showExpandAllButton: true,
}}
onSelectionChange={(event) => 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`: 마스터-디테일 레이아웃

View File

@ -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<Record<string, any>>({});
// 필터 및 그룹 설정 상태 (검색필터 연동용)
const [filters, setFilters] = useState<any[]>([]);
const [grouping, setGrouping] = useState<string[]>([]);
const [columnVisibility, setColumnVisibility] = useState<any[]>([]);
// 그룹화 데이터 훅 (검색 필터 전달)
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<string, any> = {};
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<string>();
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<string, any> = {};
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 (
<div className="rounded-md border bg-muted/20 p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FoldVertical className="h-4 w-4" />
<span> </span>
{config.groupConfig?.groupByColumn && (
<span className="text-xs">
(: {config.groupConfig.groupByColumn})
</span>
)}
</div>
<div className="mt-2 text-xs text-muted-foreground">
: {config.useCustomTable ? config.customTableName : config.selectedTable || "(미설정)"}
</div>
</div>
);
}
// 로딩 상태
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground"> ...</span>
</div>
);
}
// 에러 상태
if (error) {
return (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
{error}
</div>
);
}
// 데이터 없음
if (groups.length === 0) {
return (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
{config.emptyMessage || "데이터가 없습니다."}
</div>
);
}
return (
<div
className="v2-table-grouped flex flex-col"
style={{
height: config.height,
maxHeight: config.maxHeight,
}}
>
{/* 툴바 */}
{config.showExpandAllButton && (
<div className="flex items-center justify-between border-b bg-muted/30 px-3 py-2">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={expandAll}
className="h-7 text-xs"
>
<UnfoldVertical className="mr-1 h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={collapseAll}
className="h-7 text-xs"
>
<FoldVertical className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="text-xs text-muted-foreground">
{groupCount} | {totalCount}
</div>
</div>
)}
{/* 테이블 */}
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse text-sm">
{/* 테이블 헤더 */}
<thead className="sticky top-0 z-10 bg-muted">
<tr>
{/* 전체 선택 체크박스 */}
{config.showCheckbox && (
<th className="w-10 whitespace-nowrap border-b px-3 py-2 text-left">
<Checkbox
checked={isAllSelected}
onCheckedChange={toggleAllSelection}
className={cn(isIndeterminate && "data-[state=checked]:bg-muted")}
/>
</th>
)}
{/* 컬럼 헤더 */}
{visibleColumns.map((col) => (
<th
key={col.columnName}
className={cn(
"whitespace-nowrap border-b px-3 py-2 font-medium text-muted-foreground",
col.align === "center" && "text-center",
col.align === "right" && "text-right"
)}
style={{ width: col.width ? `${col.width}px` : "auto" }}
>
{col.displayName || col.columnName}
</th>
))}
</tr>
</thead>
{/* 테이블 바디 */}
<tbody>
{groups.map((group) => (
<React.Fragment key={group.groupKey}>
{/* 그룹 헤더 */}
<GroupHeader
group={group}
config={config}
onToggle={() => 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 (
<tr
key={itemId}
className={cn(
"border-b transition-colors",
config.rowClickable && "cursor-pointer hover:bg-muted/50",
isSelected && "bg-primary/10"
)}
onClick={() => handleRowClick(item, group.groupKey, idx)}
>
{/* 체크박스 */}
{config.showCheckbox && (
<td
className="px-3 py-2"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={isSelected}
onCheckedChange={() =>
toggleItemSelection(group.groupKey, itemId)
}
/>
</td>
)}
{/* 데이터 컬럼 */}
{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 (
<td
key={col.columnName}
className={cn(
"px-3 py-2",
col.align === "center" && "text-center",
col.align === "right" && "text-right"
)}
>
{displayValue ?? "-"}
</td>
);
})}
</tr>
);
})}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
);
}
export default TableGroupedComponent;

View File

@ -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<TableInfo[]>([]);
const [tableColumns, setTableColumns] = useState<ColumnConfig[]>([]);
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<TableGroupedConfig>) => {
onConfigChange({ ...config, ...updates });
};
// 그룹 설정 업데이트 헬퍼
const updateGroupConfig = (
updates: Partial<TableGroupedConfig["groupConfig"]>
) => {
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<LinkedFilterConfig>
) => {
const filters = [...(config.linkedFilters || [])];
filters[index] = { ...filters[index], ...updates };
updateConfig({ linkedFilters: filters });
};
return (
<div className="space-y-4 p-4">
<Accordion type="multiple" defaultValue={["table", "group", "display"]}>
{/* 테이블 설정 */}
<AccordionItem value="table">
<AccordionTrigger className="text-sm font-medium">
</AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
{/* 커스텀 테이블 사용 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.useCustomTable}
onCheckedChange={(checked) =>
updateConfig({ useCustomTable: checked })
}
/>
</div>
{/* 테이블 선택 */}
{config.useCustomTable ? (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.customTableName || ""}
onChange={(e) =>
updateConfig({ customTableName: e.target.value })
}
placeholder="테이블명 입력"
className="h-8 text-xs"
/>
</div>
) : (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableSelectOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
>
{loadingTables ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
...
</>
) : config.selectedTable ? (
<span className="truncate">
{tables.find((t) => t.tableName === config.selectedTable)
?.displayName || config.selectedTable}
<span className="ml-1 text-muted-foreground">
({config.selectedTable})
</span>
</span>
) : (
"테이블 검색..."
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command
filter={(value, search) => {
// 테이블명 또는 라벨명에 검색어가 포함되면 1, 아니면 0
const lowerSearch = search.toLowerCase();
if (value.toLowerCase().includes(lowerSearch)) {
return 1;
}
return 0;
}}
>
<CommandInput
placeholder="테이블명 또는 라벨 검색..."
className="text-xs"
/>
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName} ${table.tableName}`}
onSelect={() => {
updateConfig({ selectedTable: table.tableName });
setTableSelectOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.selectedTable === table.tableName
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
<span className="text-[10px] text-muted-foreground">
{table.tableName}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
</AccordionContent>
</AccordionItem>
{/* 그룹화 설정 */}
<AccordionItem value="group">
<AccordionTrigger className="text-sm font-medium">
</AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
{/* 그룹화 기준 컬럼 */}
<div className="space-y-1">
<Label className="text-xs"> *</Label>
<Select
value={config.groupConfig?.groupByColumn || ""}
onValueChange={(value) =>
updateGroupConfig({ groupByColumn: value })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 그룹 라벨 형식 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.groupConfig?.groupLabelFormat || "{value}"}
onChange={(e) =>
updateGroupConfig({ groupLabelFormat: e.target.value })
}
placeholder="{value} ({컬럼명})"
className="h-8 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
{"{value}"} = , {"{컬럼명}"} =
</p>
</div>
{/* 기본 펼침 상태 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.groupConfig?.defaultExpanded ?? true}
onCheckedChange={(checked) =>
updateGroupConfig({ defaultExpanded: checked })
}
/>
</div>
{/* 그룹 정렬 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.groupConfig?.sortDirection || "asc"}
onValueChange={(value: "asc" | "desc") =>
updateGroupConfig({ sortDirection: value })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortDirectionOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 개수 표시 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.groupConfig?.summary?.showCount ?? true}
onCheckedChange={(checked) =>
updateGroupConfig({
summary: {
...config.groupConfig?.summary,
showCount: checked,
},
})
}
/>
</div>
{/* 합계 컬럼 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<div className="max-h-32 space-y-1 overflow-y-auto rounded border p-2">
{tableColumns.map((col) => (
<div
key={col.columnName}
className="flex items-center gap-2 text-xs"
>
<Checkbox
id={`sum-${col.columnName}`}
checked={
config.groupConfig?.summary?.sumColumns?.includes(
col.columnName
) ?? false
}
onCheckedChange={() => toggleSumColumn(col.columnName)}
/>
<label
htmlFor={`sum-${col.columnName}`}
className="cursor-pointer"
>
{col.displayName || col.columnName}
</label>
</div>
))}
</div>
</div>
</AccordionContent>
</AccordionItem>
{/* 표시 설정 */}
<AccordionItem value="display">
<AccordionTrigger className="text-sm font-medium">
</AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
{/* 체크박스 표시 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.showCheckbox}
onCheckedChange={(checked) =>
updateConfig({ showCheckbox: checked })
}
/>
</div>
{/* 체크박스 모드 */}
{config.showCheckbox && (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.checkboxMode || "multi"}
onValueChange={(value: "single" | "multi") =>
updateConfig({ checkboxMode: value })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{checkboxModeOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 그룹 헤더 스타일 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.groupHeaderStyle || "default"}
onValueChange={(value: "default" | "compact" | "card") =>
updateConfig({ groupHeaderStyle: value })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{groupHeaderStyleOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 전체 펼치기/접기 버튼 */}
<div className="flex items-center justify-between">
<Label className="text-xs">/ </Label>
<Switch
checked={config.showExpandAllButton ?? true}
onCheckedChange={(checked) =>
updateConfig({ showExpandAllButton: checked })
}
/>
</div>
{/* 행 클릭 가능 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.rowClickable ?? true}
onCheckedChange={(checked) =>
updateConfig({ rowClickable: checked })
}
/>
</div>
{/* 최대 높이 */}
<div className="space-y-1">
<Label className="text-xs"> (px)</Label>
<Input
type="number"
value={config.maxHeight || 600}
onChange={(e) =>
updateConfig({ maxHeight: parseInt(e.target.value) || 600 })
}
className="h-8 text-xs"
/>
</div>
{/* 빈 데이터 메시지 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.emptyMessage || ""}
onChange={(e) =>
updateConfig({ emptyMessage: e.target.value })
}
placeholder="데이터가 없습니다."
className="h-8 text-xs"
/>
</div>
</AccordionContent>
</AccordionItem>
{/* 컬럼 설정 */}
<AccordionItem value="columns">
<AccordionTrigger className="text-sm font-medium">
</AccordionTrigger>
<AccordionContent className="space-y-2 pt-2">
<div className="max-h-48 space-y-1 overflow-y-auto rounded border p-2">
{(config.columns || tableColumns).map((col) => (
<div
key={col.columnName}
className="flex items-center gap-2 text-xs"
>
<Checkbox
id={`col-${col.columnName}`}
checked={col.visible !== false}
onCheckedChange={() =>
toggleColumnVisibility(col.columnName)
}
/>
<label
htmlFor={`col-${col.columnName}`}
className="cursor-pointer"
>
{col.displayName || col.columnName}
</label>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
{/* 연동 설정 */}
<AccordionItem value="linked">
<AccordionTrigger className="text-sm font-medium">
</AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Button
variant="outline"
size="sm"
onClick={addLinkedFilter}
className="h-6 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(config.linkedFilters || []).length === 0 ? (
<p className="text-xs text-muted-foreground">
.
</p>
) : (
<div className="space-y-2">
{(config.linkedFilters || []).map((filter, idx) => (
<div
key={idx}
className="space-y-2 rounded border p-2"
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium">
#{idx + 1}
</span>
<div className="flex items-center gap-2">
<Switch
checked={filter.enabled !== false}
onCheckedChange={(checked) =>
updateLinkedFilter(idx, { enabled: checked })
}
/>
<Button
variant="ghost"
size="sm"
onClick={() => removeLinkedFilter(idx)}
className="h-6 w-6 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> ID</Label>
<Input
value={filter.sourceComponentId}
onChange={(e) =>
updateLinkedFilter(idx, {
sourceComponentId: e.target.value,
})
}
placeholder="예: search-filter-1"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={filter.sourceField || "value"}
onChange={(e) =>
updateLinkedFilter(idx, {
sourceField: e.target.value,
})
}
placeholder="value"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={filter.targetColumn}
onValueChange={(value) =>
updateLinkedFilter(idx, { targetColumn: value })
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</div>
)}
<p className="text-[10px] text-muted-foreground">
( ) .
</p>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}
export default TableGroupedConfigPanel;

View File

@ -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 (
<TableGroupedComponent
{...this.props}
config={this.props.component?.componentConfig || {}}
isDesignMode={this.props.isDesignMode}
formData={this.props.formData}
componentId={this.props.component?.id}
/>
);
}
// 설정 변경 핸들러
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);
}

View File

@ -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 (
<tr className="group-header-row">
<td
colSpan={columnCount}
className="p-0"
onClick={(e) => {
// 체크박스 클릭 시 토글 방지
if ((e.target as HTMLElement).closest('[data-checkbox="true"]')) {
return;
}
onToggle();
}}
>
<div className={headerClasses}>
{/* 펼치기/접기 아이콘 */}
<span className="flex-shrink-0 text-muted-foreground">
{group.expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</span>
{/* 체크박스 */}
{showCheckbox && onSelectToggle && (
<span
data-checkbox="true"
onClick={(e) => {
e.stopPropagation();
onSelectToggle();
}}
>
<Checkbox
checked={group.selected}
className={cn(
"data-[state=checked]:bg-primary",
isIndeterminate && "data-[state=checked]:bg-muted"
)}
/>
{isIndeterminate && (
<Minus className="absolute h-3 w-3 text-muted-foreground" />
)}
</span>
)}
{/* 그룹 라벨 */}
<span className="font-medium text-foreground">{group.groupLabel}</span>
{/* 요약 정보 */}
{summaryText && (
<span className="ml-auto text-sm text-muted-foreground">
{summaryText}
</span>
)}
</div>
</td>
</tr>
);
}
export default GroupHeader;

View File

@ -0,0 +1,64 @@
import { TableGroupedConfig } from "./types";
/**
* v2-table-grouped
*/
export const defaultTableGroupedConfig: Partial<TableGroupedConfig> = {
// 그룹화 기본 설정
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: "내림차순" },
];

View File

@ -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<string, any>
): UseGroupedDataResult {
// 원본 데이터
const [rawData, setRawData] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 그룹 펼침 상태 관리
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
// 사용자가 수동으로 펼침/접기를 조작했는지 여부
const [isManuallyControlled, setIsManuallyControlled] = useState(false);
// 선택 상태 관리
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(
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<string, any[]>();
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;

View File

@ -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";

View File

@ -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<string, number>;
/** 평균 (컬럼별) */
avg?: Record<string, number>;
/** 최대값 (컬럼별) */
max?: Record<string, number>;
/** 최소값 (컬럼별) */
min?: Record<string, number>;
}
/**
*
*/
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<string, any>;
/** 선택 변경 이벤트 */
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;
}