2017 lines
58 KiB
Markdown
2017 lines
58 KiB
Markdown
# 테이블 검색 필터 컴포넌트 분리 및 통합 계획서
|
|
|
|
## 📋 목차
|
|
|
|
1. [현황 분석](#1-현황-분석)
|
|
2. [목표 및 요구사항](#2-목표-및-요구사항)
|
|
3. [아키텍처 설계](#3-아키텍처-설계)
|
|
4. [구현 계획](#4-구현-계획)
|
|
5. [파일 구조](#5-파일-구조)
|
|
6. [통합 시나리오](#6-통합-시나리오)
|
|
7. [주요 기능 및 개선 사항](#7-주요-기능-및-개선-사항)
|
|
8. [예상 장점](#8-예상-장점)
|
|
9. [구현 우선순위](#9-구현-우선순위)
|
|
10. [체크리스트](#10-체크리스트)
|
|
|
|
---
|
|
|
|
## 1. 현황 분석
|
|
|
|
### 1.1 현재 구조
|
|
|
|
- **테이블 리스트 컴포넌트**에 테이블 옵션이 내장되어 있음
|
|
- 각 테이블 컴포넌트마다 개별적으로 옵션 기능 구현
|
|
- 코드 중복 및 유지보수 어려움
|
|
|
|
### 1.2 현재 제공 기능
|
|
|
|
#### 테이블 옵션
|
|
|
|
- 컬럼 표시/숨김 설정
|
|
- 컬럼 순서 변경 (드래그앤드롭)
|
|
- 컬럼 너비 조정
|
|
- 고정 컬럼 설정
|
|
|
|
#### 필터 설정
|
|
|
|
- 컬럼별 검색 필터 적용
|
|
- 다중 필터 조건 지원
|
|
- 연산자 선택 (같음, 포함, 시작, 끝)
|
|
|
|
#### 그룹 설정
|
|
|
|
- 컬럼별 데이터 그룹화
|
|
- 다중 그룹 레벨 지원
|
|
- 그룹별 집계 표시
|
|
|
|
### 1.3 적용 대상 컴포넌트
|
|
|
|
1. **TableList**: 기본 테이블 리스트 컴포넌트
|
|
2. **SplitPanel**: 좌/우 분할 테이블 (마스터-디테일 관계)
|
|
3. **FlowWidget**: 플로우 스텝별 데이터 테이블
|
|
|
|
---
|
|
|
|
## 2. 목표 및 요구사항
|
|
|
|
### 2.1 핵심 목표
|
|
|
|
1. 테이블 옵션 기능을 **재사용 가능한 공통 컴포넌트**로 분리
|
|
2. 화면에 있는 테이블 컴포넌트를 **자동 감지**하여 검색 가능
|
|
3. 각 컴포넌트의 테이블 데이터와 **독립적으로 연동**
|
|
4. 기존 기능을 유지하면서 확장 가능한 구조 구축
|
|
|
|
### 2.2 기능 요구사항
|
|
|
|
#### 자동 감지
|
|
|
|
- 화면 로드 시 테이블 컴포넌트 자동 식별
|
|
- 컴포넌트 추가/제거 시 동적 반영
|
|
- 테이블 ID 기반 고유 식별
|
|
|
|
#### 다중 테이블 지원
|
|
|
|
- 한 화면에 여러 테이블이 있을 경우 선택 가능
|
|
- 테이블 간 독립적인 설정 관리
|
|
- 선택된 테이블에만 옵션 적용
|
|
|
|
#### 실시간 적용
|
|
|
|
- 필터/그룹 설정 시 즉시 테이블 업데이트
|
|
- 불필요한 전체 화면 리렌더링 방지
|
|
- 최적화된 데이터 조회
|
|
|
|
#### 상태 독립성
|
|
|
|
- 각 테이블의 설정이 독립적으로 유지
|
|
- 한 테이블의 설정이 다른 테이블에 영향 없음
|
|
- 화면 전환 시 설정 보존 (선택사항)
|
|
|
|
### 2.3 비기능 요구사항
|
|
|
|
- **성능**: 100개 이상의 컬럼도 부드럽게 처리
|
|
- **접근성**: 키보드 네비게이션 지원
|
|
- **반응형**: 모바일/태블릿 대응
|
|
- **확장성**: 새로운 테이블 타입 추가 용이
|
|
|
|
---
|
|
|
|
## 3. 아키텍처 설계
|
|
|
|
### 3.1 컴포넌트 구조
|
|
|
|
```
|
|
TableOptionsToolbar (신규 - 메인 툴바)
|
|
├── TableSelector (다중 테이블 선택 드롭다운)
|
|
├── ColumnVisibilityButton (테이블 옵션 버튼)
|
|
├── FilterButton (필터 설정 버튼)
|
|
└── GroupingButton (그룹 설정 버튼)
|
|
|
|
패널 컴포넌트들 (Dialog 형태)
|
|
├── ColumnVisibilityPanel (컬럼 표시/숨김 설정)
|
|
├── FilterPanel (검색 필터 설정)
|
|
└── GroupingPanel (그룹화 설정)
|
|
|
|
Context & Provider
|
|
├── TableOptionsContext (테이블 등록 및 관리)
|
|
└── TableOptionsProvider (전역 상태 관리)
|
|
|
|
화면 컴포넌트들 (기존 수정)
|
|
├── TableList → TableOptionsContext 연동
|
|
├── SplitPanel → 좌/우 각각 등록
|
|
└── FlowWidget → 스텝별 등록
|
|
```
|
|
|
|
### 3.2 데이터 흐름
|
|
|
|
```mermaid
|
|
graph TD
|
|
A[화면 컴포넌트] --> B[registerTable 호출]
|
|
B --> C[TableOptionsContext에 등록]
|
|
C --> D[TableOptionsToolbar에서 목록 조회]
|
|
D --> E[사용자가 테이블 선택]
|
|
E --> F[옵션 버튼 클릭]
|
|
F --> G[패널 열림]
|
|
G --> H[설정 변경]
|
|
H --> I[선택된 테이블의 콜백 호출]
|
|
I --> J[테이블 컴포넌트 업데이트]
|
|
J --> K[데이터 재조회/재렌더링]
|
|
```
|
|
|
|
### 3.3 상태 관리 구조
|
|
|
|
```typescript
|
|
// Context에서 관리하는 전역 상태
|
|
{
|
|
registeredTables: Map<tableId, TableRegistration> {
|
|
"table-list-123": {
|
|
tableId: "table-list-123",
|
|
label: "품목 관리",
|
|
tableName: "item_info",
|
|
columns: [...],
|
|
onFilterChange: (filters) => {},
|
|
onGroupChange: (groups) => {},
|
|
onColumnVisibilityChange: (columns) => {}
|
|
},
|
|
"split-panel-left-456": {
|
|
tableId: "split-panel-left-456",
|
|
label: "분할 패널 (좌측)",
|
|
tableName: "category_values",
|
|
columns: [...],
|
|
...
|
|
}
|
|
}
|
|
}
|
|
|
|
// 각 테이블 컴포넌트가 관리하는 로컬 상태
|
|
{
|
|
filters: [
|
|
{ columnName: "item_name", operator: "contains", value: "나사" }
|
|
],
|
|
grouping: ["category_id", "material"],
|
|
columnVisibility: [
|
|
{ columnName: "item_name", visible: true, width: 200, order: 1 },
|
|
{ columnName: "status", visible: false, width: 100, order: 2 }
|
|
]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 4. 구현 계획
|
|
|
|
### Phase 1: Context 및 Provider 구현
|
|
|
|
#### 4.1.1 타입 정의
|
|
|
|
**파일**: `types/table-options.ts`
|
|
|
|
```typescript
|
|
/**
|
|
* 테이블 필터 조건
|
|
*/
|
|
export interface TableFilter {
|
|
columnName: string;
|
|
operator:
|
|
| "equals"
|
|
| "contains"
|
|
| "startsWith"
|
|
| "endsWith"
|
|
| "gt"
|
|
| "lt"
|
|
| "gte"
|
|
| "lte"
|
|
| "notEquals";
|
|
value: string | number | boolean;
|
|
}
|
|
|
|
/**
|
|
* 컬럼 표시 설정
|
|
*/
|
|
export interface ColumnVisibility {
|
|
columnName: string;
|
|
visible: boolean;
|
|
width?: number;
|
|
order?: number;
|
|
fixed?: boolean; // 좌측 고정 여부
|
|
}
|
|
|
|
/**
|
|
* 테이블 컬럼 정보
|
|
*/
|
|
export interface TableColumn {
|
|
columnName: string;
|
|
columnLabel: string;
|
|
inputType: string;
|
|
visible: boolean;
|
|
width: number;
|
|
sortable?: boolean;
|
|
filterable?: boolean;
|
|
}
|
|
|
|
/**
|
|
* 테이블 등록 정보
|
|
*/
|
|
export interface TableRegistration {
|
|
tableId: string; // 고유 ID (예: "table-list-123")
|
|
label: string; // 사용자에게 보이는 이름 (예: "품목 관리")
|
|
tableName: string; // 실제 DB 테이블명 (예: "item_info")
|
|
columns: TableColumn[];
|
|
|
|
// 콜백 함수들
|
|
onFilterChange: (filters: TableFilter[]) => void;
|
|
onGroupChange: (groups: string[]) => void;
|
|
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
|
|
}
|
|
|
|
/**
|
|
* Context 값 타입
|
|
*/
|
|
export interface TableOptionsContextValue {
|
|
registeredTables: Map<string, TableRegistration>;
|
|
registerTable: (registration: TableRegistration) => void;
|
|
unregisterTable: (tableId: string) => void;
|
|
getTable: (tableId: string) => TableRegistration | undefined;
|
|
selectedTableId: string | null;
|
|
setSelectedTableId: (tableId: string | null) => void;
|
|
}
|
|
```
|
|
|
|
#### 4.1.2 Context 생성
|
|
|
|
**파일**: `contexts/TableOptionsContext.tsx`
|
|
|
|
```typescript
|
|
import React, {
|
|
createContext,
|
|
useContext,
|
|
useState,
|
|
useCallback,
|
|
ReactNode,
|
|
} from "react";
|
|
import {
|
|
TableRegistration,
|
|
TableOptionsContextValue,
|
|
} from "@/types/table-options";
|
|
|
|
const TableOptionsContext = createContext<TableOptionsContextValue | undefined>(
|
|
undefined
|
|
);
|
|
|
|
export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
|
|
children,
|
|
}) => {
|
|
const [registeredTables, setRegisteredTables] = useState<
|
|
Map<string, TableRegistration>
|
|
>(new Map());
|
|
const [selectedTableId, setSelectedTableId] = useState<string | null>(null);
|
|
|
|
/**
|
|
* 테이블 등록
|
|
*/
|
|
const registerTable = useCallback((registration: TableRegistration) => {
|
|
setRegisteredTables((prev) => {
|
|
const newMap = new Map(prev);
|
|
newMap.set(registration.tableId, registration);
|
|
|
|
// 첫 번째 테이블이면 자동 선택
|
|
if (newMap.size === 1) {
|
|
setSelectedTableId(registration.tableId);
|
|
}
|
|
|
|
return newMap;
|
|
});
|
|
|
|
console.log(
|
|
`[TableOptions] 테이블 등록: ${registration.label} (${registration.tableId})`
|
|
);
|
|
}, []);
|
|
|
|
/**
|
|
* 테이블 등록 해제
|
|
*/
|
|
const unregisterTable = useCallback(
|
|
(tableId: string) => {
|
|
setRegisteredTables((prev) => {
|
|
const newMap = new Map(prev);
|
|
const removed = newMap.delete(tableId);
|
|
|
|
if (removed) {
|
|
console.log(`[TableOptions] 테이블 해제: ${tableId}`);
|
|
|
|
// 선택된 테이블이 제거되면 첫 번째 테이블 선택
|
|
if (selectedTableId === tableId) {
|
|
const firstTableId = newMap.keys().next().value;
|
|
setSelectedTableId(firstTableId || null);
|
|
}
|
|
}
|
|
|
|
return newMap;
|
|
});
|
|
},
|
|
[selectedTableId]
|
|
);
|
|
|
|
/**
|
|
* 특정 테이블 조회
|
|
*/
|
|
const getTable = useCallback(
|
|
(tableId: string) => {
|
|
return registeredTables.get(tableId);
|
|
},
|
|
[registeredTables]
|
|
);
|
|
|
|
return (
|
|
<TableOptionsContext.Provider
|
|
value={{
|
|
registeredTables,
|
|
registerTable,
|
|
unregisterTable,
|
|
getTable,
|
|
selectedTableId,
|
|
setSelectedTableId,
|
|
}}
|
|
>
|
|
{children}
|
|
</TableOptionsContext.Provider>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Context Hook
|
|
*/
|
|
export const useTableOptions = () => {
|
|
const context = useContext(TableOptionsContext);
|
|
if (!context) {
|
|
throw new Error("useTableOptions must be used within TableOptionsProvider");
|
|
}
|
|
return context;
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 2: TableOptionsToolbar 컴포넌트 구현
|
|
|
|
**파일**: `components/screen/table-options/TableOptionsToolbar.tsx`
|
|
|
|
```typescript
|
|
import React, { useState } from "react";
|
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Settings, Filter, Layers } from "lucide-react";
|
|
import { ColumnVisibilityPanel } from "./ColumnVisibilityPanel";
|
|
import { FilterPanel } from "./FilterPanel";
|
|
import { GroupingPanel } from "./GroupingPanel";
|
|
|
|
export const TableOptionsToolbar: React.FC = () => {
|
|
const { registeredTables, selectedTableId, setSelectedTableId } =
|
|
useTableOptions();
|
|
|
|
const [columnPanelOpen, setColumnPanelOpen] = useState(false);
|
|
const [filterPanelOpen, setFilterPanelOpen] = useState(false);
|
|
const [groupPanelOpen, setGroupPanelOpen] = useState(false);
|
|
|
|
const tableList = Array.from(registeredTables.values());
|
|
const selectedTable = selectedTableId
|
|
? registeredTables.get(selectedTableId)
|
|
: null;
|
|
|
|
// 테이블이 없으면 표시하지 않음
|
|
if (tableList.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 border-b bg-background p-2">
|
|
{/* 테이블 선택 (2개 이상일 때만 표시) */}
|
|
{tableList.length > 1 && (
|
|
<Select
|
|
value={selectedTableId || ""}
|
|
onValueChange={setSelectedTableId}
|
|
>
|
|
<SelectTrigger className="h-8 w-48 text-xs sm:h-9 sm:w-64 sm:text-sm">
|
|
<SelectValue placeholder="테이블 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{tableList.map((table) => (
|
|
<SelectItem key={table.tableId} value={table.tableId}>
|
|
{table.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
|
|
{/* 테이블이 1개일 때는 이름만 표시 */}
|
|
{tableList.length === 1 && (
|
|
<div className="text-xs font-medium sm:text-sm">
|
|
{tableList[0].label}
|
|
</div>
|
|
)}
|
|
|
|
{/* 컬럼 수 표시 */}
|
|
<div className="text-xs text-muted-foreground sm:text-sm">
|
|
전체 {selectedTable?.columns.length || 0}개
|
|
</div>
|
|
|
|
<div className="flex-1" />
|
|
|
|
{/* 옵션 버튼들 */}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setColumnPanelOpen(true)}
|
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
|
disabled={!selectedTableId}
|
|
>
|
|
<Settings className="mr-2 h-4 w-4" />
|
|
테이블 옵션
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setFilterPanelOpen(true)}
|
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
|
disabled={!selectedTableId}
|
|
>
|
|
<Filter className="mr-2 h-4 w-4" />
|
|
필터 설정
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setGroupPanelOpen(true)}
|
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
|
disabled={!selectedTableId}
|
|
>
|
|
<Layers className="mr-2 h-4 w-4" />
|
|
그룹 설정
|
|
</Button>
|
|
|
|
{/* 패널들 */}
|
|
{selectedTableId && (
|
|
<>
|
|
<ColumnVisibilityPanel
|
|
tableId={selectedTableId}
|
|
open={columnPanelOpen}
|
|
onOpenChange={setColumnPanelOpen}
|
|
/>
|
|
<FilterPanel
|
|
tableId={selectedTableId}
|
|
open={filterPanelOpen}
|
|
onOpenChange={setFilterPanelOpen}
|
|
/>
|
|
<GroupingPanel
|
|
tableId={selectedTableId}
|
|
open={groupPanelOpen}
|
|
onOpenChange={setGroupPanelOpen}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 3: 패널 컴포넌트 구현
|
|
|
|
#### 4.3.1 ColumnVisibilityPanel
|
|
|
|
**파일**: `components/screen/table-options/ColumnVisibilityPanel.tsx`
|
|
|
|
```typescript
|
|
import React, { useState, useEffect } from "react";
|
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { GripVertical, Eye, EyeOff } from "lucide-react";
|
|
import { ColumnVisibility } from "@/types/table-options";
|
|
|
|
interface Props {
|
|
tableId: string;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|
tableId,
|
|
open,
|
|
onOpenChange,
|
|
}) => {
|
|
const { getTable } = useTableOptions();
|
|
const table = getTable(tableId);
|
|
|
|
const [localColumns, setLocalColumns] = useState<ColumnVisibility[]>([]);
|
|
|
|
// 테이블 정보 로드
|
|
useEffect(() => {
|
|
if (table) {
|
|
setLocalColumns(
|
|
table.columns.map((col) => ({
|
|
columnName: col.columnName,
|
|
visible: col.visible,
|
|
width: col.width,
|
|
order: 0,
|
|
}))
|
|
);
|
|
}
|
|
}, [table]);
|
|
|
|
const handleVisibilityChange = (columnName: string, visible: boolean) => {
|
|
setLocalColumns((prev) =>
|
|
prev.map((col) =>
|
|
col.columnName === columnName ? { ...col, visible } : col
|
|
)
|
|
);
|
|
};
|
|
|
|
const handleWidthChange = (columnName: string, width: number) => {
|
|
setLocalColumns((prev) =>
|
|
prev.map((col) =>
|
|
col.columnName === columnName ? { ...col, width } : col
|
|
)
|
|
);
|
|
};
|
|
|
|
const handleApply = () => {
|
|
table?.onColumnVisibilityChange(localColumns);
|
|
onOpenChange(false);
|
|
};
|
|
|
|
const handleReset = () => {
|
|
if (table) {
|
|
setLocalColumns(
|
|
table.columns.map((col) => ({
|
|
columnName: col.columnName,
|
|
visible: true,
|
|
width: 150,
|
|
order: 0,
|
|
}))
|
|
);
|
|
}
|
|
};
|
|
|
|
const visibleCount = localColumns.filter((col) => col.visible).length;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-2xl">
|
|
<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="flex items-center justify-between rounded-lg border bg-muted/50 p-3">
|
|
<div className="text-xs text-muted-foreground sm:text-sm">
|
|
{visibleCount}/{localColumns.length}개 컬럼 표시 중
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleReset}
|
|
className="h-7 text-xs"
|
|
>
|
|
초기화
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 컬럼 리스트 */}
|
|
<ScrollArea className="h-[300px] sm:h-[400px]">
|
|
<div className="space-y-2 pr-4">
|
|
{localColumns.map((col, index) => {
|
|
const columnMeta = table?.columns.find(
|
|
(c) => c.columnName === col.columnName
|
|
);
|
|
return (
|
|
<div
|
|
key={col.columnName}
|
|
className="flex items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50"
|
|
>
|
|
{/* 드래그 핸들 */}
|
|
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
|
|
{/* 체크박스 */}
|
|
<Checkbox
|
|
checked={col.visible}
|
|
onCheckedChange={(checked) =>
|
|
handleVisibilityChange(
|
|
col.columnName,
|
|
checked as boolean
|
|
)
|
|
}
|
|
/>
|
|
|
|
{/* 가시성 아이콘 */}
|
|
{col.visible ? (
|
|
<Eye className="h-4 w-4 shrink-0 text-primary" />
|
|
) : (
|
|
<EyeOff className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
)}
|
|
|
|
{/* 컬럼명 */}
|
|
<div className="flex-1">
|
|
<div className="text-xs font-medium sm:text-sm">
|
|
{columnMeta?.columnLabel}
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground sm:text-xs">
|
|
{col.columnName}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 너비 설정 */}
|
|
<div className="flex items-center gap-2">
|
|
<Label className="text-xs text-muted-foreground">
|
|
너비:
|
|
</Label>
|
|
<Input
|
|
type="number"
|
|
value={col.width || 150}
|
|
onChange={(e) =>
|
|
handleWidthChange(
|
|
col.columnName,
|
|
parseInt(e.target.value) || 150
|
|
)
|
|
}
|
|
className="h-7 w-16 text-xs sm:h-8 sm:w-20 sm:text-sm"
|
|
min={50}
|
|
max={500}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
onClick={handleApply}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
```
|
|
|
|
#### 4.3.2 FilterPanel
|
|
|
|
**파일**: `components/screen/table-options/FilterPanel.tsx`
|
|
|
|
```typescript
|
|
import React, { useState, useEffect } from "react";
|
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Plus, X } from "lucide-react";
|
|
import { TableFilter } from "@/types/table-options";
|
|
|
|
interface Props {
|
|
tableId: string;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
export const FilterPanel: React.FC<Props> = ({
|
|
tableId,
|
|
open,
|
|
onOpenChange,
|
|
}) => {
|
|
const { getTable } = useTableOptions();
|
|
const table = getTable(tableId);
|
|
|
|
const [activeFilters, setActiveFilters] = useState<TableFilter[]>([]);
|
|
|
|
const addFilter = () => {
|
|
setActiveFilters([
|
|
...activeFilters,
|
|
{ columnName: "", operator: "contains", value: "" },
|
|
]);
|
|
};
|
|
|
|
const removeFilter = (index: number) => {
|
|
setActiveFilters(activeFilters.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const updateFilter = (
|
|
index: number,
|
|
field: keyof TableFilter,
|
|
value: any
|
|
) => {
|
|
setActiveFilters(
|
|
activeFilters.map((filter, i) =>
|
|
i === index ? { ...filter, [field]: value } : filter
|
|
)
|
|
);
|
|
};
|
|
|
|
const applyFilters = () => {
|
|
// 빈 필터 제거
|
|
const validFilters = activeFilters.filter(
|
|
(f) => f.columnName && f.value !== ""
|
|
);
|
|
table?.onFilterChange(validFilters);
|
|
onOpenChange(false);
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
setActiveFilters([]);
|
|
table?.onFilterChange([]);
|
|
};
|
|
|
|
const operatorLabels: Record<string, string> = {
|
|
equals: "같음",
|
|
contains: "포함",
|
|
startsWith: "시작",
|
|
endsWith: "끝",
|
|
gt: "보다 큼",
|
|
lt: "보다 작음",
|
|
gte: "이상",
|
|
lte: "이하",
|
|
notEquals: "같지 않음",
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-2xl">
|
|
<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="flex items-center justify-between">
|
|
<div className="text-xs text-muted-foreground sm:text-sm">
|
|
총 {activeFilters.length}개의 검색 필터가 표시됩니다
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={clearFilters}
|
|
className="h-7 text-xs"
|
|
>
|
|
초기화
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 필터 리스트 */}
|
|
<ScrollArea className="h-[300px] sm:h-[400px]">
|
|
<div className="space-y-3 pr-4">
|
|
{activeFilters.map((filter, index) => (
|
|
<div
|
|
key={index}
|
|
className="flex flex-col gap-2 rounded-lg border bg-background p-3 sm:flex-row sm:items-center"
|
|
>
|
|
{/* 컬럼 선택 */}
|
|
<Select
|
|
value={filter.columnName}
|
|
onValueChange={(val) =>
|
|
updateFilter(index, "columnName", val)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs sm:h-9 sm:w-40 sm:text-sm">
|
|
<SelectValue placeholder="컬럼 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{table?.columns
|
|
.filter((col) => col.filterable !== false)
|
|
.map((col) => (
|
|
<SelectItem
|
|
key={col.columnName}
|
|
value={col.columnName}
|
|
>
|
|
{col.columnLabel}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* 연산자 선택 */}
|
|
<Select
|
|
value={filter.operator}
|
|
onValueChange={(val) =>
|
|
updateFilter(index, "operator", val)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs sm:h-9 sm:w-32 sm:text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(operatorLabels).map(([value, label]) => (
|
|
<SelectItem key={value} value={value}>
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* 값 입력 */}
|
|
<Input
|
|
value={filter.value as string}
|
|
onChange={(e) =>
|
|
updateFilter(index, "value", e.target.value)
|
|
}
|
|
placeholder="값 입력"
|
|
className="h-8 flex-1 text-xs sm:h-9 sm:text-sm"
|
|
/>
|
|
|
|
{/* 삭제 버튼 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => removeFilter(index)}
|
|
className="h-8 w-8 shrink-0 sm:h-9 sm:w-9"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
{/* 필터 추가 버튼 */}
|
|
<Button
|
|
variant="outline"
|
|
onClick={addFilter}
|
|
className="h-8 w-full text-xs sm:h-9 sm:text-sm"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
필터 추가
|
|
</Button>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
onClick={applyFilters}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
```
|
|
|
|
#### 4.3.3 GroupingPanel
|
|
|
|
**파일**: `components/screen/table-options/GroupingPanel.tsx`
|
|
|
|
```typescript
|
|
import React, { useState } from "react";
|
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { ArrowRight } from "lucide-react";
|
|
|
|
interface Props {
|
|
tableId: string;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
export const GroupingPanel: React.FC<Props> = ({
|
|
tableId,
|
|
open,
|
|
onOpenChange,
|
|
}) => {
|
|
const { getTable } = useTableOptions();
|
|
const table = getTable(tableId);
|
|
|
|
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
|
|
|
|
const toggleColumn = (columnName: string) => {
|
|
if (selectedColumns.includes(columnName)) {
|
|
setSelectedColumns(selectedColumns.filter((c) => c !== columnName));
|
|
} else {
|
|
setSelectedColumns([...selectedColumns, columnName]);
|
|
}
|
|
};
|
|
|
|
const applyGrouping = () => {
|
|
table?.onGroupChange(selectedColumns);
|
|
onOpenChange(false);
|
|
};
|
|
|
|
const clearGrouping = () => {
|
|
setSelectedColumns([]);
|
|
table?.onGroupChange([]);
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-xl">
|
|
<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="flex items-center justify-between rounded-lg border bg-muted/50 p-3">
|
|
<div className="text-xs text-muted-foreground sm:text-sm">
|
|
{selectedColumns.length}개 컬럼으로 그룹화
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={clearGrouping}
|
|
className="h-7 text-xs"
|
|
>
|
|
초기화
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 컬럼 리스트 */}
|
|
<ScrollArea className="h-[250px] sm:h-[300px]">
|
|
<div className="space-y-2 pr-4">
|
|
{table?.columns.map((col, index) => {
|
|
const isSelected = selectedColumns.includes(col.columnName);
|
|
const order = selectedColumns.indexOf(col.columnName) + 1;
|
|
|
|
return (
|
|
<div
|
|
key={col.columnName}
|
|
className="flex items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50"
|
|
>
|
|
<Checkbox
|
|
checked={isSelected}
|
|
onCheckedChange={() => toggleColumn(col.columnName)}
|
|
/>
|
|
|
|
<div className="flex-1">
|
|
<div className="text-xs font-medium sm:text-sm">
|
|
{col.columnLabel}
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground sm:text-xs">
|
|
{col.columnName}
|
|
</div>
|
|
</div>
|
|
|
|
{isSelected && (
|
|
<div className="flex items-center gap-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
|
|
{order}번째
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
{/* 그룹 순서 미리보기 */}
|
|
{selectedColumns.length > 0 && (
|
|
<div className="rounded-lg border bg-muted/30 p-3">
|
|
<div className="mb-2 text-xs font-medium sm:text-sm">
|
|
그룹화 순서
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
|
|
{selectedColumns.map((colName, index) => {
|
|
const col = table?.columns.find(
|
|
(c) => c.columnName === colName
|
|
);
|
|
return (
|
|
<React.Fragment key={colName}>
|
|
<div className="rounded bg-primary/10 px-2 py-1 font-medium">
|
|
{col?.columnLabel}
|
|
</div>
|
|
{index < selectedColumns.length - 1 && (
|
|
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
onClick={applyGrouping}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 4: 기존 테이블 컴포넌트 통합
|
|
|
|
#### 4.4.1 TableList 컴포넌트 수정
|
|
|
|
**파일**: `components/screen/interactive/TableList.tsx`
|
|
|
|
```typescript
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
|
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
|
|
|
export const TableList: React.FC<Props> = ({ component }) => {
|
|
const { registerTable, unregisterTable } = useTableOptions();
|
|
|
|
// 로컬 상태
|
|
const [filters, setFilters] = useState<TableFilter[]>([]);
|
|
const [grouping, setGrouping] = useState<string[]>([]);
|
|
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>(
|
|
[]
|
|
);
|
|
const [data, setData] = useState<any[]>([]);
|
|
|
|
const tableId = `table-list-${component.id}`;
|
|
|
|
// 테이블 등록
|
|
useEffect(() => {
|
|
registerTable({
|
|
tableId,
|
|
label: component.title || "테이블",
|
|
tableName: component.tableName,
|
|
columns: component.columns.map((col) => ({
|
|
columnName: col.field,
|
|
columnLabel: col.label,
|
|
inputType: col.inputType,
|
|
visible: col.visible ?? true,
|
|
width: col.width || 150,
|
|
sortable: col.sortable,
|
|
filterable: col.filterable,
|
|
})),
|
|
onFilterChange: setFilters,
|
|
onGroupChange: setGrouping,
|
|
onColumnVisibilityChange: setColumnVisibility,
|
|
});
|
|
|
|
return () => unregisterTable(tableId);
|
|
}, [component.id, component.tableName, component.columns]);
|
|
|
|
// 데이터 조회
|
|
const fetchData = useCallback(async () => {
|
|
try {
|
|
const params = {
|
|
tableName: component.tableName,
|
|
filters: JSON.stringify(filters),
|
|
groupBy: grouping.join(","),
|
|
};
|
|
|
|
const response = await apiClient.get("/api/table/data", { params });
|
|
|
|
if (response.data.success) {
|
|
setData(response.data.data);
|
|
}
|
|
} catch (error) {
|
|
console.error("데이터 조회 실패:", error);
|
|
}
|
|
}, [component.tableName, filters, grouping]);
|
|
|
|
// 필터/그룹 변경 시 데이터 재조회
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
// 표시할 컬럼 필터링
|
|
const visibleColumns = component.columns.filter((col) => {
|
|
const visibility = columnVisibility.find((v) => v.columnName === col.field);
|
|
return visibility ? visibility.visible : col.visible !== false;
|
|
});
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{/* 기존 테이블 UI */}
|
|
<div className="flex-1 overflow-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr>
|
|
{visibleColumns.map((col) => {
|
|
const visibility = columnVisibility.find(
|
|
(v) => v.columnName === col.field
|
|
);
|
|
const width = visibility?.width || col.width || 150;
|
|
|
|
return (
|
|
<th key={col.field} style={{ width: `${width}px` }}>
|
|
{col.label}
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.map((row, rowIndex) => (
|
|
<tr key={rowIndex}>
|
|
{visibleColumns.map((col) => (
|
|
<td key={col.field}>{row[col.field]}</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
#### 4.4.2 SplitPanel 컴포넌트 수정
|
|
|
|
**파일**: `components/screen/interactive/SplitPanel.tsx`
|
|
|
|
```typescript
|
|
export const SplitPanel: React.FC<Props> = ({ component }) => {
|
|
const { registerTable, unregisterTable } = useTableOptions();
|
|
|
|
// 좌측 테이블 상태
|
|
const [leftFilters, setLeftFilters] = useState<TableFilter[]>([]);
|
|
const [leftGrouping, setLeftGrouping] = useState<string[]>([]);
|
|
const [leftColumnVisibility, setLeftColumnVisibility] = useState<
|
|
ColumnVisibility[]
|
|
>([]);
|
|
|
|
// 우측 테이블 상태
|
|
const [rightFilters, setRightFilters] = useState<TableFilter[]>([]);
|
|
const [rightGrouping, setRightGrouping] = useState<string[]>([]);
|
|
const [rightColumnVisibility, setRightColumnVisibility] = useState<
|
|
ColumnVisibility[]
|
|
>([]);
|
|
|
|
const leftTableId = `split-panel-left-${component.id}`;
|
|
const rightTableId = `split-panel-right-${component.id}`;
|
|
|
|
// 좌측 테이블 등록
|
|
useEffect(() => {
|
|
registerTable({
|
|
tableId: leftTableId,
|
|
label: `${component.title || "분할 패널"} (좌측)`,
|
|
tableName: component.leftPanel.tableName,
|
|
columns: component.leftPanel.columns.map((col) => ({
|
|
columnName: col.field,
|
|
columnLabel: col.label,
|
|
inputType: col.inputType,
|
|
visible: col.visible ?? true,
|
|
width: col.width || 150,
|
|
})),
|
|
onFilterChange: setLeftFilters,
|
|
onGroupChange: setLeftGrouping,
|
|
onColumnVisibilityChange: setLeftColumnVisibility,
|
|
});
|
|
|
|
return () => unregisterTable(leftTableId);
|
|
}, [component.leftPanel]);
|
|
|
|
// 우측 테이블 등록
|
|
useEffect(() => {
|
|
registerTable({
|
|
tableId: rightTableId,
|
|
label: `${component.title || "분할 패널"} (우측)`,
|
|
tableName: component.rightPanel.tableName,
|
|
columns: component.rightPanel.columns.map((col) => ({
|
|
columnName: col.field,
|
|
columnLabel: col.label,
|
|
inputType: col.inputType,
|
|
visible: col.visible ?? true,
|
|
width: col.width || 150,
|
|
})),
|
|
onFilterChange: setRightFilters,
|
|
onGroupChange: setRightGrouping,
|
|
onColumnVisibilityChange: setRightColumnVisibility,
|
|
});
|
|
|
|
return () => unregisterTable(rightTableId);
|
|
}, [component.rightPanel]);
|
|
|
|
return (
|
|
<div className="flex h-full gap-4">
|
|
{/* 좌측 테이블 */}
|
|
<div className="flex-1">
|
|
<TableList
|
|
component={component.leftPanel}
|
|
filters={leftFilters}
|
|
grouping={leftGrouping}
|
|
columnVisibility={leftColumnVisibility}
|
|
/>
|
|
</div>
|
|
|
|
{/* 우측 테이블 */}
|
|
<div className="flex-1">
|
|
<TableList
|
|
component={component.rightPanel}
|
|
filters={rightFilters}
|
|
grouping={rightGrouping}
|
|
columnVisibility={rightColumnVisibility}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
#### 4.4.3 FlowWidget 컴포넌트 수정
|
|
|
|
**파일**: `components/screen/interactive/FlowWidget.tsx`
|
|
|
|
```typescript
|
|
export const FlowWidget: React.FC<Props> = ({ component }) => {
|
|
const { registerTable, unregisterTable } = useTableOptions();
|
|
|
|
const [selectedStep, setSelectedStep] = useState<any | null>(null);
|
|
const [filters, setFilters] = useState<TableFilter[]>([]);
|
|
const [grouping, setGrouping] = useState<string[]>([]);
|
|
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>(
|
|
[]
|
|
);
|
|
|
|
const tableId = selectedStep
|
|
? `flow-widget-${component.id}-step-${selectedStep.id}`
|
|
: null;
|
|
|
|
// 선택된 스텝의 테이블 등록
|
|
useEffect(() => {
|
|
if (!selectedStep || !tableId) return;
|
|
|
|
registerTable({
|
|
tableId,
|
|
label: `${selectedStep.name} 데이터`,
|
|
tableName: component.tableName,
|
|
columns: component.displayColumns.map((col) => ({
|
|
columnName: col.field,
|
|
columnLabel: col.label,
|
|
inputType: col.inputType,
|
|
visible: col.visible ?? true,
|
|
width: col.width || 150,
|
|
})),
|
|
onFilterChange: setFilters,
|
|
onGroupChange: setGrouping,
|
|
onColumnVisibilityChange: setColumnVisibility,
|
|
});
|
|
|
|
return () => unregisterTable(tableId);
|
|
}, [selectedStep, component.displayColumns]);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{/* 플로우 스텝 선택 UI */}
|
|
<div className="border-b p-2">{/* 스텝 선택 드롭다운 */}</div>
|
|
|
|
{/* 테이블 */}
|
|
<div className="flex-1">
|
|
{selectedStep && (
|
|
<TableList
|
|
component={component}
|
|
filters={filters}
|
|
grouping={grouping}
|
|
columnVisibility={columnVisibility}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 5: InteractiveScreenViewer 통합
|
|
|
|
**파일**: `components/screen/InteractiveScreenViewer.tsx`
|
|
|
|
```typescript
|
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
|
import { TableOptionsToolbar } from "@/components/screen/table-options/TableOptionsToolbar";
|
|
|
|
export const InteractiveScreenViewer: React.FC<Props> = ({ screenData }) => {
|
|
return (
|
|
<TableOptionsProvider>
|
|
<div className="flex h-full flex-col">
|
|
{/* 테이블 옵션 툴바 */}
|
|
<TableOptionsToolbar />
|
|
|
|
{/* 화면 컨텐츠 */}
|
|
<div className="flex-1 overflow-auto">
|
|
{screenData.components.map((component) => (
|
|
<ComponentRenderer key={component.id} component={component} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</TableOptionsProvider>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 6: 백엔드 API 개선
|
|
|
|
**파일**: `backend-node/src/controllers/tableController.ts`
|
|
|
|
```typescript
|
|
/**
|
|
* 테이블 데이터 조회 (필터/그룹 지원)
|
|
*/
|
|
export async function getTableData(req: Request, res: Response) {
|
|
const companyCode = req.user!.companyCode;
|
|
const { tableName, filters, groupBy, page = 1, pageSize = 50 } = req.query;
|
|
|
|
try {
|
|
// 필터 파싱
|
|
const parsedFilters: TableFilter[] = filters
|
|
? JSON.parse(filters as string)
|
|
: [];
|
|
|
|
// WHERE 절 생성
|
|
const whereConditions: string[] = [`company_code = $1`];
|
|
const params: any[] = [companyCode];
|
|
|
|
parsedFilters.forEach((filter, index) => {
|
|
const paramIndex = index + 2;
|
|
|
|
switch (filter.operator) {
|
|
case "equals":
|
|
whereConditions.push(`${filter.columnName} = $${paramIndex}`);
|
|
params.push(filter.value);
|
|
break;
|
|
case "contains":
|
|
whereConditions.push(`${filter.columnName} ILIKE $${paramIndex}`);
|
|
params.push(`%${filter.value}%`);
|
|
break;
|
|
case "startsWith":
|
|
whereConditions.push(`${filter.columnName} ILIKE $${paramIndex}`);
|
|
params.push(`${filter.value}%`);
|
|
break;
|
|
case "endsWith":
|
|
whereConditions.push(`${filter.columnName} ILIKE $${paramIndex}`);
|
|
params.push(`%${filter.value}`);
|
|
break;
|
|
case "gt":
|
|
whereConditions.push(`${filter.columnName} > $${paramIndex}`);
|
|
params.push(filter.value);
|
|
break;
|
|
case "lt":
|
|
whereConditions.push(`${filter.columnName} < $${paramIndex}`);
|
|
params.push(filter.value);
|
|
break;
|
|
case "gte":
|
|
whereConditions.push(`${filter.columnName} >= $${paramIndex}`);
|
|
params.push(filter.value);
|
|
break;
|
|
case "lte":
|
|
whereConditions.push(`${filter.columnName} <= $${paramIndex}`);
|
|
params.push(filter.value);
|
|
break;
|
|
case "notEquals":
|
|
whereConditions.push(`${filter.columnName} != $${paramIndex}`);
|
|
params.push(filter.value);
|
|
break;
|
|
}
|
|
});
|
|
|
|
const whereSql = `WHERE ${whereConditions.join(" AND ")}`;
|
|
const groupBySql = groupBy ? `GROUP BY ${groupBy}` : "";
|
|
|
|
// 페이징
|
|
const offset =
|
|
(parseInt(page as string) - 1) * parseInt(pageSize as string);
|
|
const limitSql = `LIMIT ${pageSize} OFFSET ${offset}`;
|
|
|
|
// 카운트 쿼리
|
|
const countQuery = `SELECT COUNT(*) as total FROM ${tableName} ${whereSql}`;
|
|
const countResult = await pool.query(countQuery, params);
|
|
const total = parseInt(countResult.rows[0].total);
|
|
|
|
// 데이터 쿼리
|
|
const dataQuery = `
|
|
SELECT * FROM ${tableName}
|
|
${whereSql}
|
|
${groupBySql}
|
|
ORDER BY id DESC
|
|
${limitSql}
|
|
`;
|
|
const dataResult = await pool.query(dataQuery, params);
|
|
|
|
return res.json({
|
|
success: true,
|
|
data: dataResult.rows,
|
|
pagination: {
|
|
page: parseInt(page as string),
|
|
pageSize: parseInt(pageSize as string),
|
|
total,
|
|
totalPages: Math.ceil(total / parseInt(pageSize as string)),
|
|
},
|
|
});
|
|
} catch (error: any) {
|
|
logger.error("테이블 데이터 조회 실패", {
|
|
error: error.message,
|
|
tableName,
|
|
});
|
|
return res.status(500).json({
|
|
success: false,
|
|
error: "데이터 조회 중 오류가 발생했습니다",
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 5. 파일 구조
|
|
|
|
```
|
|
frontend/
|
|
├── types/
|
|
│ └── table-options.ts # 타입 정의
|
|
│
|
|
├── contexts/
|
|
│ └── TableOptionsContext.tsx # Context 및 Provider
|
|
│
|
|
├── components/
|
|
│ └── screen/
|
|
│ ├── table-options/
|
|
│ │ ├── TableOptionsToolbar.tsx # 메인 툴바
|
|
│ │ ├── ColumnVisibilityPanel.tsx # 테이블 옵션 패널
|
|
│ │ ├── FilterPanel.tsx # 필터 설정 패널
|
|
│ │ └── GroupingPanel.tsx # 그룹 설정 패널
|
|
│ │
|
|
│ ├── interactive/
|
|
│ │ ├── TableList.tsx # 수정: Context 연동
|
|
│ │ ├── SplitPanel.tsx # 수정: Context 연동
|
|
│ │ └── FlowWidget.tsx # 수정: Context 연동
|
|
│ │
|
|
│ └── InteractiveScreenViewer.tsx # 수정: Provider 래핑
|
|
│
|
|
backend-node/
|
|
└── src/
|
|
└── controllers/
|
|
└── tableController.ts # 수정: 필터/그룹 지원
|
|
```
|
|
|
|
---
|
|
|
|
## 6. 통합 시나리오
|
|
|
|
### 6.1 단일 테이블 화면
|
|
|
|
```tsx
|
|
<InteractiveScreenViewer>
|
|
<TableOptionsProvider>
|
|
<TableOptionsToolbar /> {/* 자동으로 1개 테이블 선택 */}
|
|
<TableList /> {/* 자동 등록 */}
|
|
</TableOptionsProvider>
|
|
</InteractiveScreenViewer>
|
|
```
|
|
|
|
**동작 흐름**:
|
|
|
|
1. TableList 마운트 → Context에 테이블 등록
|
|
2. TableOptionsToolbar에서 자동으로 해당 테이블 선택
|
|
3. 사용자가 필터 설정 → onFilterChange 콜백 호출
|
|
4. TableList에서 filters 상태 업데이트 → 데이터 재조회
|
|
|
|
### 6.2 다중 테이블 화면 (SplitPanel)
|
|
|
|
```tsx
|
|
<InteractiveScreenViewer>
|
|
<TableOptionsProvider>
|
|
<TableOptionsToolbar /> {/* 좌/우 테이블 선택 가능 */}
|
|
<SplitPanel>
|
|
{" "}
|
|
{/* 좌/우 각각 등록 */}
|
|
<TableList /> {/* 좌측 */}
|
|
<TableList /> {/* 우측 */}
|
|
</SplitPanel>
|
|
</TableOptionsProvider>
|
|
</InteractiveScreenViewer>
|
|
```
|
|
|
|
**동작 흐름**:
|
|
|
|
1. SplitPanel 마운트 → 좌/우 테이블 각각 등록
|
|
2. TableOptionsToolbar에서 드롭다운으로 테이블 선택
|
|
3. 선택된 테이블에 대해서만 옵션 적용
|
|
4. 각 테이블의 상태는 독립적으로 관리
|
|
|
|
### 6.3 플로우 위젯 화면
|
|
|
|
```tsx
|
|
<InteractiveScreenViewer>
|
|
<TableOptionsProvider>
|
|
<TableOptionsToolbar /> {/* 현재 스텝 테이블 자동 선택 */}
|
|
<FlowWidget /> {/* 스텝 변경 시 자동 재등록 */}
|
|
</TableOptionsProvider>
|
|
</InteractiveScreenViewer>
|
|
```
|
|
|
|
**동작 흐름**:
|
|
|
|
1. FlowWidget 마운트 → 초기 스텝 테이블 등록
|
|
2. 사용자가 다른 스텝 선택 → 기존 테이블 해제 + 새 테이블 등록
|
|
3. TableOptionsToolbar에서 자동으로 새 테이블 선택
|
|
4. 스텝별로 독립적인 필터/그룹 설정 유지
|
|
|
|
---
|
|
|
|
## 7. 주요 기능 및 개선 사항
|
|
|
|
### 7.1 자동 감지 메커니즘
|
|
|
|
**구현 방법**:
|
|
|
|
- 각 테이블 컴포넌트가 마운트될 때 `registerTable()` 호출
|
|
- 언마운트 시 `unregisterTable()` 호출
|
|
- Context가 등록된 테이블 목록을 Map으로 관리
|
|
|
|
**장점**:
|
|
|
|
- 개발자가 수동으로 테이블 목록을 관리할 필요 없음
|
|
- 동적으로 컴포넌트가 추가/제거되어도 자동 반영
|
|
- 컴포넌트 간 느슨한 결합 유지
|
|
|
|
### 7.2 독립적 상태 관리
|
|
|
|
**구현 방법**:
|
|
|
|
- 각 테이블 컴포넌트가 자체 상태(filters, grouping, columnVisibility) 관리
|
|
- Context는 상태를 직접 저장하지 않고 콜백 함수만 저장
|
|
- 콜백을 통해 각 테이블에 설정 전달
|
|
|
|
**장점**:
|
|
|
|
- 한 테이블의 설정이 다른 테이블에 영향 없음
|
|
- 메모리 효율적 (Context에 모든 상태 저장 불필요)
|
|
- 각 테이블이 독립적으로 최적화 가능
|
|
|
|
### 7.3 실시간 반영
|
|
|
|
**구현 방법**:
|
|
|
|
- 옵션 변경 시 즉시 해당 테이블의 콜백 호출
|
|
- 테이블 컴포넌트는 상태 변경을 감지하여 자동 리렌더링
|
|
- useCallback과 useMemo로 불필요한 리렌더링 방지
|
|
|
|
**장점**:
|
|
|
|
- 사용자 경험 향상 (즉각적인 피드백)
|
|
- 성능 최적화 (변경된 테이블만 업데이트)
|
|
|
|
### 7.4 확장성
|
|
|
|
**새로운 테이블 컴포넌트 추가 방법**:
|
|
|
|
```typescript
|
|
export const MyCustomTable: React.FC = () => {
|
|
const { registerTable, unregisterTable } = useTableOptions();
|
|
const [filters, setFilters] = useState<TableFilter[]>([]);
|
|
|
|
useEffect(() => {
|
|
registerTable({
|
|
tableId: "my-custom-table-123",
|
|
label: "커스텀 테이블",
|
|
tableName: "custom_table",
|
|
columns: [...],
|
|
onFilterChange: setFilters,
|
|
onGroupChange: setGrouping,
|
|
onColumnVisibilityChange: setColumnVisibility,
|
|
});
|
|
|
|
return () => unregisterTable("my-custom-table-123");
|
|
}, []);
|
|
|
|
// 나머지 구현...
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 8. 예상 장점
|
|
|
|
### 8.1 개발자 측면
|
|
|
|
1. **코드 재사용성**: 공통 로직을 한 곳에서 관리
|
|
2. **유지보수 용이**: 버그 수정 시 한 곳만 수정
|
|
3. **일관된 UX**: 모든 테이블에서 동일한 사용자 경험
|
|
4. **빠른 개발**: 새 테이블 추가 시 Context만 연동
|
|
|
|
### 8.2 사용자 측면
|
|
|
|
1. **직관적인 UI**: 통일된 인터페이스로 학습 비용 감소
|
|
2. **유연한 검색**: 다양한 필터 조합으로 원하는 데이터 빠르게 찾기
|
|
3. **맞춤 설정**: 각 테이블별로 컬럼 표시/숨김 설정 가능
|
|
4. **효율적인 작업**: 그룹화로 대량 데이터를 구조적으로 확인
|
|
|
|
### 8.3 성능 측면
|
|
|
|
1. **최적화된 렌더링**: 변경된 테이블만 리렌더링
|
|
2. **효율적인 상태 관리**: Context에 최소한의 정보만 저장
|
|
3. **지연 로딩**: 패널은 열릴 때만 렌더링
|
|
4. **백엔드 부하 감소**: 필터링된 데이터만 조회
|
|
|
|
---
|
|
|
|
## 9. 구현 우선순위
|
|
|
|
### Phase 1: 기반 구조 (1-2일)
|
|
|
|
- [ ] 타입 정의 작성
|
|
- [ ] Context 및 Provider 구현
|
|
- [ ] 테스트용 간단한 TableOptionsToolbar 작성
|
|
|
|
### Phase 2: 툴바 및 패널 (2-3일)
|
|
|
|
- [ ] TableOptionsToolbar 완성
|
|
- [ ] ColumnVisibilityPanel 구현
|
|
- [ ] FilterPanel 구현
|
|
- [ ] GroupingPanel 구현
|
|
|
|
### Phase 3: 기존 컴포넌트 통합 (2-3일)
|
|
|
|
- [ ] TableList Context 연동
|
|
- [ ] SplitPanel Context 연동 (좌/우 분리)
|
|
- [ ] FlowWidget Context 연동
|
|
- [ ] InteractiveScreenViewer Provider 래핑
|
|
|
|
### Phase 4: 백엔드 API (1-2일)
|
|
|
|
- [ ] 필터 처리 로직 구현
|
|
- [ ] 그룹화 처리 로직 구현
|
|
- [ ] 페이징 최적화
|
|
- [ ] 성능 테스트
|
|
|
|
### Phase 5: 테스트 및 최적화 (1-2일)
|
|
|
|
- [ ] 단위 테스트 작성
|
|
- [ ] 통합 테스트
|
|
- [ ] 성능 프로파일링
|
|
- [ ] 버그 수정 및 최적화
|
|
|
|
**총 예상 기간**: 약 7-12일
|
|
|
|
---
|
|
|
|
## 10. 체크리스트
|
|
|
|
### 개발 전 확인사항
|
|
|
|
- [ ] 현재 테이블 옵션 기능 목록 정리
|
|
- [ ] 기존 코드의 중복 로직 파악
|
|
- [ ] 백엔드 API 현황 파악
|
|
- [ ] 성능 요구사항 정의
|
|
|
|
### 개발 중 확인사항
|
|
|
|
- [ ] 타입 정의 완료
|
|
- [ ] Context 및 Provider 동작 테스트
|
|
- [ ] 각 패널 UI/UX 검토
|
|
- [ ] 기존 컴포넌트와의 호환성 확인
|
|
- [ ] 백엔드 API 연동 테스트
|
|
|
|
### 개발 후 확인사항
|
|
|
|
- [ ] 모든 테이블 컴포넌트에서 정상 작동
|
|
- [ ] 다중 테이블 화면에서 독립성 확인
|
|
- [ ] 성능 요구사항 충족 확인
|
|
- [ ] 사용자 테스트 및 피드백 반영
|
|
- [ ] 문서화 완료
|
|
|
|
### 배포 전 확인사항
|
|
|
|
- [ ] 기존 화면에 영향 없는지 확인
|
|
- [ ] 롤백 계획 수립
|
|
- [ ] 사용자 가이드 작성
|
|
- [ ] 팀 공유 및 교육
|
|
|
|
---
|
|
|
|
## 11. 주의사항
|
|
|
|
### 11.1 멀티테넌시 준수
|
|
|
|
모든 데이터 조회 시 `company_code` 필터링 필수:
|
|
|
|
```typescript
|
|
// ✅ 올바른 방법
|
|
const whereConditions: string[] = [`company_code = $1`];
|
|
const params: any[] = [companyCode];
|
|
|
|
// ❌ 잘못된 방법
|
|
const whereConditions: string[] = []; // company_code 필터링 누락
|
|
```
|
|
|
|
### 11.2 SQL 인젝션 방지
|
|
|
|
필터 값은 반드시 파라미터 바인딩 사용:
|
|
|
|
```typescript
|
|
// ✅ 올바른 방법
|
|
whereConditions.push(`${filter.columnName} = $${paramIndex}`);
|
|
params.push(filter.value);
|
|
|
|
// ❌ 잘못된 방법
|
|
whereConditions.push(`${filter.columnName} = '${filter.value}'`); // SQL 인젝션 위험
|
|
```
|
|
|
|
### 11.3 성능 고려사항
|
|
|
|
- 컬럼이 많은 테이블(100개 이상)의 경우 가상 스크롤 적용
|
|
- 필터 변경 시 디바운싱으로 API 호출 최소화
|
|
- 그룹화는 데이터량에 따라 프론트엔드/백엔드 선택적 처리
|
|
|
|
### 11.4 접근성
|
|
|
|
- 키보드 네비게이션 지원 (Tab, Enter, Esc)
|
|
- 스크린 리더 호환성 확인
|
|
- 색상 대비 4.5:1 이상 유지
|
|
|
|
---
|
|
|
|
## 12. 추가 고려사항
|
|
|
|
### 12.1 설정 저장 기능
|
|
|
|
사용자별로 테이블 설정을 저장하여 화면 재방문 시 복원:
|
|
|
|
```typescript
|
|
// 로컬 스토리지에 저장
|
|
localStorage.setItem(
|
|
`table-settings-${tableId}`,
|
|
JSON.stringify({ columnVisibility, filters, grouping })
|
|
);
|
|
|
|
// 불러오기
|
|
const savedSettings = localStorage.getItem(`table-settings-${tableId}`);
|
|
if (savedSettings) {
|
|
const { columnVisibility, filters, grouping } = JSON.parse(savedSettings);
|
|
setColumnVisibility(columnVisibility);
|
|
setFilters(filters);
|
|
setGrouping(grouping);
|
|
}
|
|
```
|
|
|
|
### 12.2 내보내기 기능
|
|
|
|
현재 필터/그룹 설정으로 Excel 내보내기:
|
|
|
|
```typescript
|
|
const exportToExcel = () => {
|
|
const params = {
|
|
tableName: component.tableName,
|
|
filters: JSON.stringify(filters),
|
|
groupBy: grouping.join(","),
|
|
columns: visibleColumns.map((c) => c.field),
|
|
};
|
|
|
|
window.location.href = `/api/table/export?${new URLSearchParams(params)}`;
|
|
};
|
|
```
|
|
|
|
### 12.3 필터 프리셋
|
|
|
|
자주 사용하는 필터 조합을 프리셋으로 저장:
|
|
|
|
```typescript
|
|
interface FilterPreset {
|
|
id: string;
|
|
name: string;
|
|
filters: TableFilter[];
|
|
grouping: string[];
|
|
}
|
|
|
|
const presets: FilterPreset[] = [
|
|
{ id: "active-items", name: "활성 품목만", filters: [...], grouping: [] },
|
|
{ id: "by-category", name: "카테고리별 그룹", filters: [], grouping: ["category_id"] },
|
|
];
|
|
```
|
|
|
|
---
|
|
|
|
## 13. 참고 자료
|
|
|
|
- [Tanstack Table 문서](https://tanstack.com/table/v8)
|
|
- [shadcn/ui Dialog 컴포넌트](https://ui.shadcn.com/docs/components/dialog)
|
|
- [React Context 최적화 가이드](https://react.dev/learn/passing-data-deeply-with-context)
|
|
- [PostgreSQL 필터링 최적화](https://www.postgresql.org/docs/current/indexes.html)
|
|
|
|
---
|
|
|
|
## 14. 브라우저 테스트 결과
|
|
|
|
### 테스트 환경
|
|
|
|
- **날짜**: 2025-01-13
|
|
- **브라우저**: Chrome
|
|
- **테스트 URL**: http://localhost:9771/screens/106
|
|
- **화면**: DTG 수명주기 관리 - 스텝 (FlowWidget)
|
|
|
|
### 테스트 항목 및 결과
|
|
|
|
#### ✅ 1. 테이블 옵션 (ColumnVisibilityPanel)
|
|
|
|
- **상태**: 정상 동작
|
|
- **테스트 내용**:
|
|
- 툴바의 "테이블 옵션" 버튼 클릭 시 다이얼로그 정상 표시
|
|
- 7개 컬럼 모두 정상 표시 (장치 코드, 시리얼넘버, manufacturer, 모델명, 품번, 차량 타입, 차량 번호)
|
|
- 각 컬럼마다 체크박스, 드래그 핸들, 미리보기 아이콘, 너비 설정 표시
|
|
- "초기화" 버튼 표시
|
|
- **스크린샷**: `column-visibility-panel.png`
|
|
|
|
#### ✅ 2. 필터 설정 (FilterPanel)
|
|
|
|
- **상태**: 정상 동작
|
|
- **테스트 내용**:
|
|
- 툴바의 "필터 설정" 버튼 클릭 시 다이얼로그 정상 표시
|
|
- "총 0개의 검색 필터가 표시됩니다" 메시지 표시
|
|
- "필터 추가" 버튼 정상 표시
|
|
- "초기화" 버튼 표시
|
|
- **스크린샷**: `filter-panel-empty.png`
|
|
|
|
#### ✅ 3. 그룹 설정 (GroupingPanel)
|
|
|
|
- **상태**: 정상 동작
|
|
- **테스트 내용**:
|
|
- 툴바의 "그룹 설정" 버튼 클릭 시 다이얼로그 정상 표시
|
|
- "0개 컬럼으로 그룹화" 메시지 표시
|
|
- 7개 컬럼 모두 체크박스로 표시
|
|
- 각 컬럼의 라벨 및 필드명 정상 표시
|
|
- "초기화" 버튼 표시
|
|
- **스크린샷**: `grouping-panel.png`
|
|
|
|
#### ✅ 4. Context 통합
|
|
|
|
- **상태**: 정상 동작
|
|
- **테스트 내용**:
|
|
- `TableOptionsProvider`가 `/screens/[screenId]/page.tsx`에 정상 통합
|
|
- `FlowWidget` 컴포넌트가 `TableOptionsContext`에 정상 등록
|
|
- 에러 없이 페이지 로드 및 렌더링 완료
|
|
|
|
### 검증 완료 사항
|
|
|
|
1. ✅ 타입 정의 및 Context 구현 완료
|
|
2. ✅ 패널 컴포넌트 3개 구현 완료 (ColumnVisibility, Filter, Grouping)
|
|
3. ✅ TableOptionsToolbar 메인 컴포넌트 구현 완료
|
|
4. ✅ TableOptionsProvider 통합 완료
|
|
5. ✅ FlowWidget에 Context 연동 완료
|
|
6. ✅ 브라우저 테스트 완료 (모든 기능 정상 동작)
|
|
|
|
### 향후 개선 사항
|
|
|
|
1. **백엔드 API 통합**: 현재는 프론트엔드 상태 관리만 구현됨. 백엔드 API에 필터/그룹/컬럼 설정 파라미터 전달 필요
|
|
2. **필터 적용 로직**: 필터 추가 후 실제 데이터 필터링 구현
|
|
3. **그룹화 적용 로직**: 그룹 선택 후 실제 데이터 그룹화 구현
|
|
4. **컬럼 순서/너비 적용**: 드래그앤드롭으로 변경한 순서 및 너비를 실제 테이블에 반영
|
|
|
|
---
|
|
|
|
## 15. 변경 이력
|
|
|
|
| 날짜 | 버전 | 변경 내용 | 작성자 |
|
|
| ---------- | ---- | -------------------------------------------- | ------ |
|
|
| 2025-01-13 | 1.0 | 초안 작성 | AI |
|
|
| 2025-01-13 | 1.1 | 프론트엔드 구현 완료 및 브라우저 테스트 완료 | AI |
|
|
|
|
---
|
|
|
|
## 16. 구현 완료 요약
|
|
|
|
### 생성된 파일
|
|
|
|
1. `frontend/types/table-options.ts` - 타입 정의
|
|
2. `frontend/contexts/TableOptionsContext.tsx` - Context 구현
|
|
3. `frontend/components/screen/table-options/ColumnVisibilityPanel.tsx` - 컬럼 가시성 패널
|
|
4. `frontend/components/screen/table-options/FilterPanel.tsx` - 필터 패널
|
|
5. `frontend/components/screen/table-options/GroupingPanel.tsx` - 그룹핑 패널
|
|
6. `frontend/components/screen/table-options/TableOptionsToolbar.tsx` - 메인 툴바
|
|
|
|
### 수정된 파일
|
|
|
|
1. `frontend/app/(main)/screens/[screenId]/page.tsx` - Provider 통합 (화면 뷰어)
|
|
2. `frontend/components/screen/ScreenDesigner.tsx` - Provider 통합 (화면 디자이너)
|
|
3. `frontend/components/screen/InteractiveDataTable.tsx` - Context 연동
|
|
4. `frontend/components/screen/widgets/FlowWidget.tsx` - Context 연동
|
|
5. `frontend/lib/registry/components/table-list/TableListComponent.tsx` - Context 연동
|
|
6. `frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx` - Context 연동
|
|
|
|
### 구현 완료 기능
|
|
|
|
- ✅ Context API 기반 테이블 자동 감지 시스템
|
|
- ✅ 컬럼 표시/숨기기, 순서 변경, 너비 설정
|
|
- ✅ 필터 추가 UI (백엔드 연동 대기)
|
|
- ✅ 그룹화 컬럼 선택 UI (백엔드 연동 대기)
|
|
- ✅ 여러 테이블 컴포넌트 지원 (FlowWidget, TableList, SplitPanel, InteractiveDataTable)
|
|
- ✅ shadcn/ui 기반 일관된 디자인 시스템
|
|
- ✅ 브라우저 테스트 완료
|
|
|
|
---
|
|
|
|
이 계획서를 검토하신 후 수정사항이나 추가 요구사항을 알려주세요!
|