ERP-node/테이블_그룹핑_기능_구현_계획서.md

366 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 테이블 그룹핑 기능 구현 계획서
## 📋 개요
테이블 리스트 컴포넌트와 플로우 위젯에 그룹핑 기능을 추가하여, 사용자가 선택한 컬럼(들)을 기준으로 데이터를 그룹화하여 표시합니다.
## 🎯 핵심 요구사항
### 1. 기능 요구사항
- ✅ 그룹핑할 컬럼을 다중 선택 가능
- ✅ 선택한 컬럼 순서대로 계층적 그룹화
- ✅ 그룹 헤더에 그룹 정보와 데이터 개수 표시
- ✅ 그룹 펼치기/접기 기능
- ✅ localStorage에 그룹 설정 저장/복원
- ✅ 그룹 해제 기능
### 2. 적용 대상
- TableListComponent (`frontend/lib/registry/components/table-list/TableListComponent.tsx`)
- FlowWidget (`frontend/components/screen/widgets/FlowWidget.tsx`)
## 🎨 UI 디자인
### 그룹 설정 다이얼로그
```tsx
┌─────────────────────────────────────┐
📊 그룹 설정
데이터를 그룹화할 컬럼을 선택하세요
├─────────────────────────────────────┤
[x] 통화
[ ] 단위
[ ] 품목코드
[ ] 품목명
[ ] 규격
💡 선택된 그룹: 통화
├─────────────────────────────────────┤
[취소] [적용]
└─────────────────────────────────────┘
```
### 그룹화된 테이블 표시
```tsx
┌─────────────────────────────────────────────────────┐
📦 판매품목 목록 3 [🎨 그룹: 통화 ×]
├─────────────────────────────────────────────────────┤
통화: KRW > 단위: EA (2)
┌─────────────────────────────────────────────┐
품목코드 품목명 규격 단위
├─────────────────────────────────────────────┤
SALE-001 볼트 M8x20 M8x20 EA
SALE-004 스프링 와셔 M10 EA
└─────────────────────────────────────────────┘
통화: USD > 단위: EA (1)
┌─────────────────────────────────────────────┐
품목코드 품목명 규격 단위
├─────────────────────────────────────────────┤
SALE-002 너트 M8 M8 EA
└─────────────────────────────────────────────┘
└─────────────────────────────────────────────────────┘
```
## 🔧 기술 구현
### 1. 상태 관리
```typescript
// 그룹 설정 관련 상태
const [groupByColumns, setGroupByColumns] = useState<string[]>([]); // 그룹화할 컬럼 목록
const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set()); // 접힌 그룹
```
### 2. 데이터 그룹화 로직
```typescript
interface GroupedData {
groupKey: string; // "통화:KRW > 단위:EA"
groupValues: Record<string, any>; // { 통화: "KRW", 단위: "EA" }
items: any[]; // 그룹에 속한 데이터
count: number; // 항목 개수
}
const groupDataByColumns = (
data: any[],
groupColumns: string[]
): GroupedData[] => {
if (groupColumns.length === 0) return [];
const grouped = new Map<string, any[]>();
data.forEach(item => {
// 그룹 키 생성: "통화:KRW > 단위:EA"
const keyParts = groupColumns.map(col => `${col}:${item[col] || '-'}`);
const groupKey = keyParts.join(' > ');
if (!grouped.has(groupKey)) {
grouped.set(groupKey, []);
}
grouped.get(groupKey)!.push(item);
});
return Array.from(grouped.entries()).map(([groupKey, items]) => {
const groupValues: Record<string, any> = {};
groupColumns.forEach(col => {
groupValues[col] = items[0]?.[col];
});
return {
groupKey,
groupValues,
items,
count: items.length,
};
});
};
```
### 3. localStorage 저장/로드
```typescript
// 저장 키
const groupSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
return `table-list-group-${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable]);
// 그룹 설정 저장
const saveGroupSettings = useCallback(() => {
if (!groupSettingKey) return;
try {
localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
setIsGroupSettingOpen(false);
toast.success("그룹 설정이 저장되었습니다");
} catch (error) {
console.error("그룹 설정 저장 실패:", error);
toast.error("설정 저장에 실패했습니다");
}
}, [groupSettingKey, groupByColumns]);
// 그룹 설정 로드
useEffect(() => {
if (!groupSettingKey || visibleColumns.length === 0) return;
try {
const saved = localStorage.getItem(groupSettingKey);
if (saved) {
const savedGroups = JSON.parse(saved);
setGroupByColumns(savedGroups);
}
} catch (error) {
console.error("그룹 설정 불러오기 실패:", error);
}
}, [groupSettingKey, visibleColumns]);
```
### 4. 그룹 헤더 렌더링
```tsx
const renderGroupHeader = (group: GroupedData) => {
const isCollapsed = collapsedGroups.has(group.groupKey);
return (
<div
className="bg-muted/50 flex items-center gap-3 border-b p-3 cursor-pointer hover:bg-muted"
onClick={() => toggleGroupCollapse(group.groupKey)}
>
{/* 펼치기/접기 아이콘 */}
{isCollapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{/* 그룹 정보 */}
<span className="font-medium text-sm">
{groupByColumns.map((col, idx) => (
<span key={col}>
{idx > 0 && <span className="text-muted-foreground"> &gt; </span>}
<span className="text-muted-foreground">{columnLabels[col] || col}:</span>
{" "}
<span className="text-foreground">{group.groupValues[col]}</span>
</span>
))}
</span>
{/* 항목 개수 */}
<span className="text-muted-foreground text-xs ml-auto">
({group.count})
</span>
</div>
);
};
```
### 5. 그룹 설정 다이얼로그
```tsx
<Dialog open={isGroupSettingOpen} onOpenChange={setIsGroupSettingOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">그룹 설정</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 컬럼 목록 */}
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded border p-2">
{visibleColumns
.filter((col) => col.columnName !== "__checkbox__")
.map((col) => (
<div key={col.columnName} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
<Checkbox
id={`group-${col.columnName}`}
checked={groupByColumns.includes(col.columnName)}
onCheckedChange={() => toggleGroupColumn(col.columnName)}
/>
<Label
htmlFor={`group-${col.columnName}`}
className="flex-1 cursor-pointer text-xs font-normal sm:text-sm"
>
{columnLabels[col.columnName] || col.displayName || col.columnName}
</Label>
</div>
))}
</div>
{/* 선택된 그룹 안내 */}
<div className="text-muted-foreground bg-muted/30 rounded p-3 text-xs">
{groupByColumns.length === 0 ? (
<span>그룹화할 컬럼을 선택하세요</span>
) : (
<span>
선택된 그룹: <span className="text-primary font-semibold">
{groupByColumns.map((col) => columnLabels[col] || col).join(" → ")}
</span>
</span>
)}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsGroupSettingOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
취소
</Button>
<Button onClick={saveGroupSettings} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
적용
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
```
### 6. 그룹 해제 버튼
```tsx
{/* 헤더 영역 */}
<div className="flex items-center justify-between">
<h2>{tableLabel}</h2>
<div className="flex items-center gap-2">
{/* 그룹 표시 배지 */}
{groupByColumns.length > 0 && (
<div className="bg-primary/10 text-primary flex items-center gap-2 rounded px-3 py-1 text-xs">
<span>그룹: {groupByColumns.map(col => columnLabels[col] || col).join(", ")}</span>
<button
onClick={() => {
setGroupByColumns([]);
localStorage.removeItem(groupSettingKey || "");
toast.success("그룹이 해제되었습니다");
}}
className="hover:bg-primary/20 rounded p-0.5"
>
<X className="h-3 w-3" />
</button>
</div>
)}
{/* 그룹 설정 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => setIsGroupSettingOpen(true)}
>
<Layers className="mr-2 h-4 w-4" />
그룹 설정
</Button>
</div>
</div>
```
## 📝 구현 순서
### Phase 1: TableListComponent 구현
1. ✅ 상태 관리 추가 (groupByColumns, isGroupSettingOpen, collapsedGroups)
2. ✅ 그룹화 로직 구현 (groupDataByColumns 함수)
3. ✅ localStorage 저장/로드 로직
4. ✅ 그룹 설정 다이얼로그 UI
5. ✅ 그룹 헤더 렌더링
6. ✅ 그룹별 데이터 렌더링
7. ✅ 그룹 해제 기능
### Phase 2: FlowWidget 구현
1. ✅ TableListComponent와 동일한 로직 적용
2. ✅ 스텝 데이터에 그룹화 적용
3. ✅ UI 통일성 유지
### Phase 3: 테스트 및 최적화
1. ✅ 다중 그룹 계층 테스트
2. ✅ 대량 데이터 성능 테스트
3. ✅ localStorage 저장/복원 테스트
4. ✅ 그룹 펼치기/접기 테스트
## 🎯 예상 효과
### 사용자 경험 개선
- 데이터를 논리적으로 그룹화하여 가독성 향상
- 대량 데이터를 효율적으로 탐색 가능
- 사용자 정의 뷰 제공
### 데이터 분석 지원
- 카테고리별 데이터 분석 용이
- 통계 정보 제공 (그룹별 개수)
- 계층적 데이터 구조 시각화
## ⚠️ 주의사항
### 성능 고려사항
- 그룹화는 클라이언트 측에서 수행
- 대량 데이터의 경우 성능 저하 가능
- 필요시 서버 측 그룹화로 전환 검토
### 사용성
- 그룹화 해제가 쉽게 가능해야 함
- 그룹 설정이 직관적이어야 함
- 모바일에서도 사용 가능한 UI
## 📊 구현 상태
- [ ] Phase 1: TableListComponent 구현
- [ ] 상태 관리 추가
- [ ] 그룹화 로직 구현
- [ ] localStorage 연동
- [ ] UI 구현
- [ ] Phase 2: FlowWidget 구현
- [ ] Phase 3: 테스트 및 최적화
---
**작성일**: 2025-11-03
**버전**: 1.0
**상태**: 구현 예정