feat: 분할 패널 내부 컴포넌트 선택 기능 추가

- RealtimePreviewDynamic, ScreenDesigner, DynamicComponentRenderer, SplitPanelLayoutComponent 및 관련 파일에서 분할 패널 내부 컴포넌트 선택 콜백 및 상태 관리 기능을 추가하였습니다.
- 커스텀 모드에서 패널 내부에 컴포넌트를 자유롭게 배치할 수 있는 기능을 구현하였습니다.
- 선택된 패널 컴포넌트의 상태를 관리하고, 관련 UI 요소를 업데이트하여 사용자 경험을 향상시켰습니다.
- 패널의 표시 모드에 'custom' 옵션을 추가하여 사용자 정의 배치 기능을 지원합니다.
This commit is contained in:
kjs 2026-01-30 16:34:05 +09:00
parent 152558d593
commit 17e212118c
11 changed files with 1814 additions and 50 deletions

View File

@ -182,12 +182,43 @@ interface ColumnConfig {
#### 주요 특징 #### 주요 특징
- 좌측: 마스터 목록 (리스트/테이블 모드) - 좌측: 마스터 목록 (리스트/테이블/커스텀 모드)
- 우측: 디테일 정보 (연관 데이터) - 우측: 디테일 정보 (연관 데이터)
- 좌우 비율 조절 가능 (드래그 리사이즈) - 좌우 비율 조절 가능 (드래그 리사이즈)
- 다중 탭 지원 (우측 패널) - 다중 탭 지원 (우측 패널)
- N:M 관계 데이터 지원 - N:M 관계 데이터 지원
- 중복 제거 기능 - 중복 제거 기능
- **커스텀 모드 (NEW)**: 패널 내부에 자유롭게 컴포넌트 배치
#### 커스텀 모드 (displayMode: "custom")
패널 내부에 다양한 컴포넌트를 자유롭게 배치할 수 있는 모드입니다. **v2-tabs-widget과 동일한 구조**로 구현되어 있습니다.
**특징:**
- 패널 내부에 버튼, 테이블, 입력 필드 등 모든 V2 컴포넌트 배치 가능
- 드래그앤드롭으로 컴포넌트 이동
- 리사이즈 핸들로 크기 조절
- **컴포넌트 클릭 시 좌측 설정 패널에서 해당 컴포넌트 속성 편집**
- 디자인 모드에서 실제 컴포넌트가 렌더링되어 미리보기 가능
**사용 방법:**
1. 좌측/우측 패널의 `displayMode``"custom"`으로 설정
2. 컴포넌트 팔레트에서 원하는 컴포넌트를 패널로 드래그
3. 배치된 컴포넌트 클릭 → 좌측 패널에서 속성 편집
4. 드래그 핸들(상단)로 이동, 모서리 핸들로 크기 조절
**컴포넌트 구조:**
```typescript
interface PanelInlineComponent {
id: string;
componentType: string;
label?: string;
position?: { x: number; y: number };
size?: { width: number; height: number };
componentConfig?: Record<string, any>;
style?: Record<string, any>;
}
```
#### 데이터 흐름 #### 데이터 흐름
@ -220,7 +251,7 @@ interface SplitPanelLayoutConfig {
tableName?: string; tableName?: string;
useCustomTable?: boolean; useCustomTable?: boolean;
customTableName?: string; customTableName?: string;
displayMode?: "list" | "table"; displayMode?: "list" | "table" | "custom"; // 🆕 커스텀 모드 추가
showSearch?: boolean; showSearch?: boolean;
showAdd?: boolean; showAdd?: boolean;
showEdit?: boolean; showEdit?: boolean;
@ -228,15 +259,21 @@ interface SplitPanelLayoutConfig {
columns?: ColumnConfig[]; columns?: ColumnConfig[];
tableConfig?: TableDisplayConfig; tableConfig?: TableDisplayConfig;
dataFilter?: DataFilterConfig; dataFilter?: DataFilterConfig;
// 🆕 커스텀 모드 전용: 내부 컴포넌트 배열
components?: PanelInlineComponent[];
}; };
// 우측 패널 // 우측 패널
rightPanel: { rightPanel: {
title: string; title: string;
tableName?: string; tableName?: string;
displayMode?: "list" | "table"; displayMode?: "list" | "table" | "custom"; // 🆕 커스텀 모드 추가
columns?: ColumnConfig[]; columns?: ColumnConfig[];
// 🆕 커스텀 모드 전용: 내부 컴포넌트 배열
components?: PanelInlineComponent[];
// 관계 설정 // 관계 설정
relation?: { relation?: {
type?: "join" | "detail"; type?: "join" | "detail";

View File

@ -959,6 +959,29 @@ receiveData(data, config): Promise<void>
**구독 이벤트**: **구독 이벤트**:
- `refreshTable` - 내부 테이블 갱신 - `refreshTable` - 내부 테이블 갱신
**커스텀 모드 (displayMode: "custom")**:
- 패널 내부에 자유롭게 컴포넌트 배치 가능 (v2-tabs-widget과 동일 구조)
- 컴포넌트 클릭 시 좌측 설정 패널에서 속성 편집
- 드래그앤드롭으로 컴포넌트 이동, 리사이즈 핸들로 크기 조절
- 디자인 모드에서 실제 컴포넌트 렌더링 (미리보기)
```typescript
// 커스텀 모드 설정 예시
leftPanel: {
displayMode: "custom",
components: [
{
id: "btn-1",
componentType: "v2-button-primary",
label: "저장",
position: { x: 10, y: 10 },
size: { width: 100, height: 40 },
componentConfig: { buttonAction: "save" }
}
]
}
```
--- ---
## 7. 연동 가능한 조합 ## 7. 연동 가능한 조합

View File

@ -0,0 +1,582 @@
# 다음 구현 필요 컴포넌트 개발 계획
> **작성일**: 2026-01-30
> **상태**: 계획 수립 완료
> **우선순위**: v2-table-grouped (1순위) → v2-timeline-scheduler (2순위)
---
## 개요
생산계획관리 화면의 정식 버전 구현을 위해 필요한 2개의 신규 컴포넌트 개발 계획입니다.
| 컴포넌트 | 용도 | 난이도 | 예상 작업량 |
|----------|------|:------:|:----------:|
| `v2-table-grouped` | 그룹화 테이블 (접기/펼치기) | 중 | 2-3일 |
| `v2-timeline-scheduler` | 타임라인/간트차트 스케줄러 | 상 | 5-7일 |
---
## 1. v2-table-grouped (그룹화 테이블)
### 1.1 컴포넌트 개요
| 항목 | 내용 |
|------|------|
| **컴포넌트 ID** | `v2-table-grouped` |
| **카테고리** | DISPLAY |
| **용도** | 데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능 제공 |
| **기반 컴포넌트** | `v2-table-list` 확장 |
| **참고 UI** | Excel 그룹화, VS Code 파일 그룹화 |
### 1.2 핵심 기능
| 기능 | 설명 | 우선순위 |
|------|------|:--------:|
| 그룹화 | 지정된 컬럼 기준으로 데이터 그룹핑 | 필수 |
| 접기/펼치기 | 그룹 행 클릭 시 하위 항목 토글 | 필수 |
| 그룹 요약 | 그룹별 합계/개수 표시 | 필수 |
| 다중 그룹 | 여러 컬럼 기준 중첩 그룹화 | 선택 |
| 그룹 선택 | 그룹 체크박스로 하위 전체 선택 | 필수 |
| 전체 펼치기/접기 | 모든 그룹 일괄 토글 | 필수 |
### 1.3 UI 목업
```
┌─────────────────────────────────────────────────────────────────┐
│ [전체 펼치기] [전체 접기] [3개 그룹] │
├─────────────────────────────────────────────────────────────────┤
│ ▼ □ 품목A (P001) 수량: 150 3건 │
│ ├─ □ 2026-01-15 생산계획001 50개 설비A │
│ ├─ □ 2026-01-16 생산계획002 50개 설비B │
│ └─ □ 2026-01-17 생산계획003 50개 설비A │
├─────────────────────────────────────────────────────────────────┤
│ ► □ 품목B (P002) 수량: 200 2건 │ ← 접힌 상태
├─────────────────────────────────────────────────────────────────┤
│ ▼ □ 품목C (P003) 수량: 100 1건 │
│ └─ □ 2026-01-18 생산계획004 100개 설비C │
└─────────────────────────────────────────────────────────────────┘
```
### 1.4 타입 정의 (types.ts)
```typescript
import { ColumnConfig } from "../v2-table-list/types";
/**
* 그룹화 설정
*/
export interface GroupConfig {
/** 그룹화 기준 컬럼 */
groupByColumn: string;
/** 그룹 표시 형식 (예: "{item_name} ({item_code})") */
groupLabelFormat?: string;
/** 그룹 요약 설정 */
summary?: {
/** 합계 컬럼 */
sumColumns?: string[];
/** 개수 표시 여부 */
showCount?: boolean;
};
/** 초기 펼침 상태 */
defaultExpanded?: boolean;
/** 중첩 그룹 (다중 그룹화) */
nestedGroup?: GroupConfig;
}
/**
* 그룹화 테이블 설정
*/
export interface TableGroupedConfig {
/** 테이블명 */
selectedTable?: string;
/** 커스텀 테이블 사용 */
useCustomTable?: boolean;
customTableName?: string;
/** 그룹화 설정 */
groupConfig: GroupConfig;
/** 컬럼 설정 (v2-table-list와 동일) */
columns?: ColumnConfig[];
/** 체크박스 표시 */
showCheckbox?: boolean;
/** 체크박스 모드 */
checkboxMode?: "single" | "multi";
/** 페이지네이션 (그룹 단위) */
pagination?: {
enabled: boolean;
pageSize: number;
};
/** 정렬 설정 */
defaultSort?: {
column: string;
direction: "asc" | "desc";
};
}
/**
* 그룹 상태
*/
export interface GroupState {
/** 그룹 키 (groupByColumn 값) */
groupKey: string;
/** 펼침 여부 */
expanded: boolean;
/** 그룹 내 데이터 */
items: any[];
/** 그룹 요약 데이터 */
summary?: Record<string, number>;
}
```
### 1.5 파일 구조
```
frontend/lib/registry/components/v2-table-grouped/
├── index.ts # Definition (V2TableGroupedDefinition)
├── types.ts # 타입 정의
├── config.ts # 기본 설정값
├── TableGroupedComponent.tsx # 메인 컴포넌트
├── TableGroupedConfigPanel.tsx # 설정 패널
├── TableGroupedRenderer.tsx # 레지스트리 등록
├── components/
│ ├── GroupHeader.tsx # 그룹 헤더 (펼치기/접기)
│ ├── GroupSummary.tsx # 그룹 요약
│ └── GroupCheckbox.tsx # 그룹 체크박스
├── hooks/
│ └── useGroupedData.ts # 그룹화 로직 훅
└── README.md
```
### 1.6 구현 단계
| 단계 | 작업 내용 | 예상 시간 |
|:----:|----------|:---------:|
| 1 | 타입 정의 및 기본 구조 생성 | 2시간 |
| 2 | `useGroupedData` 훅 구현 (데이터 그룹화 로직) | 4시간 |
| 3 | `GroupHeader` 컴포넌트 (펼치기/접기 UI) | 2시간 |
| 4 | `TableGroupedComponent` 메인 구현 | 6시간 |
| 5 | 그룹 체크박스 연동 | 2시간 |
| 6 | 그룹 요약 (합계/개수) | 2시간 |
| 7 | `TableGroupedConfigPanel` 설정 패널 | 4시간 |
| 8 | 테스트 및 문서화 | 2시간 |
**총 예상: 24시간 (약 3일)**
### 1.7 v2-table-list와의 차이점
| 항목 | v2-table-list | v2-table-grouped |
|------|---------------|------------------|
| 데이터 구조 | 플랫 리스트 | 계층 구조 (그룹 > 아이템) |
| 렌더링 | 행 단위 | 그룹 헤더 + 상세 행 |
| 선택 | 개별 행 | 그룹 단위 / 개별 단위 |
| 요약 | 전체 합계 (선택) | 그룹별 요약 |
| 페이지네이션 | 행 단위 | 그룹 단위 |
---
## 2. v2-timeline-scheduler (타임라인 스케줄러)
### 2.1 컴포넌트 개요
| 항목 | 내용 |
|------|------|
| **컴포넌트 ID** | `v2-timeline-scheduler` |
| **카테고리** | DISPLAY |
| **용도** | 간트차트 형태의 일정/계획 시각화 및 편집 |
| **참고 UI** | MS Project, Jira Timeline, dhtmlxGantt |
| **외부 라이브러리** | 고려 중: `@tanstack/react-virtual` (가상 스크롤) |
### 2.2 핵심 기능
| 기능 | 설명 | 우선순위 |
|------|------|:--------:|
| 타임라인 그리드 | 날짜 기준 그리드 표시 (일/주/월) | 필수 |
| 스케줄 바 | 시작~종료 기간 바 렌더링 | 필수 |
| 리소스 행 | 설비/작업자별 행 구분 | 필수 |
| 드래그 이동 | 스케줄 바 드래그로 날짜 변경 | 필수 |
| 리사이즈 | 바 양쪽 핸들로 기간 조정 | 필수 |
| 줌 레벨 | 일/주/월 단위 전환 | 필수 |
| 충돌 표시 | 같은 리소스 시간 겹침 경고 | 선택 |
| 진행률 표시 | 바 내부 진행률 표시 | 선택 |
| 마일스톤 | 단일 일정 마커 | 선택 |
### 2.3 UI 목업
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ [◀ 이전] [오늘] [다음 ▶] 2026년 1월 [일] [주] [월] [+ 추가] │
├────────────┬─────────────────────────────────────────────────────────────────┤
│ │ 15(수) │ 16(목) │ 17(금) │ 18(토) │ 19(일) │ 20(월) │ 21(화) │
├────────────┼─────────────────────────────────────────────────────────────────┤
│ 설비A │ ████████████████ │
│ │ [생산계획001] │ │
├────────────┼─────────────────────────────────────────────────────────────────┤
│ 설비B │ █████████████████████████ │
│ │ [생산계획002 ] │
├────────────┼─────────────────────────────────────────────────────────────────┤
│ 설비C │ ████████████████ │
│ │ [생산계획003] │
└────────────┴─────────────────────────────────────────────────────────────────┘
범례: ██ 진행중 ██ 완료 ██ 지연 ◆ 마일스톤
```
### 2.4 타입 정의 (types.ts)
```typescript
/**
* 줌 레벨
*/
export type ZoomLevel = "day" | "week" | "month";
/**
* 스케줄 상태
*/
export type ScheduleStatus = "planned" | "in_progress" | "completed" | "delayed" | "cancelled";
/**
* 스케줄 항목
*/
export interface ScheduleItem {
/** 고유 ID */
id: string;
/** 리소스 ID (설비/작업자) */
resourceId: string;
/** 표시 제목 */
title: string;
/** 시작 일시 */
startDate: string; // ISO 8601 format
/** 종료 일시 */
endDate: string;
/** 상태 */
status: ScheduleStatus;
/** 진행률 (0-100) */
progress?: number;
/** 색상 (CSS color) */
color?: string;
/** 추가 데이터 */
data?: Record<string, any>;
}
/**
* 리소스 (행)
*/
export interface Resource {
/** 리소스 ID */
id: string;
/** 표시명 */
name: string;
/** 그룹 (선택) */
group?: string;
/** 아이콘 (선택) */
icon?: string;
/** 용량 (선택, 충돌 계산용) */
capacity?: number;
}
/**
* 타임라인 설정
*/
export interface TimelineSchedulerConfig {
/** 테이블명 (스케줄 데이터) */
selectedTable?: string;
/** 리소스 테이블명 */
resourceTable?: string;
/** 필드 매핑 */
fieldMapping: {
id: string;
resourceId: string;
title: string;
startDate: string;
endDate: string;
status?: string;
progress?: string;
color?: string;
};
/** 리소스 필드 매핑 */
resourceFieldMapping?: {
id: string;
name: string;
group?: string;
};
/** 초기 줌 레벨 */
defaultZoomLevel?: ZoomLevel;
/** 초기 표시 날짜 */
initialDate?: string;
/** 편집 가능 여부 */
editable?: boolean;
/** 드래그 이동 허용 */
allowDrag?: boolean;
/** 리사이즈 허용 */
allowResize?: boolean;
/** 충돌 체크 */
checkConflicts?: boolean;
/** 상태별 색상 */
statusColors?: Record<ScheduleStatus, string>;
/** 리소스 컬럼 너비 */
resourceColumnWidth?: number;
/** 행 높이 */
rowHeight?: number;
/** 셀 너비 (줌 레벨별) */
cellWidth?: {
day: number;
week: number;
month: number;
};
/** 툴바 표시 */
showToolbar?: boolean;
/** 범례 표시 */
showLegend?: boolean;
}
/**
* 이벤트 핸들러
*/
export interface TimelineEvents {
/** 스케줄 클릭 */
onScheduleClick?: (schedule: ScheduleItem) => void;
/** 스케줄 더블클릭 */
onScheduleDoubleClick?: (schedule: ScheduleItem) => void;
/** 드래그 완료 */
onScheduleDrag?: (schedule: ScheduleItem, newStart: Date, newEnd: Date) => void;
/** 리사이즈 완료 */
onScheduleResize?: (schedule: ScheduleItem, newStart: Date, newEnd: Date) => void;
/** 빈 영역 클릭 (새 스케줄 추가용) */
onEmptyClick?: (resourceId: string, date: Date) => void;
}
```
### 2.5 파일 구조
```
frontend/lib/registry/components/v2-timeline-scheduler/
├── index.ts # Definition (V2TimelineSchedulerDefinition)
├── types.ts # 타입 정의
├── config.ts # 기본 설정값
├── TimelineSchedulerComponent.tsx # 메인 컴포넌트
├── TimelineSchedulerConfigPanel.tsx # 설정 패널
├── TimelineSchedulerRenderer.tsx # 레지스트리 등록
├── components/
│ ├── TimelineHeader.tsx # 날짜 헤더
│ ├── TimelineGrid.tsx # 그리드 배경
│ ├── ResourceColumn.tsx # 리소스 컬럼 (좌측)
│ ├── ScheduleBar.tsx # 스케줄 바 (드래그/리사이즈)
│ ├── TimelineToolbar.tsx # 툴바 (줌, 네비게이션)
│ ├── TimelineLegend.tsx # 범례
│ └── ConflictIndicator.tsx # 충돌 표시
├── hooks/
│ ├── useTimelineState.ts # 타임라인 상태 관리
│ ├── useScheduleDrag.ts # 드래그 로직
│ ├── useScheduleResize.ts # 리사이즈 로직
│ └── useDateCalculation.ts # 날짜/위치 계산
├── utils/
│ ├── dateUtils.ts # 날짜 유틸리티
│ └── conflictDetection.ts # 충돌 감지
└── README.md
```
### 2.6 구현 단계
| 단계 | 작업 내용 | 예상 시간 |
|:----:|----------|:---------:|
| 1 | 타입 정의 및 기본 구조 생성 | 3시간 |
| 2 | `TimelineHeader` (날짜 헤더, 줌 레벨) | 4시간 |
| 3 | `TimelineGrid` (그리드 배경) | 3시간 |
| 4 | `ResourceColumn` (리소스 목록) | 2시간 |
| 5 | `ScheduleBar` 기본 렌더링 | 4시간 |
| 6 | 드래그 이동 구현 | 6시간 |
| 7 | 리사이즈 구현 | 4시간 |
| 8 | 줌 레벨 전환 (일/주/월) | 3시간 |
| 9 | 날짜 네비게이션 | 2시간 |
| 10 | 충돌 감지 및 표시 | 4시간 |
| 11 | 가상 스크롤 (대용량 데이터) | 4시간 |
| 12 | `TimelineSchedulerConfigPanel` | 4시간 |
| 13 | API 연동 (저장/로드) | 4시간 |
| 14 | 테스트 및 문서화 | 3시간 |
**총 예상: 50시간 (약 6-7일)**
### 2.7 핵심 알고리즘
#### 날짜 → 픽셀 위치 변환
```typescript
function dateToPosition(date: Date, viewStart: Date, cellWidth: number, zoomLevel: ZoomLevel): number {
const diffMs = date.getTime() - viewStart.getTime();
switch (zoomLevel) {
case "day":
const diffDays = diffMs / (1000 * 60 * 60 * 24);
return diffDays * cellWidth;
case "week":
const diffWeeks = diffMs / (1000 * 60 * 60 * 24 * 7);
return diffWeeks * cellWidth;
case "month":
// 월 단위는 일수가 다르므로 별도 계산
return calculateMonthPosition(date, viewStart, cellWidth);
}
}
```
#### 충돌 감지
```typescript
function detectConflicts(schedules: ScheduleItem[], resourceId: string): ScheduleItem[][] {
const resourceSchedules = schedules
.filter(s => s.resourceId === resourceId)
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
const conflicts: ScheduleItem[][] = [];
for (let i = 0; i < resourceSchedules.length; i++) {
const current = resourceSchedules[i];
const overlapping = resourceSchedules.filter(s =>
s.id !== current.id &&
new Date(s.startDate) < new Date(current.endDate) &&
new Date(s.endDate) > new Date(current.startDate)
);
if (overlapping.length > 0) {
conflicts.push([current, ...overlapping]);
}
}
return conflicts;
}
```
---
## 3. 구현 우선순위 및 일정
### 3.1 권장 순서
```
1단계: v2-table-grouped (2-3일)
2단계: v2-timeline-scheduler (5-7일)
3단계: 생산계획관리 정식 버전 화면 구성 (1-2일)
```
### 3.2 이유
1. **v2-table-grouped 먼저**:
- `v2-table-list` 기반 확장으로 난이도 낮음
- 생산계획 외 다른 화면(BOM, 수주 등)에서도 활용 가능
- 타임라인 개발 중에도 테스트용으로 사용 가능
2. **v2-timeline-scheduler 나중**:
- 복잡도가 높아 집중 개발 필요
- 드래그/리사이즈 등 인터랙션 테스트 필요
- 생산계획관리 전용 컴포넌트
### 3.3 체크리스트
#### v2-table-grouped
- [ ] 타입 정의 완료
- [ ] 기본 구조 생성
- [ ] useGroupedData 훅 구현
- [ ] GroupHeader 컴포넌트
- [ ] 메인 컴포넌트 구현
- [ ] 그룹 체크박스 연동
- [ ] 그룹 요약 (합계/개수)
- [ ] 설정 패널 구현
- [ ] 레지스트리 등록
- [ ] 테스트 완료
- [ ] 문서화
#### v2-timeline-scheduler
- [ ] 타입 정의 완료
- [ ] 기본 구조 생성
- [ ] TimelineHeader (날짜)
- [ ] TimelineGrid (배경)
- [ ] ResourceColumn (리소스)
- [ ] ScheduleBar 기본 렌더링
- [ ] 드래그 이동
- [ ] 리사이즈
- [ ] 줌 레벨 전환
- [ ] 날짜 네비게이션
- [ ] 충돌 감지
- [ ] 가상 스크롤
- [ ] 설정 패널 구현
- [ ] API 연동
- [ ] 레지스트리 등록
- [ ] 테스트 완료
- [ ] 문서화
---
## 4. 참고 자료
### 기존 V2 컴포넌트 참고
- `v2-table-list`: 테이블 렌더링, 체크박스, 페이지네이션
- `v2-pivot-grid`: 복잡한 그리드 렌더링, 가상 스크롤
- `v2-split-panel-layout`: 커스텀 모드 컴포넌트 배치
### 외부 라이브러리 검토
| 라이브러리 | 용도 | 고려 사항 |
|----------|------|----------|
| `@tanstack/react-virtual` | 가상 스크롤 | 이미 사용 중, 확장 용이 |
| `date-fns` | 날짜 계산 | 이미 사용 중 |
| `react-dnd` | 드래그앤드롭 | 검토 필요, 현재 네이티브 구현 |
### 관련 문서
- [생산계획관리 화면 설계](../03_production/production-plan.md)
- [V2 컴포넌트 분석 가이드](../../V2_컴포넌트_분석_가이드.md)
- [컴포넌트 개발 가이드](../../../frontend/docs/component-development-guide.md)
---
**작성자**: Claude AI
**최종 수정**: 2026-01-30

View File

@ -63,7 +63,7 @@
| ID | 이름 | 용도 | 주요 옵션 | | ID | 이름 | 용도 | 주요 옵션 |
|----|------|------|----------| |----|------|------|----------|
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, relation | | `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, relation, **displayMode: custom** |
| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs, activeTabId | | `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs, activeTabId |
| `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 | title, collapsible, padding | | `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 | title, collapsible, padding |
| `v2-section-paper` | 섹션 페이퍼 | 배경색 그룹화 | backgroundColor, padding, shadow | | `v2-section-paper` | 섹션 페이퍼 | 배경색 그룹화 | backgroundColor, padding, shadow |
@ -284,6 +284,45 @@
} }
``` ```
#### v2-split-panel-layout 커스텀 모드 (NEW)
패널 내부에 자유롭게 컴포넌트를 배치할 수 있습니다. (v2-tabs-widget과 동일 구조)
```typescript
{
leftPanel: {
displayMode: "custom", // 커스텀 모드 활성화
components: [ // 내부 컴포넌트 배열
{
id: "btn-save",
componentType: "v2-button-primary",
label: "저장",
position: { x: 10, y: 10 },
size: { width: 100, height: 40 },
componentConfig: { buttonAction: "save" }
},
{
id: "tbl-list",
componentType: "v2-table-list",
label: "목록",
position: { x: 10, y: 60 },
size: { width: 400, height: 300 },
componentConfig: { selectedTable: "테이블명" }
}
]
},
rightPanel: {
displayMode: "table" // 기존 모드 유지
}
}
```
**디자인 모드 기능**:
- 컴포넌트 클릭 → 좌측 설정 패널에서 속성 편집
- 드래그 핸들(상단)로 이동
- 리사이즈 핸들(모서리)로 크기 조절
- 실제 컴포넌트 미리보기 렌더링
#### v2-card-display 필수 설정 #### v2-card-display 필수 설정
```typescript ```typescript

View File

@ -5,8 +5,8 @@
> ⚠️ **구현 상태 안내** > ⚠️ **구현 상태 안내**
> - **간소화 버전 (패턴 A)**: ✅ 즉시 구현 가능 > - **간소화 버전 (패턴 A)**: ✅ 즉시 구현 가능
> - **정식 버전 (복합 레이아웃)**: ⚠️ 컴포넌트 확장/개발 후 구현 가능 > - **정식 버전 (복합 레이아웃)**: ⚠️ 컴포넌트 개발 후 구현 가능
> - `v2-split-panel-layout` 커스텀 모드 확장 (1개) > - ~~`v2-split-panel-layout` 커스텀 모드 확장~~ ✅ **구현 완료**
> - `v2-table-grouped`, `v2-timeline-scheduler` 신규 개발 (2개) > - `v2-table-grouped`, `v2-timeline-scheduler` 신규 개발 (2개)
--- ---
@ -24,7 +24,7 @@
| **메인 테이블** | `production_plan_mng` | | **메인 테이블** | `production_plan_mng` |
| **관련 테이블** | `sales_order_mng`, `item_info`, `equipment_info`, `bom_info` | | **관련 테이블** | `sales_order_mng`, `item_info`, `equipment_info`, `bom_info` |
| **주요 기능** | 수주 기반 생산계획 수립, 타임라인 스케줄러, 자동 스케줄 생성, 반제품 계획 연동 | | **주요 기능** | 수주 기반 생산계획 수립, 타임라인 스케줄러, 자동 스케줄 생성, 반제품 계획 연동 |
| **필요 작업** | 1. `v2-split-panel-layout` 확장 2. `v2-table-grouped` 개발 3. `v2-timeline-scheduler` 개발 | | **필요 작업** | ~~1. `v2-split-panel-layout` 확장~~ ✅ 완료 / 2. `v2-table-grouped` 개발 / 3. `v2-timeline-scheduler` 개발 |
--- ---
@ -169,7 +169,7 @@
| 기본 버튼 (엑셀, 사용자옵션) | ✅ | `v2-button-primary` | | | 기본 버튼 (엑셀, 사용자옵션) | ✅ | `v2-button-primary` | |
| 단일 테이블 목록 | ✅ | `v2-table-list` | | | 단일 테이블 목록 | ✅ | `v2-table-list` | |
| 기본 모달 폼 | ✅ | 모달 화면 | | | 기본 모달 폼 | ✅ | 모달 화면 | |
| 좌우 분할 패널 (기본) | ⚠️ | `v2-split-panel-layout` | 테이블/리스트만 표시 가능 | | 좌우 분할 패널 (커스텀 모드) | ✅ | `v2-split-panel-layout` | **displayMode: "custom" 지원** |
| 탭 컴포넌트 (기본) | ⚠️ | `v2-tabs-widget` | 디자인 모드에서 컴포넌트 배치 | | 탭 컴포넌트 (기본) | ⚠️ | `v2-tabs-widget` | 디자인 모드에서 컴포넌트 배치 |
### 4.2 현재 V2 컴포넌트의 제한 사항 및 확장 방안 ### 4.2 현재 V2 컴포넌트의 제한 사항 및 확장 방안
@ -182,37 +182,41 @@
| 테이블/리스트 표시 | ✅ | `displayMode: "list" \| "table"` | | 테이블/리스트 표시 | ✅ | `displayMode: "list" \| "table"` |
| 마스터-디테일 관계 | ✅ | `relation` 설정 | | 마스터-디테일 관계 | ✅ | `relation` 설정 |
| 우측 추가 탭 (`additionalTabs`) | ⚠️ | **타입만 정의, 렌더링 미구현** | | 우측 추가 탭 (`additionalTabs`) | ⚠️ | **타입만 정의, 렌더링 미구현** |
| 패널 내 임의 컴포넌트 배치 | ❌ | 미지원 | | **패널 내 임의 컴포넌트 배치** | ✅ | **`displayMode: "custom"` - 구현 완료!** |
#### 🆕 확장 방안: 커스텀 모드 추가 #### ✅ 커스텀 모드 (구현 완료)
`v2-tabs-widget`처럼 패널 안에 컴포넌트를 자유롭게 배치하는 **커스텀 모드** 추가: `v2-tabs-widget`과 동일한 구조로 패널 안에 컴포넌트를 자유롭게 배치:
```typescript ```typescript
// 현재 구조
leftPanel: { leftPanel: {
displayMode: "list" | "table", // 2가지만 지원 displayMode: "list" | "table" | "custom", // ✅ 커스텀 모드 지원
tableName: string,
columns: [...],
}
// 🆕 확장 구조
leftPanel: {
displayMode: "list" | "table" | "custom", // 커스텀 모드 추가
// 기존 모드 (list, table) // 기존 모드 (list, table)
tableName?: string, tableName?: string,
columns?: [...], columns?: [...],
// 🆕 커스텀 모드 (탭 컴포넌트처럼 컴포넌트 배치) // ✅ 커스텀 모드 - 패널 안에 자유롭게 컴포넌트 배치
components?: TabInlineComponent[], // 패널 안에 자유롭게 컴포넌트 배치 components?: PanelInlineComponent[],
}
// PanelInlineComponent 구조
interface PanelInlineComponent {
id: string;
componentType: string;
label?: string;
position?: { x: number; y: number };
size?: { width: number; height: number };
componentConfig?: Record<string, any>;
} }
``` ```
**커스텀 모드 장점**: **커스텀 모드 기능**:
- 기존 `v2-tabs-widget`의 드래그&드롭 로직 재사용 - ✅ 드래그앤드롭으로 컴포넌트 이동
- 패널 안에 탭, 테이블, 타임라인 등 어떤 컴포넌트든 배치 가능 - ✅ 리사이즈 핸들로 크기 조절
- `v2-split-panel-enhanced` 별도 개발 불필요 - ✅ 컴포넌트 클릭 시 좌측 설정 패널에서 속성 편집
- ✅ 디자인 모드에서 실제 컴포넌트 미리보기 렌더링
- ✅ 탭, 테이블, 버튼 등 모든 V2 컴포넌트 배치 가능
#### `v2-tabs-widget` 현재 기능 #### `v2-tabs-widget` 현재 기능
@ -244,8 +248,8 @@ interface TabInlineComponent {
| 필요 기능 | 현재 지원 | 해결 방안 | | 필요 기능 | 현재 지원 | 해결 방안 |
|----------|:---------:|----------| |----------|:---------:|----------|
| 좌우 분할 패널 | ✅ | `v2-split-panel-layout` | | 좌우 분할 패널 | ✅ | `v2-split-panel-layout` |
| 왼쪽 패널 탭 (수주/안전재고) | ⚠️ | 커스텀 모드 + `v2-tabs-widget` 조합 | | 왼쪽 패널 탭 (수주/안전재고) | ✅ | **커스텀 모드 + `v2-tabs-widget` 조합 (구현 완료)** |
| 오른쪽 패널 탭 (완제품/반제품) | ⚠️ | 커스텀 모드 + `v2-tabs-widget` 조합 | | 오른쪽 패널 탭 (완제품/반제품) | ✅ | **커스텀 모드 + `v2-tabs-widget` 조합 (구현 완료)** |
| **그룹화된 테이블** (품목별 접기/펼치기) | ❌ | **신규 개발 필요** | | **그룹화된 테이블** (품목별 접기/펼치기) | ❌ | **신규 개발 필요** |
| **타임라인 스케줄러** (간트차트) | ❌ | **신규 개발 필요** | | **타임라인 스케줄러** (간트차트) | ❌ | **신규 개발 필요** |
| 드래그&드롭 스케줄 이동 | ❌ | `v2-timeline-scheduler`에 포함 | | 드래그&드롭 스케줄 이동 | ❌ | `v2-timeline-scheduler`에 포함 |
@ -255,9 +259,9 @@ interface TabInlineComponent {
#### 기존 컴포넌트 확장 (1개) #### 기존 컴포넌트 확장 (1개)
| 컴포넌트 | 작업 내용 | 난이도 | | 컴포넌트 | 작업 내용 | 상태 |
|----------|----------|:------:| |----------|----------|:------:|
| `v2-split-panel-layout` | `displayMode: "custom"` 추가 + `components[]` 지원 | | | `v2-split-panel-layout` | `displayMode: "custom"` 추가 + `components[]` 지원 | **완료** |
#### 신규 컴포넌트 개발 (2개) - 대체 불가 #### 신규 컴포넌트 개발 (2개) - 대체 불가
@ -275,7 +279,7 @@ interface TabInlineComponent {
``` ```
최종 필요 작업: 최종 필요 작업:
1. v2-split-panel-layout 확장 - displayMode: "custom" 추가 1. v2-split-panel-layout 확장 - displayMode: "custom" 추가 [완료]
2. v2-table-grouped 신규 개발 - 그룹화 테이블 (접기/펼치기) 2. v2-table-grouped 신규 개발 - 그룹화 테이블 (접기/펼치기)
3. v2-timeline-scheduler 신규 개발 - 타임라인/간트차트 (핵심!) 3. v2-timeline-scheduler 신규 개발 - 타임라인/간트차트 (핵심!)
``` ```
@ -632,10 +636,11 @@ V2 컴포넌트로 기본 CRUD 화면 구현:
### Phase 2: 컴포넌트 확장 및 개발 ### Phase 2: 컴포넌트 확장 및 개발
1. **`v2-split-panel-layout` 확장** 1. ~~**`v2-split-panel-layout` 확장**~~ ✅ **완료**
- `displayMode: "custom"` 추가 - ✅ `displayMode: "custom"` 추가
- `components[]` 필드 지원 (탭 컴포넌트와 동일한 구조) - ✅ `components[]` 필드 지원 (탭 컴포넌트와 동일한 구조)
- 디자인 모드에서 컴포넌트 드래그&드롭 지원 - ✅ 디자인 모드에서 컴포넌트 드래그&드롭 지원
- ✅ 컴포넌트 클릭 시 좌측 설정 패널에서 속성 편집
2. **`v2-table-grouped` 신규 개발** 2. **`v2-table-grouped` 신규 개발**
- 품목별 그룹핑 (접기/펼치기) - 품목별 그룹핑 (접기/펼치기)
@ -1193,7 +1198,7 @@ v2-table-list (생산계획 목록)
| 체크 | 항목 | 설명 | | 체크 | 항목 | 설명 |
|:----:|------|------| |:----:|------|------|
| ☐ | **v2-split-panel-layout 확장** | `displayMode: "custom"` + `components[]` 추가 | | ✅ | ~~**v2-split-panel-layout 확장**~~ | `displayMode: "custom"` + `components[]` 추가 **완료** |
| ☐ | **v2-table-grouped 개발** | 그룹화 테이블 컴포넌트 (접기/펼치기) | | ☐ | **v2-table-grouped 개발** | 그룹화 테이블 컴포넌트 (접기/펼치기) |
| ☐ | **v2-timeline-scheduler 개발** | 타임라인/간트차트 스케줄러 | | ☐ | **v2-timeline-scheduler 개발** | 타임라인/간트차트 스케줄러 |
| ☐ | **드래그&드롭 기능** | 타임라인 바 드래그 이동/크기 조정 | | ☐ | **드래그&드롭 기능** | 타임라인 바 드래그 이동/크기 조정 |

View File

@ -39,6 +39,8 @@ interface RealtimePreviewProps {
onUpdateComponent?: (updatedComponent: any) => void; // 🆕 컴포넌트 업데이트 콜백 onUpdateComponent?: (updatedComponent: any) => void; // 🆕 컴포넌트 업데이트 콜백
onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; // 🆕 탭 내부 컴포넌트 선택 콜백 onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; // 🆕 탭 내부 컴포넌트 선택 콜백
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백
selectedPanelComponentId?: string; // 🆕 선택된 분할 패널 컴포넌트 ID
onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백 onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백
// 버튼 액션을 위한 props // 버튼 액션을 위한 props
@ -140,6 +142,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
onUpdateComponent, // 🆕 컴포넌트 업데이트 콜백 onUpdateComponent, // 🆕 컴포넌트 업데이트 콜백
onSelectTabComponent, // 🆕 탭 내부 컴포넌트 선택 콜백 onSelectTabComponent, // 🆕 탭 내부 컴포넌트 선택 콜백
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백
selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID
onResize, // 🆕 리사이즈 콜백 onResize, // 🆕 리사이즈 콜백
}) => { }) => {
// 🆕 화면 다국어 컨텍스트 // 🆕 화면 다국어 컨텍스트
@ -640,6 +644,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
onUpdateComponent={onUpdateComponent} onUpdateComponent={onUpdateComponent}
onSelectTabComponent={onSelectTabComponent} onSelectTabComponent={onSelectTabComponent}
selectedTabComponentId={selectedTabComponentId} selectedTabComponentId={selectedTabComponentId}
onSelectPanelComponent={onSelectPanelComponent}
selectedPanelComponentId={selectedPanelComponentId}
/> />
</div> </div>

View File

@ -177,13 +177,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
component: any; // 탭 내부 컴포넌트 데이터 component: any; // 탭 내부 컴포넌트 데이터
} | null>(null); } | null>(null);
// 🆕 분할 패널 내부 컴포넌트 선택 상태
const [selectedPanelComponentInfo, setSelectedPanelComponentInfo] = useState<{
splitPanelId: string; // 분할 패널 컴포넌트 ID
panelSide: "left" | "right"; // 좌측/우측 패널
componentId: string; // 패널 내부 컴포넌트 ID
component: any; // 패널 내부 컴포넌트 데이터
} | null>(null);
// 컴포넌트 선택 시 통합 패널 자동 열기 // 컴포넌트 선택 시 통합 패널 자동 열기
const handleComponentSelect = useCallback( const handleComponentSelect = useCallback(
(component: ComponentData | null) => { (component: ComponentData | null) => {
setSelectedComponent(component); setSelectedComponent(component);
// 일반 컴포넌트 선택 시 탭 내부 컴포넌트 선택 해제 // 일반 컴포넌트 선택 시 탭 내부 컴포넌트/분할 패널 내부 컴포넌트 선택 해제
if (component) { if (component) {
setSelectedTabComponentInfo(null); setSelectedTabComponentInfo(null);
setSelectedPanelComponentInfo(null);
} }
// 컴포넌트가 선택되면 통합 패널 자동 열기 // 컴포넌트가 선택되면 통합 패널 자동 열기
@ -209,8 +218,32 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
componentId: compId, componentId: compId,
component: comp, component: comp,
}); });
// 탭 내부 컴포넌트 선택 시 일반 컴포넌트 선택 해제 // 탭 내부 컴포넌트 선택 시 일반 컴포넌트/분할 패널 내부 컴포넌트 선택 해제
setSelectedComponent(null); setSelectedComponent(null);
setSelectedPanelComponentInfo(null);
openPanel("v2");
},
[openPanel],
);
// 🆕 분할 패널 내부 컴포넌트 선택 핸들러
const handleSelectPanelComponent = useCallback(
(splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => {
if (!compId) {
// 패널 영역 빈 공간 클릭 시 선택 해제
setSelectedPanelComponentInfo(null);
return;
}
setSelectedPanelComponentInfo({
splitPanelId,
panelSide,
componentId: compId,
component: comp,
});
// 분할 패널 내부 컴포넌트 선택 시 일반 컴포넌트/탭 내부 컴포넌트 선택 해제
setSelectedComponent(null);
setSelectedTabComponentInfo(null);
openPanel("v2"); openPanel("v2");
}, },
[openPanel], [openPanel],
@ -2509,6 +2542,72 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
} }
} }
// 🎯 분할 패널 커스텀 모드 컨테이너 내부 드롭 처리
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
if (splitPanelContainer) {
const containerId = splitPanelContainer.getAttribute("data-component-id");
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
if (containerId && panelSide) {
const targetComponent = layout.components.find((c) => c.id === containerId);
const compType = (targetComponent as any)?.componentType;
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
const currentConfig = (targetComponent as any).componentConfig || {};
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
const panelConfig = currentConfig[panelKey] || {};
const currentComponents = panelConfig.components || [];
// 드롭 위치 계산
const panelRect = splitPanelContainer.getBoundingClientRect();
const dropX = (e.clientX - panelRect.left) / zoomLevel;
const dropY = (e.clientY - panelRect.top) / zoomLevel;
// 새 컴포넌트 생성
const componentType = component.id || component.componentType || "v2-text-display";
console.log("🎯 분할 패널에 컴포넌트 드롭:", {
componentId: component.id,
componentType: componentType,
panelSide: panelSide,
dropPosition: { x: dropX, y: dropY },
});
const newPanelComponent = {
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
componentType: componentType,
label: component.name || component.label || "새 컴포넌트",
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
size: component.defaultSize || { width: 200, height: 100 },
componentConfig: component.defaultConfig || {},
};
const updatedPanelConfig = {
...panelConfig,
components: [...currentComponents, newPanelComponent],
};
const updatedComponent = {
...targetComponent,
componentConfig: {
...currentConfig,
[panelKey]: updatedPanelConfig,
},
};
const newLayout = {
...layout,
components: layout.components.map((c) =>
c.id === containerId ? updatedComponent : c
),
};
setLayout(newLayout);
saveToHistory(newLayout);
toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`);
return; // 분할 패널 처리 완료
}
}
}
const rect = canvasRef.current?.getBoundingClientRect(); const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return; if (!rect) return;
@ -2996,6 +3095,105 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
} }
} }
// 🎯 분할 패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
if (splitPanelContainer && type === "column" && column) {
const containerId = splitPanelContainer.getAttribute("data-component-id");
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
if (containerId && panelSide) {
const targetComponent = layout.components.find((c) => c.id === containerId);
const compType = (targetComponent as any)?.componentType;
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
const currentConfig = (targetComponent as any).componentConfig || {};
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
const panelConfig = currentConfig[panelKey] || {};
const currentComponents = panelConfig.components || [];
// 드롭 위치 계산
const panelRect = splitPanelContainer.getBoundingClientRect();
const dropX = (e.clientX - panelRect.left) / zoomLevel;
const dropY = (e.clientY - panelRect.top) / zoomLevel;
// V2 컴포넌트 매핑 사용
const v2Mapping = createV2ConfigFromColumn({
widgetType: column.widgetType,
columnName: column.columnName,
columnLabel: column.columnLabel,
codeCategory: column.codeCategory,
inputType: column.inputType,
required: column.required,
detailSettings: column.detailSettings,
referenceTable: column.referenceTable,
referenceColumn: column.referenceColumn,
displayColumn: column.displayColumn,
});
// 웹타입별 기본 크기 계산
const getPanelComponentSize = (widgetType: string) => {
const sizeMap: Record<string, { width: number; height: number }> = {
text: { width: 200, height: 36 },
number: { width: 150, height: 36 },
decimal: { width: 150, height: 36 },
date: { width: 180, height: 36 },
datetime: { width: 200, height: 36 },
select: { width: 200, height: 36 },
category: { width: 200, height: 36 },
code: { width: 200, height: 36 },
entity: { width: 220, height: 36 },
boolean: { width: 120, height: 36 },
checkbox: { width: 120, height: 36 },
textarea: { width: 300, height: 100 },
file: { width: 250, height: 80 },
};
return sizeMap[widgetType] || { width: 200, height: 36 };
};
const componentSize = getPanelComponentSize(column.widgetType);
const newPanelComponent = {
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
componentType: v2Mapping.componentType,
label: column.columnLabel || column.columnName,
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
size: componentSize,
inputType: column.inputType || column.widgetType,
widgetType: column.widgetType,
componentConfig: {
...v2Mapping.componentConfig,
columnName: column.columnName,
tableName: column.tableName,
inputType: column.inputType || column.widgetType,
},
};
const updatedPanelConfig = {
...panelConfig,
components: [...currentComponents, newPanelComponent],
};
const updatedComponent = {
...targetComponent,
componentConfig: {
...currentConfig,
[panelKey]: updatedPanelConfig,
},
};
const newLayout = {
...layout,
components: layout.components.map((c) =>
c.id === containerId ? updatedComponent : c
),
};
setLayout(newLayout);
saveToHistory(newLayout);
toast.success(`컬럼이 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`);
return;
}
}
}
const rect = canvasRef.current?.getBoundingClientRect(); const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return; if (!rect) return;
@ -5123,6 +5321,158 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
</div> </div>
); );
})() })()
) : selectedPanelComponentInfo ? (
// 🆕 분할 패널 내부 컴포넌트 선택 시 V2PropertiesPanel 사용
(() => {
const panelComp = selectedPanelComponentInfo.component;
// 분할 패널 내부 컴포넌트를 ComponentData 형식으로 변환
const panelComponentAsComponentData: ComponentData = {
id: panelComp.id,
type: "component",
componentType: panelComp.componentType,
label: panelComp.label,
position: panelComp.position || { x: 0, y: 0 },
size: panelComp.size || { width: 200, height: 100 },
componentConfig: panelComp.componentConfig || {},
style: panelComp.style || {},
} as ComponentData;
// 분할 패널 내부 컴포넌트용 속성 업데이트 핸들러
const updatePanelComponentProperty = (componentId: string, path: string, value: any) => {
const { splitPanelId, panelSide } = selectedPanelComponentInfo;
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
setLayout((prevLayout) => {
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
if (!splitPanelComponent) return prevLayout;
const currentConfig = (splitPanelComponent as any).componentConfig || {};
const panelConfig = currentConfig[panelKey] || {};
const components = panelConfig.components || [];
// 해당 컴포넌트 찾기
const targetCompIndex = components.findIndex((c: any) => c.id === componentId);
if (targetCompIndex === -1) return prevLayout;
// 컴포넌트 속성 업데이트
const targetComp = components[targetCompIndex];
const updatedComp = path === "style"
? { ...targetComp, style: value }
: path.includes(".")
? (() => {
const parts = path.split(".");
let obj = { ...targetComp };
let current: any = obj;
for (let i = 0; i < parts.length - 1; i++) {
current[parts[i]] = { ...current[parts[i]] };
current = current[parts[i]];
}
current[parts[parts.length - 1]] = value;
return obj;
})()
: { ...targetComp, [path]: value };
const updatedComponents = [
...components.slice(0, targetCompIndex),
updatedComp,
...components.slice(targetCompIndex + 1),
];
const updatedComponent = {
...splitPanelComponent,
componentConfig: {
...currentConfig,
[panelKey]: {
...panelConfig,
components: updatedComponents,
},
},
};
// selectedPanelComponentInfo 업데이트
setSelectedPanelComponentInfo(prev =>
prev ? { ...prev, component: updatedComp } : null
);
return {
...prevLayout,
components: prevLayout.components.map((c) =>
c.id === splitPanelId ? updatedComponent : c
),
};
});
};
// 분할 패널 내부 컴포넌트 삭제 핸들러
const deletePanelComponent = (componentId: string) => {
const { splitPanelId, panelSide } = selectedPanelComponentInfo;
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
setLayout((prevLayout) => {
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
if (!splitPanelComponent) return prevLayout;
const currentConfig = (splitPanelComponent as any).componentConfig || {};
const panelConfig = currentConfig[panelKey] || {};
const components = panelConfig.components || [];
const updatedComponents = components.filter((c: any) => c.id !== componentId);
const updatedComponent = {
...splitPanelComponent,
componentConfig: {
...currentConfig,
[panelKey]: {
...panelConfig,
components: updatedComponents,
},
},
};
setSelectedPanelComponentInfo(null);
return {
...prevLayout,
components: prevLayout.components.map((c) =>
c.id === splitPanelId ? updatedComponent : c
),
};
});
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2">
<span className="text-xs text-muted-foreground">
({selectedPanelComponentInfo.panelSide === "left" ? "좌측" : "우측"})
</span>
<button
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => setSelectedPanelComponentInfo(null)}
>
</button>
</div>
<div className="flex-1 overflow-hidden">
<V2PropertiesPanel
selectedComponent={panelComponentAsComponentData}
tables={tables}
onUpdateProperty={updatePanelComponentProperty}
onDeleteComponent={deletePanelComponent}
currentTable={tables.length > 0 ? tables[0] : undefined}
currentTableName={selectedScreen?.tableName}
currentScreenCompanyCode={selectedScreen?.companyCode}
onStyleChange={(style) => {
updatePanelComponentProperty(panelComp.id, "style", style);
}}
allComponents={layout.components}
menuObjid={menuObjid}
/>
</div>
</div>
);
})()
) : ( ) : (
<V2PropertiesPanel <V2PropertiesPanel
selectedComponent={selectedComponent || undefined} selectedComponent={selectedComponent || undefined}
@ -5514,6 +5864,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
? selectedTabComponentInfo.componentId ? selectedTabComponentInfo.componentId
: undefined : undefined
} }
// 🆕 분할 패널 내부 컴포넌트 선택 핸들러
onSelectPanelComponent={(panelSide, compId, comp) =>
handleSelectPanelComponent(component.id, panelSide, compId, comp)
}
selectedPanelComponentId={
selectedPanelComponentInfo?.splitPanelId === component.id
? selectedPanelComponentInfo.componentId
: undefined
}
> >
{/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} {/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
{(component.type === "group" || {(component.type === "group" ||

View File

@ -143,6 +143,9 @@ export interface DynamicComponentRendererProps {
// 🆕 탭 내부 컴포넌트 선택 콜백 // 🆕 탭 내부 컴포넌트 선택 콜백
onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void;
selectedTabComponentId?: string; selectedTabComponentId?: string;
// 🆕 분할 패널 내부 컴포넌트 선택 콜백
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void;
selectedPanelComponentId?: string;
flowSelectedStepId?: number | null; flowSelectedStepId?: number | null;
onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void; onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
// 테이블 새로고침 키 // 테이블 새로고침 키
@ -494,6 +497,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 🆕 탭 내부 컴포넌트 선택 콜백 // 🆕 탭 내부 컴포넌트 선택 콜백
onSelectTabComponent: props.onSelectTabComponent, onSelectTabComponent: props.onSelectTabComponent,
selectedTabComponentId: props.selectedTabComponentId, selectedTabComponentId: props.selectedTabComponentId,
// 🆕 분할 패널 내부 컴포넌트 선택 콜백
onSelectPanelComponent: props.onSelectPanelComponent,
selectedPanelComponentId: props.selectedPanelComponentId,
}; };
// 렌더러가 클래스인지 함수인지 확인 // 렌더러가 클래스인지 함수인지 확인

View File

@ -18,6 +18,8 @@ import {
ChevronRight, ChevronRight,
Pencil, Pencil,
Trash2, Trash2,
Settings,
Move,
} from "lucide-react"; } from "lucide-react";
import { dataApi } from "@/lib/api/data"; import { dataApi } from "@/lib/api/data";
import { entityJoinApi } from "@/lib/api/entityJoin"; import { entityJoinApi } from "@/lib/api/entityJoin";
@ -37,9 +39,16 @@ import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useSplitPanel } from "./SplitPanelContext"; import { useSplitPanel } from "./SplitPanelContext";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { PanelInlineComponent } from "./types";
import { cn } from "@/lib/utils";
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props // 추가 props
onUpdateComponent?: (component: any) => void;
// 🆕 패널 내부 컴포넌트 선택 콜백 (탭 컴포넌트와 동일 구조)
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: PanelInlineComponent) => void;
selectedPanelComponentId?: string;
} }
/** /**
@ -52,6 +61,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
isSelected = false, isSelected = false,
isPreview = false, isPreview = false,
onClick, onClick,
onUpdateComponent,
onSelectPanelComponent,
selectedPanelComponentId: externalSelectedPanelComponentId,
...props ...props
}) => { }) => {
const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig; const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig;
@ -181,6 +193,207 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [rightCategoryMappings, setRightCategoryMappings] = useState< const [rightCategoryMappings, setRightCategoryMappings] = useState<
Record<string, Record<string, { label: string; color?: string }>> Record<string, Record<string, { label: string; color?: string }>>
>({}); // 우측 카테고리 매핑 >({}); // 우측 카테고리 매핑
// 🆕 커스텀 모드: 드래그/리사이즈 상태
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
const [resizingCompId, setResizingCompId] = useState<string | null>(null);
const [resizeSize, setResizeSize] = useState<{ width: number; height: number } | null>(null);
// 🆕 외부에서 전달받은 선택 상태 사용 (탭 컴포넌트와 동일 구조)
const selectedPanelComponentId = externalSelectedPanelComponentId || null;
const rafRef = useRef<number | null>(null);
// 🆕 10px 단위 스냅 함수
const snapTo10 = useCallback((value: number) => Math.round(value / 10) * 10, []);
// 🆕 커스텀 모드: 컴포넌트 삭제 핸들러
const handleRemovePanelComponent = useCallback(
(panelSide: "left" | "right", compId: string) => {
if (!onUpdateComponent) return;
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
const panelConfig = componentConfig[panelKey] || {};
const updatedComponents = (panelConfig.components || []).filter(
(c: PanelInlineComponent) => c.id !== compId
);
onUpdateComponent({
...component,
componentConfig: {
...componentConfig,
[panelKey]: {
...panelConfig,
components: updatedComponents,
},
},
});
},
[component, componentConfig, onUpdateComponent]
);
// 🆕 커스텀 모드: 드래그 시작 핸들러
const handlePanelDragStart = useCallback(
(e: React.MouseEvent, panelSide: "left" | "right", comp: PanelInlineComponent) => {
e.stopPropagation();
e.preventDefault();
const startMouseX = e.clientX;
const startMouseY = e.clientY;
const startLeft = comp.position?.x || 0;
const startTop = comp.position?.y || 0;
setDraggingCompId(comp.id);
setDragPosition({ x: startLeft, y: startTop });
const handleMouseMove = (moveEvent: MouseEvent) => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
const deltaX = moveEvent.clientX - startMouseX;
const deltaY = moveEvent.clientY - startMouseY;
const newX = Math.max(0, startLeft + deltaX);
const newY = Math.max(0, startTop + deltaY);
setDragPosition({ x: newX, y: newY });
});
};
const handleMouseUp = (upEvent: MouseEvent) => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
const deltaX = upEvent.clientX - startMouseX;
const deltaY = upEvent.clientY - startMouseY;
const newX = Math.max(0, startLeft + deltaX);
const newY = Math.max(0, startTop + deltaY);
setDraggingCompId(null);
setDragPosition(null);
if (onUpdateComponent) {
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
const panelConfig = componentConfig[panelKey] || {};
const updatedComponents = (panelConfig.components || []).map((c: PanelInlineComponent) =>
c.id === comp.id
? { ...c, position: { x: Math.round(newX), y: Math.round(newY) } }
: c
);
onUpdateComponent({
...component,
componentConfig: {
...componentConfig,
[panelKey]: {
...panelConfig,
components: updatedComponents,
},
},
});
}
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[component, componentConfig, onUpdateComponent]
);
// 🆕 커스텀 모드: 리사이즈 시작 핸들러
const handlePanelResizeStart = useCallback(
(e: React.MouseEvent, panelSide: "left" | "right", comp: PanelInlineComponent, direction: "e" | "s" | "se") => {
e.stopPropagation();
e.preventDefault();
const startMouseX = e.clientX;
const startMouseY = e.clientY;
const startWidth = comp.size?.width || 200;
const startHeight = comp.size?.height || 100;
setResizingCompId(comp.id);
setResizeSize({ width: startWidth, height: startHeight });
const handleMouseMove = (moveEvent: MouseEvent) => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
const deltaX = moveEvent.clientX - startMouseX;
const deltaY = moveEvent.clientY - startMouseY;
let newWidth = startWidth;
let newHeight = startHeight;
if (direction === "e" || direction === "se") {
newWidth = snapTo10(Math.max(50, startWidth + deltaX));
}
if (direction === "s" || direction === "se") {
newHeight = snapTo10(Math.max(30, startHeight + deltaY));
}
setResizeSize({ width: newWidth, height: newHeight });
});
};
const handleMouseUp = (upEvent: MouseEvent) => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
const deltaX = upEvent.clientX - startMouseX;
const deltaY = upEvent.clientY - startMouseY;
let newWidth = startWidth;
let newHeight = startHeight;
if (direction === "e" || direction === "se") {
newWidth = snapTo10(Math.max(50, startWidth + deltaX));
}
if (direction === "s" || direction === "se") {
newHeight = snapTo10(Math.max(30, startHeight + deltaY));
}
setResizingCompId(null);
setResizeSize(null);
if (onUpdateComponent) {
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
const panelConfig = componentConfig[panelKey] || {};
const updatedComponents = (panelConfig.components || []).map((c: PanelInlineComponent) =>
c.id === comp.id
? { ...c, size: { width: newWidth, height: newHeight } }
: c
);
onUpdateComponent({
...component,
componentConfig: {
...componentConfig,
[panelKey]: {
...panelConfig,
components: updatedComponents,
},
},
});
}
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[component, componentConfig, onUpdateComponent, snapTo10]
);
const { toast } = useToast(); const { toast } = useToast();
// 추가 모달 상태 // 추가 모달 상태
@ -2079,8 +2292,191 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div> </div>
)} )}
<CardContent className="flex-1 overflow-auto p-4"> <CardContent className="flex-1 overflow-auto p-4">
{/* 좌측 데이터 목록/테이블 */} {/* 좌측 데이터 목록/테이블/커스텀 */}
{componentConfig.leftPanel?.displayMode === "table" ? ( {componentConfig.leftPanel?.displayMode === "custom" ? (
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
<div
className="relative h-full w-full"
data-split-panel-container="true"
data-component-id={component.id}
data-panel-side="left"
>
{/* 🆕 커스텀 모드: 디자인/실행 모드 통합 렌더링 */}
{componentConfig.leftPanel?.components && componentConfig.leftPanel.components.length > 0 ? (
<div className="relative h-full w-full" style={{ minHeight: "100%", minWidth: "100%" }}>
{componentConfig.leftPanel.components.map((comp: PanelInlineComponent) => {
const isSelectedComp = selectedPanelComponentId === comp.id;
const isDraggingComp = draggingCompId === comp.id;
const isResizingComp = resizingCompId === comp.id;
// 드래그/리사이즈 중 표시할 크기/위치
const displayX = isDraggingComp && dragPosition ? dragPosition.x : (comp.position?.x || 0);
const displayY = isDraggingComp && dragPosition ? dragPosition.y : (comp.position?.y || 0);
const displayWidth = isResizingComp && resizeSize ? resizeSize.width : (comp.size?.width || 200);
const displayHeight = isResizingComp && resizeSize ? resizeSize.height : (comp.size?.height || 100);
// 컴포넌트 데이터를 DynamicComponentRenderer 형식으로 변환
const componentData = {
id: comp.id,
type: "component" as const,
componentType: comp.componentType,
label: comp.label,
position: comp.position || { x: 0, y: 0 },
size: { width: displayWidth, height: displayHeight },
componentConfig: comp.componentConfig || {},
style: comp.style || {},
};
if (isDesignMode) {
// 디자인 모드: 탭 컴포넌트와 동일하게 실제 컴포넌트 렌더링
return (
<div
key={comp.id}
data-panel-comp-id={comp.id}
className="absolute"
style={{
left: displayX,
top: displayY,
zIndex: isDraggingComp ? 100 : isSelectedComp ? 10 : 1,
}}
onClick={(e) => {
e.stopPropagation();
onSelectPanelComponent?.("left", comp.id, comp);
}}
>
{/* 드래그 핸들 - 컴포넌트 외부 상단 */}
<div
className={cn(
"flex h-4 cursor-move items-center justify-between rounded-t border border-b-0 bg-gray-100 px-1",
isSelectedComp ? "border-primary" : "border-gray-200"
)}
style={{ width: displayWidth }}
onMouseDown={(e) => handlePanelDragStart(e, "left", comp)}
>
<div className="flex items-center gap-0.5">
<Move className="h-2.5 w-2.5 text-gray-400" />
<span className="max-w-[100px] truncate text-[9px] text-gray-500">
{comp.label || comp.componentType}
</span>
</div>
<div className="flex items-center">
<button
className="rounded p-0.5 hover:bg-gray-200"
onClick={(e) => {
e.stopPropagation();
onSelectPanelComponent?.("left", comp.id, comp);
}}
title="설정"
>
<Settings className="h-2.5 w-2.5 text-gray-500" />
</button>
<button
className="rounded p-0.5 hover:bg-red-100"
onClick={(e) => {
e.stopPropagation();
handleRemovePanelComponent("left", comp.id);
}}
title="삭제"
>
<Trash2 className="h-2.5 w-2.5 text-red-500" />
</button>
</div>
</div>
{/* 실제 컴포넌트 렌더링 - 핸들 아래에 별도 영역 */}
<div
className={cn(
"relative overflow-hidden rounded-b border bg-white shadow-sm",
isSelectedComp
? "border-primary ring-2 ring-primary/30"
: "border-gray-200",
(isDraggingComp || isResizingComp) && "opacity-80 shadow-lg",
!(isDraggingComp || isResizingComp) && "transition-all"
)}
style={{
width: displayWidth,
height: displayHeight,
}}
>
<div className="pointer-events-none h-full w-full">
<DynamicComponentRenderer
component={componentData as any}
isDesignMode={true}
formData={{}}
/>
</div>
{/* 리사이즈 가장자리 영역 - 선택된 컴포넌트에만 표시 */}
{isSelectedComp && (
<>
{/* 오른쪽 가장자리 (너비 조절) */}
<div
className="pointer-events-auto absolute right-0 top-0 z-10 h-full w-2 cursor-ew-resize hover:bg-primary/10"
onMouseDown={(e) => handlePanelResizeStart(e, "left", comp, "e")}
/>
{/* 아래 가장자리 (높이 조절) */}
<div
className="pointer-events-auto absolute bottom-0 left-0 z-10 h-2 w-full cursor-ns-resize hover:bg-primary/10"
onMouseDown={(e) => handlePanelResizeStart(e, "left", comp, "s")}
/>
{/* 오른쪽 아래 모서리 (너비+높이 조절) */}
<div
className="pointer-events-auto absolute bottom-0 right-0 z-20 h-3 w-3 cursor-nwse-resize hover:bg-primary/20"
onMouseDown={(e) => handlePanelResizeStart(e, "left", comp, "se")}
/>
</>
)}
</div>
</div>
);
} else {
// 실행 모드: DynamicComponentRenderer로 렌더링
const componentData = {
id: comp.id,
type: "component" as const,
componentType: comp.componentType,
label: comp.label,
position: comp.position || { x: 0, y: 0 },
size: comp.size || { width: 400, height: 300 },
componentConfig: comp.componentConfig || {},
style: comp.style || {},
};
return (
<div
key={comp.id}
className="absolute"
style={{
left: comp.position?.x || 0,
top: comp.position?.y || 0,
width: comp.size?.width || 400,
height: comp.size?.height || 300,
}}
>
<DynamicComponentRenderer
component={componentData as any}
isDesignMode={false}
formData={{}}
/>
</div>
);
}
})}
</div>
) : (
// 컴포넌트가 없을 때 드롭 영역 표시
<div className="flex h-full w-full flex-col items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50/50">
<Plus className="mb-2 h-8 w-8 text-gray-400" />
<p className="text-sm font-medium text-gray-500">
</p>
<p className="mt-1 text-xs text-gray-400">
{isDesignMode ? "컴포넌트를 드래그하여 배치하세요" : "배치된 컴포넌트가 없습니다"}
</p>
</div>
)}
</div>
) : componentConfig.leftPanel?.displayMode === "table" ? (
// 테이블 모드 // 테이블 모드
<div className="w-full"> <div className="w-full">
{isDesignMode ? ( {isDesignMode ? (
@ -2577,8 +2973,180 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div> </div>
)} )}
<CardContent className="flex-1 overflow-auto p-4"> <CardContent className="flex-1 overflow-auto p-4">
{/* 우측 데이터 */} {/* 우측 데이터/커스텀 */}
{isLoadingRight ? ( {componentConfig.rightPanel?.displayMode === "custom" ? (
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
<div
className="relative h-full w-full"
data-split-panel-container="true"
data-component-id={component.id}
data-panel-side="right"
>
{/* 🆕 커스텀 모드: 디자인/실행 모드 통합 렌더링 */}
{componentConfig.rightPanel?.components && componentConfig.rightPanel.components.length > 0 ? (
<div className="relative h-full w-full" style={{ minHeight: "100%", minWidth: "100%" }}>
{componentConfig.rightPanel.components.map((comp: PanelInlineComponent) => {
const isSelectedComp = selectedPanelComponentId === comp.id;
const isDraggingComp = draggingCompId === comp.id;
const isResizingComp = resizingCompId === comp.id;
// 드래그/리사이즈 중 표시할 크기/위치
const displayX = isDraggingComp && dragPosition ? dragPosition.x : (comp.position?.x || 0);
const displayY = isDraggingComp && dragPosition ? dragPosition.y : (comp.position?.y || 0);
const displayWidth = isResizingComp && resizeSize ? resizeSize.width : (comp.size?.width || 200);
const displayHeight = isResizingComp && resizeSize ? resizeSize.height : (comp.size?.height || 100);
// 컴포넌트 데이터를 DynamicComponentRenderer 형식으로 변환
const componentData = {
id: comp.id,
type: "component" as const,
componentType: comp.componentType,
label: comp.label,
position: comp.position || { x: 0, y: 0 },
size: { width: displayWidth, height: displayHeight },
componentConfig: comp.componentConfig || {},
style: comp.style || {},
};
if (isDesignMode) {
// 디자인 모드: 탭 컴포넌트와 동일하게 실제 컴포넌트 렌더링
return (
<div
key={comp.id}
data-panel-comp-id={comp.id}
className="absolute"
style={{
left: displayX,
top: displayY,
zIndex: isDraggingComp ? 100 : isSelectedComp ? 10 : 1,
}}
onClick={(e) => {
e.stopPropagation();
onSelectPanelComponent?.("right", comp.id, comp);
}}
>
{/* 드래그 핸들 - 컴포넌트 외부 상단 */}
<div
className={cn(
"flex h-4 cursor-move items-center justify-between rounded-t border border-b-0 bg-gray-100 px-1",
isSelectedComp ? "border-primary" : "border-gray-200"
)}
style={{ width: displayWidth }}
onMouseDown={(e) => handlePanelDragStart(e, "right", comp)}
>
<div className="flex items-center gap-0.5">
<Move className="h-2.5 w-2.5 text-gray-400" />
<span className="max-w-[100px] truncate text-[9px] text-gray-500">
{comp.label || comp.componentType}
</span>
</div>
<div className="flex items-center">
<button
className="rounded p-0.5 hover:bg-gray-200"
onClick={(e) => {
e.stopPropagation();
onSelectPanelComponent?.("right", comp.id, comp);
}}
title="설정"
>
<Settings className="h-2.5 w-2.5 text-gray-500" />
</button>
<button
className="rounded p-0.5 hover:bg-red-100"
onClick={(e) => {
e.stopPropagation();
handleRemovePanelComponent("right", comp.id);
}}
title="삭제"
>
<Trash2 className="h-2.5 w-2.5 text-red-500" />
</button>
</div>
</div>
{/* 실제 컴포넌트 렌더링 - 핸들 아래에 별도 영역 */}
<div
className={cn(
"relative overflow-hidden rounded-b border bg-white shadow-sm",
isSelectedComp
? "border-primary ring-2 ring-primary/30"
: "border-gray-200",
(isDraggingComp || isResizingComp) && "opacity-80 shadow-lg",
!(isDraggingComp || isResizingComp) && "transition-all"
)}
style={{
width: displayWidth,
height: displayHeight,
}}
>
<div className="pointer-events-none h-full w-full">
<DynamicComponentRenderer
component={componentData as any}
isDesignMode={true}
formData={{}}
/>
</div>
{/* 리사이즈 가장자리 영역 - 선택된 컴포넌트에만 표시 */}
{isSelectedComp && (
<>
{/* 오른쪽 가장자리 (너비 조절) */}
<div
className="pointer-events-auto absolute right-0 top-0 z-10 h-full w-2 cursor-ew-resize hover:bg-primary/10"
onMouseDown={(e) => handlePanelResizeStart(e, "right", comp, "e")}
/>
{/* 아래 가장자리 (높이 조절) */}
<div
className="pointer-events-auto absolute bottom-0 left-0 z-10 h-2 w-full cursor-ns-resize hover:bg-primary/10"
onMouseDown={(e) => handlePanelResizeStart(e, "right", comp, "s")}
/>
{/* 오른쪽 아래 모서리 (너비+높이 조절) */}
<div
className="pointer-events-auto absolute bottom-0 right-0 z-20 h-3 w-3 cursor-nwse-resize hover:bg-primary/20"
onMouseDown={(e) => handlePanelResizeStart(e, "right", comp, "se")}
/>
</>
)}
</div>
</div>
);
} else {
return (
<div
key={comp.id}
className="absolute"
style={{
left: comp.position?.x || 0,
top: comp.position?.y || 0,
width: comp.size?.width || 400,
height: comp.size?.height || 300,
}}
>
<DynamicComponentRenderer
component={componentData as any}
isDesignMode={false}
formData={{}}
/>
</div>
);
}
})}
</div>
) : (
// 컴포넌트가 없을 때 드롭 영역 표시
<div className="flex h-full w-full flex-col items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50/50">
<Plus className="mb-2 h-8 w-8 text-gray-400" />
<p className="text-sm font-medium text-gray-500">
</p>
<p className="mt-1 text-xs text-gray-400">
{isDesignMode ? "컴포넌트를 드래그하여 배치하세요" : "배치된 컴포넌트가 없습니다"}
</p>
</div>
)}
</div>
) : isLoadingRight ? (
// 로딩 중 // 로딩 중
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-center"> <div className="text-center">

View File

@ -11,7 +11,8 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
// Accordion 제거 - 단순 섹션으로 변경 // Accordion 제거 - 단순 섹션으로 변경
import { Check, ChevronsUpDown, ArrowRight, Plus, X, ArrowUp, ArrowDown, Trash2, Database, GripVertical } from "lucide-react"; import { Check, ChevronsUpDown, ArrowRight, Plus, X, ArrowUp, ArrowDown, Trash2, Database, GripVertical, Move } from "lucide-react";
import { PanelInlineComponent } from "./types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SplitPanelLayoutConfig, AdditionalTabConfig } from "./types"; import { SplitPanelLayoutConfig, AdditionalTabConfig } from "./types";
import { TableInfo, ColumnInfo } from "@/types/screen"; import { TableInfo, ColumnInfo } from "@/types/screen";
@ -1547,7 +1548,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
<Label> </Label> <Label> </Label>
<Select <Select
value={config.leftPanel?.displayMode || "list"} value={config.leftPanel?.displayMode || "list"}
onValueChange={(value: "list" | "table") => updateLeftPanel({ displayMode: value })} onValueChange={(value: "list" | "table" | "custom") => updateLeftPanel({ displayMode: value })}
> >
<SelectTrigger className="bg-white"> <SelectTrigger className="bg-white">
<SelectValue placeholder="표시 모드 선택" /> <SelectValue placeholder="표시 모드 선택" />
@ -1565,11 +1566,75 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
<span className="text-xs text-gray-500"> </span> <span className="text-xs text-gray-500"> </span>
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value="custom">
<div className="flex flex-col">
<span className="font-medium"> (CUSTOM)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{config.leftPanel?.displayMode === "custom" && (
<p className="text-xs text-amber-600">
.
</p>
)}
</div> </div>
{/* 좌측 패널 표시 컬럼 설정 - 체크박스 방식 */} {/* 🆕 커스텀 모드: 배치된 컴포넌트 목록 */}
{config.leftPanel?.displayMode === "custom" && (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{!config.leftPanel?.components || config.leftPanel.components.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
<Move className="mx-auto mb-2 h-6 w-6 text-muted-foreground" />
<p className="text-muted-foreground text-xs">
</p>
</div>
) : (
<div className="space-y-2">
{config.leftPanel.components.map((comp: PanelInlineComponent) => (
<div
key={comp.id}
className="flex items-center justify-between rounded-md border bg-background p-2"
>
<div className="flex-1">
<p className="text-xs font-medium">
{comp.label || comp.componentType}
</p>
<p className="text-muted-foreground text-[10px]">
{comp.componentType} | : ({comp.position?.x || 0}, {comp.position?.y || 0}) | : {comp.size?.width || 0}x{comp.size?.height || 0}
</p>
</div>
<Button
onClick={() => {
const updatedComponents = (config.leftPanel?.components || []).filter(
(c: PanelInlineComponent) => c.id !== comp.id
);
onChange({
...config,
leftPanel: {
...config.leftPanel,
components: updatedComponents,
},
});
}}
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
)}
{/* 좌측 패널 표시 컬럼 설정 - 체크박스 방식 (커스텀 모드가 아닐 때만) */}
{config.leftPanel?.displayMode !== "custom" && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
@ -1731,6 +1796,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
{/* 좌측 패널 데이터 필터링 */} {/* 좌측 패널 데이터 필터링 */}
@ -1851,7 +1917,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
<Label> </Label> <Label> </Label>
<Select <Select
value={config.rightPanel?.displayMode || "list"} value={config.rightPanel?.displayMode || "list"}
onValueChange={(value: "list" | "table") => updateRightPanel({ displayMode: value })} onValueChange={(value: "list" | "table" | "custom") => updateRightPanel({ displayMode: value })}
> >
<SelectTrigger className="bg-white"> <SelectTrigger className="bg-white">
<SelectValue placeholder="표시 모드 선택" /> <SelectValue placeholder="표시 모드 선택" />
@ -1869,11 +1935,74 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
<span className="text-xs text-gray-500"> </span> <span className="text-xs text-gray-500"> </span>
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value="custom">
<div className="flex flex-col">
<span className="font-medium"> (CUSTOM)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{config.rightPanel?.displayMode === "custom" && (
<p className="text-xs text-amber-600">
.
</p>
)}
</div> </div>
{/* 요약 표시 설정 (LIST 모드에서만) */} {/* 🆕 커스텀 모드: 배치된 컴포넌트 목록 */}
{config.rightPanel?.displayMode === "custom" && (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{!config.rightPanel?.components || config.rightPanel.components.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
<Move className="mx-auto mb-2 h-6 w-6 text-muted-foreground" />
<p className="text-muted-foreground text-xs">
</p>
</div>
) : (
<div className="space-y-2">
{config.rightPanel.components.map((comp: PanelInlineComponent) => (
<div
key={comp.id}
className="flex items-center justify-between rounded-md border bg-background p-2"
>
<div className="flex-1">
<p className="text-xs font-medium">
{comp.label || comp.componentType}
</p>
<p className="text-muted-foreground text-[10px]">
{comp.componentType} | : ({comp.position?.x || 0}, {comp.position?.y || 0}) | : {comp.size?.width || 0}x{comp.size?.height || 0}
</p>
</div>
<Button
onClick={() => {
const updatedComponents = (config.rightPanel?.components || []).filter(
(c: PanelInlineComponent) => c.id !== comp.id
);
onChange({
...config,
rightPanel: {
...config.rightPanel,
components: updatedComponents,
},
});
}}
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
)}
{/* 요약 표시 설정 (LIST 모드에서만, 커스텀 모드가 아닐 때) */}
{(config.rightPanel?.displayMode || "list") === "list" && ( {(config.rightPanel?.displayMode || "list") === "list" && (
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3"> <div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
<Label className="text-sm font-semibold"> </Label> <Label className="text-sm font-semibold"> </Label>

View File

@ -2,7 +2,13 @@
* SplitPanelLayout * SplitPanelLayout
*/ */
import { DataFilterConfig } from "@/types/screen-management"; import { DataFilterConfig, TabInlineComponent } from "@/types/screen-management";
/**
* ( )
* TabInlineComponent와
*/
export type PanelInlineComponent = TabInlineComponent;
/** /**
* ( + tabId, label) * ( + tabId, label)
@ -118,7 +124,9 @@ export interface SplitPanelLayoutConfig {
useCustomTable?: boolean; // 화면 기본 테이블이 아닌 다른 테이블 사용 여부 useCustomTable?: boolean; // 화면 기본 테이블이 아닌 다른 테이블 사용 여부
customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때) customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때)
dataSource?: string; // API 엔드포인트 dataSource?: string; // API 엔드포인트
displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블 displayMode?: "list" | "table" | "custom"; // 표시 모드: 목록, 테이블, 또는 커스텀
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 (탭 컴포넌트와 동일 구조)
components?: PanelInlineComponent[];
showSearch?: boolean; showSearch?: boolean;
showAdd?: boolean; showAdd?: boolean;
showEdit?: boolean; // 수정 버튼 showEdit?: boolean; // 수정 버튼
@ -185,7 +193,9 @@ export interface SplitPanelLayoutConfig {
useCustomTable?: boolean; // 화면 기본 테이블이 아닌 다른 테이블 사용 여부 useCustomTable?: boolean; // 화면 기본 테이블이 아닌 다른 테이블 사용 여부
customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때) customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때)
dataSource?: string; dataSource?: string;
displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블 displayMode?: "list" | "table" | "custom"; // 표시 모드: 목록, 테이블, 또는 커스텀
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 (탭 컴포넌트와 동일 구조)
components?: PanelInlineComponent[];
showSearch?: boolean; showSearch?: boolean;
showAdd?: boolean; showAdd?: boolean;
showEdit?: boolean; // 수정 버튼 showEdit?: boolean; // 수정 버튼