From 17e212118cf1a3377f4a578bbd26820dc5fd5e09 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 30 Jan 2026 16:34:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B6=84=ED=95=A0=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20=EB=82=B4=EB=B6=80=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RealtimePreviewDynamic, ScreenDesigner, DynamicComponentRenderer, SplitPanelLayoutComponent 및 관련 파일에서 분할 패널 내부 컴포넌트 선택 콜백 및 상태 관리 기능을 추가하였습니다. - 커스텀 모드에서 패널 내부에 컴포넌트를 자유롭게 배치할 수 있는 기능을 구현하였습니다. - 선택된 패널 컴포넌트의 상태를 관리하고, 관련 UI 요소를 업데이트하여 사용자 경험을 향상시켰습니다. - 패널의 표시 모드에 'custom' 옵션을 추가하여 사용자 정의 배치 기능을 지원합니다. --- docs/V2_컴포넌트_분석_가이드.md | 43 +- docs/V2_컴포넌트_연동_가이드.md | 23 + .../next-component-development-plan.md | 582 ++++++++++++++++++ .../00_analysis/v2-component-usage-guide.md | 41 +- .../03_production/production-plan.md | 69 ++- .../screen/RealtimePreviewDynamic.tsx | 6 + frontend/components/screen/ScreenDesigner.tsx | 363 ++++++++++- .../lib/registry/DynamicComponentRenderer.tsx | 6 + .../SplitPanelLayoutComponent.tsx | 576 ++++++++++++++++- .../SplitPanelLayoutConfigPanel.tsx | 139 ++++- .../components/v2-split-panel-layout/types.ts | 16 +- 11 files changed, 1814 insertions(+), 50 deletions(-) create mode 100644 docs/screen-implementation-guide/00_analysis/next-component-development-plan.md diff --git a/docs/V2_컴포넌트_분석_가이드.md b/docs/V2_컴포넌트_분석_가이드.md index 55598e02..89d18a40 100644 --- a/docs/V2_컴포넌트_분석_가이드.md +++ b/docs/V2_컴포넌트_분석_가이드.md @@ -182,12 +182,43 @@ interface ColumnConfig { #### 주요 특징 -- 좌측: 마스터 목록 (리스트/테이블 모드) +- 좌측: 마스터 목록 (리스트/테이블/커스텀 모드) - 우측: 디테일 정보 (연관 데이터) - 좌우 비율 조절 가능 (드래그 리사이즈) - 다중 탭 지원 (우측 패널) - 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; + style?: Record; +} +``` #### 데이터 흐름 @@ -220,7 +251,7 @@ interface SplitPanelLayoutConfig { tableName?: string; useCustomTable?: boolean; customTableName?: string; - displayMode?: "list" | "table"; + displayMode?: "list" | "table" | "custom"; // 🆕 커스텀 모드 추가 showSearch?: boolean; showAdd?: boolean; showEdit?: boolean; @@ -228,15 +259,21 @@ interface SplitPanelLayoutConfig { columns?: ColumnConfig[]; tableConfig?: TableDisplayConfig; dataFilter?: DataFilterConfig; + + // 🆕 커스텀 모드 전용: 내부 컴포넌트 배열 + components?: PanelInlineComponent[]; }; // 우측 패널 rightPanel: { title: string; tableName?: string; - displayMode?: "list" | "table"; + displayMode?: "list" | "table" | "custom"; // 🆕 커스텀 모드 추가 columns?: ColumnConfig[]; + // 🆕 커스텀 모드 전용: 내부 컴포넌트 배열 + components?: PanelInlineComponent[]; + // 관계 설정 relation?: { type?: "join" | "detail"; diff --git a/docs/V2_컴포넌트_연동_가이드.md b/docs/V2_컴포넌트_연동_가이드.md index f6756a5e..a8496926 100644 --- a/docs/V2_컴포넌트_연동_가이드.md +++ b/docs/V2_컴포넌트_연동_가이드.md @@ -959,6 +959,29 @@ receiveData(data, config): Promise **구독 이벤트**: - `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. 연동 가능한 조합 diff --git a/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md b/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md new file mode 100644 index 00000000..1282e6c3 --- /dev/null +++ b/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md @@ -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; +} +``` + +### 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; +} + +/** + * 리소스 (행) + */ +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; + + /** 리소스 컬럼 너비 */ + 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 diff --git a/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md b/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md index 1f18e804..e32e68cc 100644 --- a/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md +++ b/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md @@ -63,7 +63,7 @@ | ID | 이름 | 용도 | 주요 옵션 | |----|------|------|----------| -| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, relation | +| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, relation, **displayMode: custom** | | `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs, activeTabId | | `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 | title, collapsible, padding | | `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 필수 설정 ```typescript diff --git a/docs/screen-implementation-guide/03_production/production-plan.md b/docs/screen-implementation-guide/03_production/production-plan.md index 855102c3..1cbfda4f 100644 --- a/docs/screen-implementation-guide/03_production/production-plan.md +++ b/docs/screen-implementation-guide/03_production/production-plan.md @@ -5,8 +5,8 @@ > ⚠️ **구현 상태 안내** > - **간소화 버전 (패턴 A)**: ✅ 즉시 구현 가능 -> - **정식 버전 (복합 레이아웃)**: ⚠️ 컴포넌트 확장/개발 후 구현 가능 -> - `v2-split-panel-layout` 커스텀 모드 확장 (1개) +> - **정식 버전 (복합 레이아웃)**: ⚠️ 컴포넌트 개발 후 구현 가능 +> - ~~`v2-split-panel-layout` 커스텀 모드 확장~~ ✅ **구현 완료** > - `v2-table-grouped`, `v2-timeline-scheduler` 신규 개발 (2개) --- @@ -24,7 +24,7 @@ | **메인 테이블** | `production_plan_mng` | | **관련 테이블** | `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-table-list` | | | 기본 모달 폼 | ✅ | 모달 화면 | | -| 좌우 분할 패널 (기본) | ⚠️ | `v2-split-panel-layout` | 테이블/리스트만 표시 가능 | +| 좌우 분할 패널 (커스텀 모드) | ✅ | `v2-split-panel-layout` | **displayMode: "custom" 지원** | | 탭 컴포넌트 (기본) | ⚠️ | `v2-tabs-widget` | 디자인 모드에서 컴포넌트 배치 | ### 4.2 현재 V2 컴포넌트의 제한 사항 및 확장 방안 @@ -182,37 +182,41 @@ | 테이블/리스트 표시 | ✅ | `displayMode: "list" \| "table"` | | 마스터-디테일 관계 | ✅ | `relation` 설정 | | 우측 추가 탭 (`additionalTabs`) | ⚠️ | **타입만 정의, 렌더링 미구현** | -| 패널 내 임의 컴포넌트 배치 | ❌ | 미지원 | +| **패널 내 임의 컴포넌트 배치** | ✅ | **`displayMode: "custom"` - 구현 완료!** | -#### 🆕 확장 방안: 커스텀 모드 추가 +#### ✅ 커스텀 모드 (구현 완료) -`v2-tabs-widget`처럼 패널 안에 컴포넌트를 자유롭게 배치하는 **커스텀 모드** 추가: +`v2-tabs-widget`과 동일한 구조로 패널 안에 컴포넌트를 자유롭게 배치: ```typescript -// 현재 구조 leftPanel: { - displayMode: "list" | "table", // 2가지만 지원 - tableName: string, - columns: [...], -} - -// 🆕 확장 구조 -leftPanel: { - displayMode: "list" | "table" | "custom", // 커스텀 모드 추가 + displayMode: "list" | "table" | "custom", // ✅ 커스텀 모드 지원 // 기존 모드 (list, table) tableName?: string, 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; } ``` -**커스텀 모드 장점**: -- 기존 `v2-tabs-widget`의 드래그&드롭 로직 재사용 -- 패널 안에 탭, 테이블, 타임라인 등 어떤 컴포넌트든 배치 가능 -- `v2-split-panel-enhanced` 별도 개발 불필요 +**커스텀 모드 기능**: +- ✅ 드래그앤드롭으로 컴포넌트 이동 +- ✅ 리사이즈 핸들로 크기 조절 +- ✅ 컴포넌트 클릭 시 좌측 설정 패널에서 속성 편집 +- ✅ 디자인 모드에서 실제 컴포넌트 미리보기 렌더링 +- ✅ 탭, 테이블, 버튼 등 모든 V2 컴포넌트 배치 가능 #### `v2-tabs-widget` 현재 기능 @@ -244,8 +248,8 @@ interface TabInlineComponent { | 필요 기능 | 현재 지원 | 해결 방안 | |----------|:---------:|----------| | 좌우 분할 패널 | ✅ | `v2-split-panel-layout` | -| 왼쪽 패널 탭 (수주/안전재고) | ⚠️ | 커스텀 모드 + `v2-tabs-widget` 조합 | -| 오른쪽 패널 탭 (완제품/반제품) | ⚠️ | 커스텀 모드 + `v2-tabs-widget` 조합 | +| 왼쪽 패널 탭 (수주/안전재고) | ✅ | **커스텀 모드 + `v2-tabs-widget` 조합 (구현 완료)** | +| 오른쪽 패널 탭 (완제품/반제품) | ✅ | **커스텀 모드 + `v2-tabs-widget` 조합 (구현 완료)** | | **그룹화된 테이블** (품목별 접기/펼치기) | ❌ | **신규 개발 필요** | | **타임라인 스케줄러** (간트차트) | ❌ | **신규 개발 필요** | | 드래그&드롭 스케줄 이동 | ❌ | `v2-timeline-scheduler`에 포함 | @@ -255,9 +259,9 @@ interface TabInlineComponent { #### 기존 컴포넌트 확장 (1개) -| 컴포넌트 | 작업 내용 | 난이도 | +| 컴포넌트 | 작업 내용 | 상태 | |----------|----------|:------:| -| `v2-split-panel-layout` | `displayMode: "custom"` 추가 + `components[]` 지원 | 중 | +| `v2-split-panel-layout` | `displayMode: "custom"` 추가 + `components[]` 지원 | ✅ **완료** | #### 신규 컴포넌트 개발 (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 신규 개발 - 그룹화 테이블 (접기/펼치기) 3. v2-timeline-scheduler 신규 개발 - 타임라인/간트차트 (핵심!) ``` @@ -632,10 +636,11 @@ V2 컴포넌트로 기본 CRUD 화면 구현: ### Phase 2: 컴포넌트 확장 및 개발 -1. **`v2-split-panel-layout` 확장** - - `displayMode: "custom"` 추가 - - `components[]` 필드 지원 (탭 컴포넌트와 동일한 구조) - - 디자인 모드에서 컴포넌트 드래그&드롭 지원 +1. ~~**`v2-split-panel-layout` 확장**~~ ✅ **완료** + - ✅ `displayMode: "custom"` 추가 + - ✅ `components[]` 필드 지원 (탭 컴포넌트와 동일한 구조) + - ✅ 디자인 모드에서 컴포넌트 드래그&드롭 지원 + - ✅ 컴포넌트 클릭 시 좌측 설정 패널에서 속성 편집 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-timeline-scheduler 개발** | 타임라인/간트차트 스케줄러 | | ☐ | **드래그&드롭 기능** | 타임라인 바 드래그 이동/크기 조정 | diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 75eec128..886e3977 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -39,6 +39,8 @@ interface RealtimePreviewProps { onUpdateComponent?: (updatedComponent: any) => void; // 🆕 컴포넌트 업데이트 콜백 onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; // 🆕 탭 내부 컴포넌트 선택 콜백 selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID + onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백 + selectedPanelComponentId?: string; // 🆕 선택된 분할 패널 컴포넌트 ID onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백 // 버튼 액션을 위한 props @@ -140,6 +142,8 @@ const RealtimePreviewDynamicComponent: React.FC = ({ onUpdateComponent, // 🆕 컴포넌트 업데이트 콜백 onSelectTabComponent, // 🆕 탭 내부 컴포넌트 선택 콜백 selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID + onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백 + selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID onResize, // 🆕 리사이즈 콜백 }) => { // 🆕 화면 다국어 컨텍스트 @@ -640,6 +644,8 @@ const RealtimePreviewDynamicComponent: React.FC = ({ onUpdateComponent={onUpdateComponent} onSelectTabComponent={onSelectTabComponent} selectedTabComponentId={selectedTabComponentId} + onSelectPanelComponent={onSelectPanelComponent} + selectedPanelComponentId={selectedPanelComponentId} /> diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index b05f03b6..a0ed5574 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -177,13 +177,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU component: any; // 탭 내부 컴포넌트 데이터 } | null>(null); + // 🆕 분할 패널 내부 컴포넌트 선택 상태 + const [selectedPanelComponentInfo, setSelectedPanelComponentInfo] = useState<{ + splitPanelId: string; // 분할 패널 컴포넌트 ID + panelSide: "left" | "right"; // 좌측/우측 패널 + componentId: string; // 패널 내부 컴포넌트 ID + component: any; // 패널 내부 컴포넌트 데이터 + } | null>(null); + // 컴포넌트 선택 시 통합 패널 자동 열기 const handleComponentSelect = useCallback( (component: ComponentData | null) => { setSelectedComponent(component); - // 일반 컴포넌트 선택 시 탭 내부 컴포넌트 선택 해제 + // 일반 컴포넌트 선택 시 탭 내부 컴포넌트/분할 패널 내부 컴포넌트 선택 해제 if (component) { setSelectedTabComponentInfo(null); + setSelectedPanelComponentInfo(null); } // 컴포넌트가 선택되면 통합 패널 자동 열기 @@ -209,8 +218,32 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU componentId: compId, component: comp, }); - // 탭 내부 컴포넌트 선택 시 일반 컴포넌트 선택 해제 + // 탭 내부 컴포넌트 선택 시 일반 컴포넌트/분할 패널 내부 컴포넌트 선택 해제 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], @@ -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(); 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 = { + 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(); if (!rect) return; @@ -5123,6 +5321,158 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU ); })() + ) : 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 ( +
+
+ + 분할 패널 ({selectedPanelComponentInfo.panelSide === "left" ? "좌측" : "우측"}) 컴포넌트 + + +
+
+ 0 ? tables[0] : undefined} + currentTableName={selectedScreen?.tableName} + currentScreenCompanyCode={selectedScreen?.companyCode} + onStyleChange={(style) => { + updatePanelComponentProperty(panelComp.id, "style", style); + }} + allComponents={layout.components} + menuObjid={menuObjid} + /> +
+
+ ); + })() ) : ( + handleSelectPanelComponent(component.id, panelSide, compId, comp) + } + selectedPanelComponentId={ + selectedPanelComponentInfo?.splitPanelId === component.id + ? selectedPanelComponentInfo.componentId + : undefined + } > {/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} {(component.type === "group" || diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 23b684ac..a1e51e1d 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -143,6 +143,9 @@ export interface DynamicComponentRendererProps { // 🆕 탭 내부 컴포넌트 선택 콜백 onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; selectedTabComponentId?: string; + // 🆕 분할 패널 내부 컴포넌트 선택 콜백 + onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; + selectedPanelComponentId?: string; flowSelectedStepId?: number | null; onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void; // 테이블 새로고침 키 @@ -494,6 +497,9 @@ export const DynamicComponentRenderer: React.FC = // 🆕 탭 내부 컴포넌트 선택 콜백 onSelectTabComponent: props.onSelectTabComponent, selectedTabComponentId: props.selectedTabComponentId, + // 🆕 분할 패널 내부 컴포넌트 선택 콜백 + onSelectPanelComponent: props.onSelectPanelComponent, + selectedPanelComponentId: props.selectedPanelComponentId, }; // 렌더러가 클래스인지 함수인지 확인 diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 725f6bc0..8a5898e0 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -18,6 +18,8 @@ import { ChevronRight, Pencil, Trash2, + Settings, + Move, } from "lucide-react"; import { dataApi } from "@/lib/api/data"; import { entityJoinApi } from "@/lib/api/entityJoin"; @@ -37,9 +39,16 @@ import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; import { useAuth } from "@/hooks/useAuth"; import { useSplitPanel } from "./SplitPanelContext"; +import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; +import { PanelInlineComponent } from "./types"; +import { cn } from "@/lib/utils"; export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { // 추가 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 isSelected = false, isPreview = false, onClick, + onUpdateComponent, + onSelectPanelComponent, + selectedPanelComponentId: externalSelectedPanelComponentId, ...props }) => { const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig; @@ -181,6 +193,207 @@ export const SplitPanelLayoutComponent: React.FC const [rightCategoryMappings, setRightCategoryMappings] = useState< Record> >({}); // 우측 카테고리 매핑 + + // 🆕 커스텀 모드: 드래그/리사이즈 상태 + const [draggingCompId, setDraggingCompId] = useState(null); + const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null); + const [resizingCompId, setResizingCompId] = useState(null); + const [resizeSize, setResizeSize] = useState<{ width: number; height: number } | null>(null); + // 🆕 외부에서 전달받은 선택 상태 사용 (탭 컴포넌트와 동일 구조) + const selectedPanelComponentId = externalSelectedPanelComponentId || null; + const rafRef = useRef(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(); // 추가 모달 상태 @@ -2079,8 +2292,191 @@ export const SplitPanelLayoutComponent: React.FC )} - {/* 좌측 데이터 목록/테이블 */} - {componentConfig.leftPanel?.displayMode === "table" ? ( + {/* 좌측 데이터 목록/테이블/커스텀 */} + {componentConfig.leftPanel?.displayMode === "custom" ? ( + // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 +
+ {/* 🆕 커스텀 모드: 디자인/실행 모드 통합 렌더링 */} + {componentConfig.leftPanel?.components && componentConfig.leftPanel.components.length > 0 ? ( +
+ {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 ( +
{ + e.stopPropagation(); + onSelectPanelComponent?.("left", comp.id, comp); + }} + > + {/* 드래그 핸들 - 컴포넌트 외부 상단 */} +
handlePanelDragStart(e, "left", comp)} + > +
+ + + {comp.label || comp.componentType} + +
+
+ + +
+
+ + {/* 실제 컴포넌트 렌더링 - 핸들 아래에 별도 영역 */} +
+
+ +
+ + {/* 리사이즈 가장자리 영역 - 선택된 컴포넌트에만 표시 */} + {isSelectedComp && ( + <> + {/* 오른쪽 가장자리 (너비 조절) */} +
handlePanelResizeStart(e, "left", comp, "e")} + /> + {/* 아래 가장자리 (높이 조절) */} +
handlePanelResizeStart(e, "left", comp, "s")} + /> + {/* 오른쪽 아래 모서리 (너비+높이 조절) */} +
handlePanelResizeStart(e, "left", comp, "se")} + /> + + )} +
+
+ ); + } 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 ( +
+ +
+ ); + } + })} +
+ ) : ( + // 컴포넌트가 없을 때 드롭 영역 표시 +
+ +

+ 커스텀 모드 +

+

+ {isDesignMode ? "컴포넌트를 드래그하여 배치하세요" : "배치된 컴포넌트가 없습니다"} +

+
+ )} +
+ ) : componentConfig.leftPanel?.displayMode === "table" ? ( // 테이블 모드
{isDesignMode ? ( @@ -2577,8 +2973,180 @@ export const SplitPanelLayoutComponent: React.FC
)} - {/* 우측 데이터 */} - {isLoadingRight ? ( + {/* 우측 데이터/커스텀 */} + {componentConfig.rightPanel?.displayMode === "custom" ? ( + // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 +
+ {/* 🆕 커스텀 모드: 디자인/실행 모드 통합 렌더링 */} + {componentConfig.rightPanel?.components && componentConfig.rightPanel.components.length > 0 ? ( +
+ {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 ( +
{ + e.stopPropagation(); + onSelectPanelComponent?.("right", comp.id, comp); + }} + > + {/* 드래그 핸들 - 컴포넌트 외부 상단 */} +
handlePanelDragStart(e, "right", comp)} + > +
+ + + {comp.label || comp.componentType} + +
+
+ + +
+
+ + {/* 실제 컴포넌트 렌더링 - 핸들 아래에 별도 영역 */} +
+
+ +
+ + {/* 리사이즈 가장자리 영역 - 선택된 컴포넌트에만 표시 */} + {isSelectedComp && ( + <> + {/* 오른쪽 가장자리 (너비 조절) */} +
handlePanelResizeStart(e, "right", comp, "e")} + /> + {/* 아래 가장자리 (높이 조절) */} +
handlePanelResizeStart(e, "right", comp, "s")} + /> + {/* 오른쪽 아래 모서리 (너비+높이 조절) */} +
handlePanelResizeStart(e, "right", comp, "se")} + /> + + )} +
+
+ ); + } else { + + return ( +
+ +
+ ); + } + })} +
+ ) : ( + // 컴포넌트가 없을 때 드롭 영역 표시 +
+ +

+ 커스텀 모드 +

+

+ {isDesignMode ? "컴포넌트를 드래그하여 배치하세요" : "배치된 컴포넌트가 없습니다"} +

+
+ )} +
+ ) : isLoadingRight ? ( // 로딩 중
diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx index d678332e..79813d8f 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -11,7 +11,8 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; // 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 { SplitPanelLayoutConfig, AdditionalTabConfig } from "./types"; import { TableInfo, ColumnInfo } from "@/types/screen"; @@ -1547,7 +1548,7 @@ export const SplitPanelLayoutConfigPanel: React.FC표시 모드 + {config.leftPanel?.displayMode === "custom" && ( +

+ 화면 디자이너에서 좌측 패널에 컴포넌트를 드래그하여 배치하세요. +

+ )}
- {/* 좌측 패널 표시 컬럼 설정 - 체크박스 방식 */} + {/* 🆕 커스텀 모드: 배치된 컴포넌트 목록 */} + {config.leftPanel?.displayMode === "custom" && ( +
+ + {!config.leftPanel?.components || config.leftPanel.components.length === 0 ? ( +
+ +

+ 디자인 화면에서 컴포넌트를 드래그하여 추가하세요 +

+
+ ) : ( +
+ {config.leftPanel.components.map((comp: PanelInlineComponent) => ( +
+
+

+ {comp.label || comp.componentType} +

+

+ {comp.componentType} | 위치: ({comp.position?.x || 0}, {comp.position?.y || 0}) | 크기: {comp.size?.width || 0}x{comp.size?.height || 0} +

+
+ +
+ ))} +
+ )} +
+ )} + + {/* 좌측 패널 표시 컬럼 설정 - 체크박스 방식 (커스텀 모드가 아닐 때만) */} + {config.leftPanel?.displayMode !== "custom" && (
@@ -1731,6 +1796,7 @@ export const SplitPanelLayoutConfigPanel: React.FC )}
+ )}
{/* 좌측 패널 데이터 필터링 */} @@ -1851,7 +1917,7 @@ export const SplitPanelLayoutConfigPanel: React.FC표시 모드 + {config.rightPanel?.displayMode === "custom" && ( +

+ 화면 디자이너에서 우측 패널에 컴포넌트를 드래그하여 배치하세요. +

+ )}
- {/* 요약 표시 설정 (LIST 모드에서만) */} + {/* 🆕 커스텀 모드: 배치된 컴포넌트 목록 */} + {config.rightPanel?.displayMode === "custom" && ( +
+ + {!config.rightPanel?.components || config.rightPanel.components.length === 0 ? ( +
+ +

+ 디자인 화면에서 컴포넌트를 드래그하여 추가하세요 +

+
+ ) : ( +
+ {config.rightPanel.components.map((comp: PanelInlineComponent) => ( +
+
+

+ {comp.label || comp.componentType} +

+

+ {comp.componentType} | 위치: ({comp.position?.x || 0}, {comp.position?.y || 0}) | 크기: {comp.size?.width || 0}x{comp.size?.height || 0} +

+
+ +
+ ))} +
+ )} +
+ )} + + {/* 요약 표시 설정 (LIST 모드에서만, 커스텀 모드가 아닐 때) */} {(config.rightPanel?.displayMode || "list") === "list" && (
diff --git a/frontend/lib/registry/components/v2-split-panel-layout/types.ts b/frontend/lib/registry/components/v2-split-panel-layout/types.ts index 123dc13a..7ab0dbcb 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/types.ts +++ b/frontend/lib/registry/components/v2-split-panel-layout/types.ts @@ -2,7 +2,13 @@ * SplitPanelLayout 컴포넌트 타입 정의 */ -import { DataFilterConfig } from "@/types/screen-management"; +import { DataFilterConfig, TabInlineComponent } from "@/types/screen-management"; + +/** + * 패널 내 인라인 컴포넌트 (커스텀 모드용) + * TabInlineComponent와 동일한 구조 사용 + */ +export type PanelInlineComponent = TabInlineComponent; /** * 추가 탭 설정 (우측 패널과 동일한 구조 + tabId, label) @@ -118,7 +124,9 @@ export interface SplitPanelLayoutConfig { useCustomTable?: boolean; // 화면 기본 테이블이 아닌 다른 테이블 사용 여부 customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때) dataSource?: string; // API 엔드포인트 - displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블 + displayMode?: "list" | "table" | "custom"; // 표시 모드: 목록, 테이블, 또는 커스텀 + // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 (탭 컴포넌트와 동일 구조) + components?: PanelInlineComponent[]; showSearch?: boolean; showAdd?: boolean; showEdit?: boolean; // 수정 버튼 @@ -185,7 +193,9 @@ export interface SplitPanelLayoutConfig { useCustomTable?: boolean; // 화면 기본 테이블이 아닌 다른 테이블 사용 여부 customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때) dataSource?: string; - displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블 + displayMode?: "list" | "table" | "custom"; // 표시 모드: 목록, 테이블, 또는 커스텀 + // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 (탭 컴포넌트와 동일 구조) + components?: PanelInlineComponent[]; showSearch?: boolean; showAdd?: boolean; showEdit?: boolean; // 수정 버튼