From ef9f1b94ff9ba6bdb7451b9f2a64fbee88d73e2b Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 3 Feb 2026 15:50:23 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=ED=99=94=EB=A9=B4=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EB=B0=8F=20=EC=84=9C=EB=B8=8C=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 화면 그룹 조회 시 삭제된 화면(is_active = 'D')을 제외하도록 쿼리를 수정하였습니다. - 화면 서브 테이블 API에서 전역 메인 테이블 목록을 수집하여, 메인 테이블과 서브 테이블의 우선순위를 적용하였습니다. - 화면 삭제 시 연결된 화면 그룹의 관계를 해제하는 로직을 추가하였습니다. - 화면 관계 흐름에서 연결된 화면들을 추가하는 로직을 개선하여, 그룹 모드와 개별 화면 모드에서의 동작을 명확히 하였습니다. - 관련 문서 및 주석을 업데이트하여 새로운 기능에 대한 이해를 돕도록 하였습니다. --- .../src/controllers/screenGroupController.ts | 74 +++++++- .../src/services/screenManagementService.ts | 28 ++- .../RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md | 162 +++++++++++++++++ docs/DDD1542/화면관계_시각화_개선_보고서.md | 169 +++++++++++++++++- docs/SCREEN_COPY_V2_MIGRATION_PLAN.md | 7 +- frontend/components/screen/ScreenNode.tsx | 16 +- .../components/screen/ScreenRelationFlow.tsx | 115 +++++++++--- frontend/stagewise.json | 3 + 8 files changed, 510 insertions(+), 64 deletions(-) create mode 100644 frontend/stagewise.json diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index df0c4f4d..32ce60c3 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -46,11 +46,13 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) const countResult = await pool.query(countQuery, params); const total = parseInt(countResult.rows[0].total); - // 데이터 조회 (screens 배열 포함) + // 데이터 조회 (screens 배열 포함) - 삭제된 화면(is_active = 'D') 제외 const dataQuery = ` SELECT sg.*, - (SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count, + (SELECT COUNT(*) FROM screen_group_screens sgs + LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id + WHERE sgs.group_id = sg.id AND sd.is_active != 'D') as screen_count, (SELECT json_agg( json_build_object( 'id', sgs.id, @@ -64,6 +66,7 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) ) FROM screen_group_screens sgs LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id WHERE sgs.group_id = sg.id + AND sd.is_active != 'D' ) as screens FROM screen_groups sg ${whereClause} @@ -111,6 +114,7 @@ export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) = ) FROM screen_group_screens sgs LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id WHERE sgs.group_id = sg.id + AND sd.is_active != 'D' ) as screens FROM screen_groups sg WHERE sg.id = $1 @@ -1737,7 +1741,9 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons }); // 4. rightPanel.relation 파싱 (split-panel-layout 등에서 사용) + // screen_layouts (v1)와 screen_layouts_v2 모두 조회 const rightPanelQuery = ` + -- V1: screen_layouts에서 조회 SELECT sd.screen_id, sd.screen_name, @@ -1750,6 +1756,23 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) AND sl.properties->'componentConfig'->'rightPanel'->'relation' IS NOT NULL + + UNION ALL + + -- V2: screen_layouts_v2에서 조회 (v2-split-panel-layout 컴포넌트) + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + comp->'overrides'->>'type' as component_type, + comp->'overrides'->'rightPanel'->'relation' as right_panel_relation, + comp->'overrides'->'rightPanel'->>'tableName' as right_panel_table, + comp->'overrides'->'rightPanel'->'columns' as right_panel_columns + FROM screen_definitions sd + JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id, + jsonb_array_elements(slv2.layout_data->'components') as comp + WHERE sd.screen_id = ANY($1) + AND comp->'overrides'->'rightPanel'->'relation' IS NOT NULL `; const rightPanelResult = await pool.query(rightPanelQuery, [screenIds]); @@ -2118,9 +2141,56 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons })) }); + // ============================================================ + // 6. 전역 메인 테이블 목록 수집 (우선순위 적용용) + // ============================================================ + // 메인 테이블 조건: + // 1. screen_definitions.table_name (컴포넌트 직접 연결) + // 2. v2-split-panel-layout의 rightPanel.tableName (WHERE 조건 대상) + // + // 이 목록에 있으면 서브 테이블로 분류되지 않음 (우선순위: 메인 > 서브) + const globalMainTablesQuery = ` + -- 1. 모든 화면의 메인 테이블 (screen_definitions.table_name) + SELECT DISTINCT table_name as main_table + FROM screen_definitions + WHERE screen_id = ANY($1) + AND table_name IS NOT NULL + + UNION + + -- 2. v2-split-panel-layout의 rightPanel.tableName (WHERE 조건 대상) + -- 현재 그룹의 화면들에서 마스터-디테일로 연결된 테이블 + SELECT DISTINCT comp->'overrides'->'rightPanel'->>'tableName' as main_table + FROM screen_definitions sd + JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id, + jsonb_array_elements(slv2.layout_data->'components') as comp + WHERE sd.screen_id = ANY($1) + AND comp->'overrides'->'rightPanel'->>'tableName' IS NOT NULL + + UNION + + -- 3. v1 screen_layouts의 rightPanel.tableName (WHERE 조건 대상) + SELECT DISTINCT sl.properties->'componentConfig'->'rightPanel'->>'tableName' as main_table + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->'rightPanel'->>'tableName' IS NOT NULL + `; + + const globalMainTablesResult = await pool.query(globalMainTablesQuery, [screenIds]); + const globalMainTables = globalMainTablesResult.rows + .map((r: any) => r.main_table) + .filter((t: string) => t != null && t !== ''); + + logger.info("전역 메인 테이블 목록 수집 완료", { + count: globalMainTables.length, + tables: globalMainTables + }); + res.json({ success: true, data: screenSubTables, + globalMainTables: globalMainTables, // 메인 테이블로 분류되어야 하는 테이블 목록 }); } catch (error: any) { logger.error("화면 서브 테이블 정보 조회 실패:", error); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 572f2443..37a21a0a 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -731,6 +731,14 @@ export class ScreenManagementService { WHERE screen_id = $1 AND is_active = 'Y'`, [screenId], ); + + // 5. 화면 그룹 연결 삭제 (screen_group_screens) + await client.query( + `DELETE FROM screen_group_screens WHERE screen_id = $1`, + [screenId], + ); + + logger.info("화면 삭제 시 그룹 연결 해제", { screenId }); }); } @@ -5110,18 +5118,6 @@ export class ScreenManagementService { console.log( `V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`, ); - - // 🐛 디버깅: finished_timeline의 fieldMapping 확인 - const splitPanel = layout.layout_data?.components?.find((c: any) => - c.url?.includes("v2-split-panel-layout") - ); - const finishedTimeline = splitPanel?.overrides?.rightPanel?.components?.find( - (c: any) => c.id === "finished_timeline" - ); - if (finishedTimeline) { - console.log("🐛 [Backend] finished_timeline fieldMapping:", JSON.stringify(finishedTimeline.componentConfig?.fieldMapping)); - } - return layout.layout_data; } @@ -5161,20 +5157,16 @@ export class ScreenManagementService { ...layoutData }; - // SUPER_ADMIN인 경우 화면 정의의 company_code로 저장 (로드와 일관성 유지) - const saveCompanyCode = companyCode === "*" ? existingScreen.company_code : companyCode; - console.log(`저장할 company_code: ${saveCompanyCode} (원본: ${companyCode}, 화면 정의: ${existingScreen.company_code})`); - // UPSERT (있으면 업데이트, 없으면 삽입) await query( `INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at) VALUES ($1, $2, $3, NOW(), NOW()) ON CONFLICT (screen_id, company_code) DO UPDATE SET layout_data = $3, updated_at = NOW()`, - [screenId, saveCompanyCode, JSON.stringify(dataToSave)], + [screenId, companyCode, JSON.stringify(dataToSave)], ); - console.log(`V2 레이아웃 저장 완료 (company_code: ${saveCompanyCode})`); + console.log(`V2 레이아웃 저장 완료`); } } diff --git a/docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md b/docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md index 42cd872b..411fdd1f 100644 --- a/docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md +++ b/docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md @@ -103,6 +103,162 @@ - 분할 패널 반응형 처리 ``` +### 2.5 레이아웃 시스템 구조 + +현재 시스템에는 두 가지 레벨의 레이아웃이 존재합니다: + +#### 2.5.1 화면 레이아웃 (screen_layouts_v2) + +화면 전체의 컴포넌트 배치를 담당합니다. + +```json +// DB 구조 +{ + "version": "2.0", + "components": [ + { "id": "comp_1", "position": { "x": 100, "y": 50 }, ... }, + { "id": "comp_2", "position": { "x": 500, "y": 50 }, ... }, + { "id": "GridLayout_1", "position": { "x": 100, "y": 200 }, ... } + ] +} +``` + +**현재**: absolute 포지션으로 컴포넌트 배치 → **반응형 불가** + +#### 2.5.2 컴포넌트 레이아웃 (GridLayout, FlexboxLayout 등) + +개별 레이아웃 컴포넌트 내부의 zone 배치를 담당합니다. + +| 컴포넌트 | 위치 | 내부 구조 | CSS Grid 사용 | +|----------|------|-----------|---------------| +| `GridLayout` | `layouts/grid/` | zones 배열 | ✅ 이미 사용 | +| `FlexboxLayout` | `layouts/flexbox/` | zones 배열 | ❌ absolute | +| `SplitLayout` | `layouts/split/` | left/right | ❌ flex | +| `TabsLayout` | `layouts/` | tabs 배열 | ❌ 탭 구조 | +| `CardLayout` | `layouts/card-layout/` | zones 배열 | ❌ flex | +| `AccordionLayout` | `layouts/accordion/` | items 배열 | ❌ 아코디언 | + +#### 2.5.3 구조 다이어그램 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ screen_layouts_v2 (화면 전체) │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 현재: absolute 포지션 → 반응형 불가 │ │ +│ │ 변경: ResponsiveGridLayout (CSS Grid) → 반응형 가능 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌─────────────────────────────┐ │ +│ │ v2-button │ │ v2-input │ │ GridLayout (컴포넌트) │ │ +│ │ (shadcn) │ │ (shadcn) │ │ ┌─────────┬─────────────┐ │ │ +│ └──────────┘ └──────────┘ │ │ zone1 │ zone2 │ │ │ +│ │ │ (이미 │ (이미 │ │ │ +│ │ │ CSS Grid│ CSS Grid) │ │ │ +│ │ └─────────┴─────────────┘ │ │ +│ └─────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.6 기존 레이아웃 컴포넌트 호환성 + +#### 2.6.1 GridLayout (기존 커스텀 그리드) + +```tsx +// frontend/lib/registry/layouts/grid/GridLayout.tsx +// 이미 CSS Grid를 사용하고 있음! + +const gridStyle: React.CSSProperties = { + display: "grid", + gridTemplateRows: `repeat(${gridConfig.rows}, 1fr)`, + gridTemplateColumns: `repeat(${gridConfig.columns}, 1fr)`, + gap: `${gridConfig.gap || 16}px`, +}; +``` + +**호환성**: ✅ **완전 호환** +- GridLayout은 화면 내 하나의 컴포넌트로 취급됨 +- ResponsiveGridLayout이 GridLayout의 **위치만** 관리 +- GridLayout 내부는 기존 방식 그대로 동작 + +#### 2.6.2 FlexboxLayout + +```tsx +// frontend/lib/registry/layouts/flexbox/FlexboxLayout.tsx +// zone 내부에서 컴포넌트를 absolute로 배치 + +{zoneChildren.map((child) => ( +
+ {renderer.renderChild(child)} +
+))} +``` + +**호환성**: ✅ **호환** (내부는 기존 방식 유지) +- FlexboxLayout 컴포넌트 자체의 위치는 ResponsiveGridLayout이 관리 +- 내부 zone의 컴포넌트 배치는 기존 absolute 방식 유지 + +#### 2.6.3 SplitPanelLayout (분할 패널) + +**호환성**: ⚠️ **별도 수정 필요** +- 외부 위치: ResponsiveGridLayout이 관리 ✅ +- 내부 반응형: 별도 수정 필요 (모바일에서 상하 분할) + +#### 2.6.4 호환성 요약 + +| 컴포넌트 | 외부 배치 | 내부 동작 | 추가 수정 | +|----------|----------|----------|-----------| +| **v2-button, v2-input 등** | ✅ 반응형 | ✅ shadcn 그대로 | ❌ 불필요 | +| **GridLayout** | ✅ 반응형 | ✅ CSS Grid 그대로 | ❌ 불필요 | +| **FlexboxLayout** | ✅ 반응형 | ⚠️ absolute 유지 | ❌ 불필요 | +| **SplitPanelLayout** | ✅ 반응형 | ❌ 좌우 고정 | ⚠️ 내부 반응형 추가 | +| **TabsLayout** | ✅ 반응형 | ✅ 탭 그대로 | ❌ 불필요 | + +### 2.7 동작 방식 비교 + +#### 변경 전 + +``` +화면 로드 + ↓ +screen_layouts_v2에서 components 조회 + ↓ +각 컴포넌트를 position.x, position.y로 absolute 배치 + ↓ +GridLayout 컴포넌트도 absolute로 배치됨 + ↓ +GridLayout 내부는 CSS Grid로 zone 배치 + ↓ +결과: 화면 크기 변해도 모든 컴포넌트 위치 고정 +``` + +#### 변경 후 + +``` +화면 로드 + ↓ +screen_layouts_v2에서 components 조회 + ↓ +layoutMode === "grid" 확인 + ↓ +ResponsiveGridLayout으로 렌더링 (CSS Grid) + ↓ +각 컴포넌트를 grid.col, grid.colSpan으로 배치 + ↓ +화면 크기 감지 (ResizeObserver) + ↓ +breakpoint에 따라 responsive.sm/md/lg 적용 + ↓ +GridLayout 컴포넌트도 반응형으로 배치됨 + ↓ +GridLayout 내부는 기존 CSS Grid로 zone 배치 (변경 없음) + ↓ +결과: 화면 크기에 따라 컴포넌트 재배치 +``` + --- ## 3. 기술 결정 @@ -649,6 +805,10 @@ ALTER TABLE screen_layouts_v2_backup_20260130 RENAME TO screen_layouts_v2; - [ ] 태블릿 (768px, 1024px) 테스트 - [ ] 모바일 (375px, 414px) 테스트 - [ ] 분할 패널 화면 테스트 +- [ ] GridLayout 컴포넌트 포함 화면 테스트 +- [ ] FlexboxLayout 컴포넌트 포함 화면 테스트 +- [ ] TabsLayout 컴포넌트 포함 화면 테스트 +- [ ] 중첩 레이아웃 (GridLayout 안에 컴포넌트) 테스트 --- @@ -659,6 +819,8 @@ ALTER TABLE screen_layouts_v2_backup_20260130 RENAME TO screen_layouts_v2; | 마이그레이션 실패 | 높음 | 백업 테이블에서 즉시 롤백 | | 기존 화면 깨짐 | 중간 | `layoutMode` 없으면 기존 방식 사용 (폴백) | | 디자인 모드 혼란 | 낮음 | position/size 필드 유지 | +| GridLayout 내부 깨짐 | 낮음 | 내부는 기존 방식 유지, 외부 배치만 변경 | +| 중첩 레이아웃 문제 | 낮음 | 각 레이아웃 컴포넌트는 독립적으로 동작 | --- diff --git a/docs/DDD1542/화면관계_시각화_개선_보고서.md b/docs/DDD1542/화면관계_시각화_개선_보고서.md index 27946afa..aea92243 100644 --- a/docs/DDD1542/화면관계_시각화_개선_보고서.md +++ b/docs/DDD1542/화면관계_시각화_개선_보고서.md @@ -23,7 +23,8 @@ | 테이블명 | 용도 | 주요 컬럼 | |----------|------|----------| | `screen_definitions` | 화면 정의 정보 | `screen_id`, `screen_name`, `table_name`, `company_code` | -| `screen_layouts` | 화면 레이아웃/컴포넌트 정보 | `screen_id`, `properties` (JSONB - componentConfig 포함) | +| `screen_layouts` | 화면 레이아웃/컴포넌트 정보 (Legacy) | `screen_id`, `properties` (JSONB - componentConfig 포함) | +| `screen_layouts_v2` | 화면 레이아웃/컴포넌트 정보 (V2) | `screen_id`, `layout_data` (JSONB - components 배열) | | `screen_groups` | 화면 그룹 정보 | `group_id`, `group_code`, `group_name`, `parent_group_id` | | `screen_group_mappings` | 화면-그룹 매핑 | `group_id`, `screen_id`, `display_order` | @@ -86,9 +87,17 @@ screen_groups (그룹) │ │ │ └─── screen_definitions (화면) │ │ - │ └─── screen_layouts (레이아웃/컴포넌트) + │ ├─── screen_layouts (Legacy) + │ │ │ + │ │ └─── properties.componentConfig + │ │ ├── fieldMappings + │ │ ├── parentDataMapping + │ │ ├── columns.mapping + │ │ └── rightPanel.relation + │ │ + │ └─── screen_layouts_v2 (V2) ← 현재 표준 │ │ - │ └─── properties.componentConfig + │ └─── layout_data.components[].overrides │ ├── fieldMappings │ ├── parentDataMapping │ ├── columns.mapping @@ -1120,9 +1129,12 @@ screenSubTables[screenId].subTables.push({ 21. [x] 필터 연결선 포커싱 제어 (해당 화면 포커싱 시에만 표시) 22. [x] 저장 테이블 제외 조건 추가 (table-list + 체크박스 + openModalWithData) 23. [x] 첫 진입 시 포커싱 없이 시작 (트리에서 화면 클릭 시 그룹만 진입) -24. [ ] **선 교차점 이질감 해결** (계획 중) -22. [ ] 범례 UI 추가 (선택사항) -23. [ ] 엣지 라벨에 관계 유형 표시 (선택사항) +24. [x] **screen_layouts_v2 지원 추가** (rightPanel.relation V2 UNION 쿼리) ✅ 2026-01-30 +25. [x] **테이블 분류 우선순위 시스템** (메인 > 서브 우선순위 적용) ✅ 2026-01-30 +26. [x] **globalMainTables API 추가** (WHERE 조건 대상 테이블 목록 반환) ✅ 2026-01-30 +27. [ ] **선 교차점 이질감 해결** (계획 중) +28. [ ] 범례 UI 추가 (선택사항) +29. [ ] 엣지 라벨에 관계 유형 표시 (선택사항) --- @@ -1682,6 +1694,149 @@ frontend/ --- +## 테이블 분류 우선순위 시스템 (2026-01-30) + +### 배경 + +마스터-디테일 관계의 디테일 테이블(예: `user_dept`)이 다른 곳에서 autocomplete 참조로도 사용되는 경우, +서브 테이블 영역에 잘못 배치되는 문제가 발생했습니다. + +### 문제 상황 + +``` +[user_info] - 화면 139의 디테일 → 메인 테이블 영역 (O) +[user_dept] - 화면 162의 디테일이지만 autocomplete 참조도 있음 → 서브 테이블 영역 (X) +``` + +**원인**: 테이블 분류 시 우선순위가 없어서 먼저 발견된 관계 타입으로 분류됨 + +### 해결책: 우선순위 기반 테이블 분류 + +#### 분류 규칙 + +| 우선순위 | 분류 | 조건 | 비고 | +|----------|------|------|------| +| **1순위** | 메인 테이블 | `screen_definitions.table_name` | 컴포넌트 직접 연결 | +| **1순위** | 메인 테이블 | `v2-split-panel-layout.rightPanel.tableName` | WHERE 조건 대상 | +| **2순위** | 서브 테이블 | 조인으로만 연결된 테이블 | autocomplete 등 참조 | + +#### 핵심 규칙 + +> **메인 조건에 해당하면, 서브 조건이 있어도 무조건 메인으로 분류** + +### 백엔드 변경 (`screenGroupController.ts`) + +#### 1. screen_layouts_v2 지원 추가 + +`rightPanelQuery`에 V2 테이블 UNION 추가: + +```sql +-- V1: screen_layouts에서 조회 +SELECT ... +FROM screen_definitions sd +JOIN screen_layouts sl ON sd.screen_id = sl.screen_id +WHERE sl.properties->'componentConfig'->'rightPanel'->'relation' IS NOT NULL + +UNION ALL + +-- V2: screen_layouts_v2에서 조회 (v2-split-panel-layout 컴포넌트) +SELECT + sd.screen_id, + comp->'overrides'->>'type' as component_type, + comp->'overrides'->'rightPanel'->'relation' as right_panel_relation, + comp->'overrides'->'rightPanel'->>'tableName' as right_panel_table, + ... +FROM screen_definitions sd +JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id, +jsonb_array_elements(slv2.layout_data->'components') as comp +WHERE comp->'overrides'->'rightPanel'->'relation' IS NOT NULL +``` + +#### 2. globalMainTables API 추가 + +`getScreenSubTables` 응답에 전역 메인 테이블 목록 추가: + +```sql +-- 모든 화면의 메인 테이블 수집 +SELECT DISTINCT table_name as main_table FROM screen_definitions WHERE screen_id = ANY($1) +UNION +SELECT DISTINCT comp->'overrides'->'rightPanel'->>'tableName' as main_table +FROM screen_layouts_v2 ... +``` + +**응답 구조:** +```typescript +res.json({ + success: true, + data: screenSubTables, + globalMainTables: globalMainTables, // 메인 테이블 목록 추가 +}); +``` + +### 프론트엔드 변경 (`ScreenRelationFlow.tsx`) + +#### 1. globalMainTables 상태 추가 + +```typescript +const [globalMainTables, setGlobalMainTables] = useState>(new Set()); +``` + +#### 2. 우선순위 기반 테이블 분류 + +```typescript +// 1. globalMainTables를 mainTableSet에 먼저 추가 (우선순위 적용) +globalMainTables.forEach((tableName) => { + if (!mainTableSet.has(tableName)) { + mainTableSet.add(tableName); + filterTableSet.add(tableName); // 보라색 테두리 + } +}); + +// 2. 서브 테이블 수집 (mainTableSet에 없는 것만) +screenSubData.subTables.forEach((subTable) => { + if (mainTableSet.has(subTable.tableName)) { + return; // 메인 테이블은 서브에서 제외 + } + subTableSet.add(subTable.tableName); +}); +``` + +### 시각적 결과 + +#### 변경 전 + +``` +[화면 노드들] + │ + ▼ +[메인 테이블: dept_info, user_info] ← user_dept 없음 + │ + ▼ +[서브 테이블: user_dept, customer_mng] ← user_dept가 잘못 배치됨 +``` + +#### 변경 후 + +``` +[화면 노드들] + │ + ▼ +[메인 테이블: dept_info, user_info, user_dept] ← user_dept 보라색 테두리 + │ + ▼ +[서브 테이블: customer_mng] ← 조인 참조용 테이블만 +``` + +### 관련 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `backend-node/src/controllers/screenGroupController.ts` | screen_layouts_v2 UNION 추가, globalMainTables 반환 | +| `frontend/components/screen/ScreenRelationFlow.tsx` | globalMainTables 상태, 우선순위 분류 로직 | +| `frontend/components/screen/ScreenNode.tsx` | isFilterTable prop 및 보라색 테두리 스타일 | + +--- + ## 화면 설정 모달 개선 (2026-01-12) ### 개요 @@ -1742,4 +1897,6 @@ npm install react-zoom-pan-pinch - [멀티테넌시 구현 가이드](.cursor/rules/multi-tenancy-guide.mdc) - [API 클라이언트 사용 규칙](.cursor/rules/api-client-usage.mdc) - [관리자 페이지 스타일 가이드](.cursor/rules/admin-page-style-guide.mdc) +- [화면 복제 V2 마이그레이션 계획서](../SCREEN_COPY_V2_MIGRATION_PLAN.md) - screen_layouts_v2 복제 로직 +- [V2 컴포넌트 마이그레이션 분석](../V2_COMPONENT_MIGRATION_ANALYSIS.md) - V2 아키텍처 diff --git a/docs/SCREEN_COPY_V2_MIGRATION_PLAN.md b/docs/SCREEN_COPY_V2_MIGRATION_PLAN.md index 7e1afcba..bcb2a765 100644 --- a/docs/SCREEN_COPY_V2_MIGRATION_PLAN.md +++ b/docs/SCREEN_COPY_V2_MIGRATION_PLAN.md @@ -467,9 +467,9 @@ V2 전환 롤백 (필요시): - [x] copyScreens() - Legacy 제거, V2로 교체 ✅ 2026-01-28 - [x] hasLayoutChangesV2() 함수 추가 ✅ 2026-01-28 - [x] updateTabScreenReferences() V2 지원 추가 ✅ 2026-01-28 -- [ ] 단위 테스트 통과 -- [ ] 통합 테스트 통과 -- [ ] V2 전용 복제 동작 확인 +- [x] 단위 테스트 통과 ✅ 2026-01-30 +- [x] 통합 테스트 통과 ✅ 2026-01-30 +- [x] V2 전용 복제 동작 확인 ✅ 2026-01-30 ### 9.3 Phase 2 완료 조건 @@ -522,3 +522,4 @@ V2 전환 롤백 (필요시): | 2026-01-28 | 시뮬레이션 검증 - updateTabScreenReferences V2 지원 추가 | Claude | | 2026-01-28 | V2 경로 지원 추가 - action/sections 직접 경로 (componentConfig 없이) | Claude | | 2026-01-30 | **실제 코드 구현 완료** - copyScreen(), copyScreens() V2 전환 | Claude | +| 2026-01-30 | **Phase 1 테스트 완료** - 단위/통합 테스트 통과 확인 | Claude | diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx index e49bf6d8..bcaaf054 100644 --- a/frontend/components/screen/ScreenNode.tsx +++ b/frontend/components/screen/ScreenNode.tsx @@ -58,6 +58,7 @@ export interface TableNodeData { label: string; subLabel?: string; isMain?: boolean; + isFilterTable?: boolean; // 마스터-디테일의 디테일 테이블인지 (보라색 테두리) isFocused?: boolean; // 포커스된 테이블인지 isFaded?: boolean; // 흑백 처리할지 columns?: Array<{ @@ -448,7 +449,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: // ========== 테이블 노드 (하단) - 컬럼 목록 표시 (컴팩트) ========== export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { - const { label, subLabel, isMain, isFocused, isFaded, columns, highlightedColumns, joinColumns, joinColumnRefs, filterColumns, fieldMappings, referencedBy, saveInfos } = data; + const { label, subLabel, isMain, isFilterTable, isFocused, isFaded, columns, highlightedColumns, joinColumns, joinColumnRefs, filterColumns, fieldMappings, referencedBy, saveInfos } = data; // 강조할 컬럼 세트 (영문 컬럼명 기준) const highlightSet = new Set(highlightedColumns || []); @@ -574,16 +575,19 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { return (
{ + const handleScreenListRefresh = () => { + // refreshKey 증가로 데이터 재로드 트리거 + setRefreshKey(prev => prev + 1); + }; + + window.addEventListener("screen-list-refresh", handleScreenListRefresh); + return () => { + window.removeEventListener("screen-list-refresh", handleScreenListRefresh); + }; + }, []); + // 그룹 또는 화면이 변경될 때 포커스 초기화 useEffect(() => { setFocusedScreenId(null); @@ -170,6 +183,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 화면별 사용 컬럼 매핑 (화면 ID -> 테이블명 -> 사용 컬럼들) const [screenUsedColumnsMap, setScreenUsedColumnsMap] = useState>>({}); + + // 전역 메인 테이블 목록 (우선순위: 메인 > 서브) + // 이 목록에 있는 테이블은 서브 테이블로 분류되지 않음 + const [globalMainTables, setGlobalMainTables] = useState>(new Set()); // 테이블 컬럼 정보 로드 const loadTableColumns = useCallback( @@ -266,24 +283,26 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId const flows = flowsRes.success ? flowsRes.data || [] : []; const relations = relationsRes.success ? relationsRes.data || [] : []; - // 데이터 흐름에서 연결된 화면들 추가 - flows.forEach((flow: any) => { - if (flow.source_screen_id === screen.screenId && flow.target_screen_id) { - const exists = screenList.some((s) => s.screenId === flow.target_screen_id); - if (!exists) { - screenList.push({ - screenId: flow.target_screen_id, - screenName: flow.target_screen_name || `화면 ${flow.target_screen_id}`, - screenCode: "", - tableName: "", - companyCode: screen.companyCode, - isActive: "Y", - createdDate: new Date(), - updatedDate: new Date(), - } as ScreenDefinition); + // 데이터 흐름에서 연결된 화면들 추가 (개별 화면 모드에서만 - 그룹 모드에서는 그룹 내 화면만 표시) + if (!selectedGroup && screen) { + flows.forEach((flow: any) => { + if (flow.source_screen_id === screen.screenId && flow.target_screen_id) { + const exists = screenList.some((s) => s.screenId === flow.target_screen_id); + if (!exists) { + screenList.push({ + screenId: flow.target_screen_id, + screenName: flow.target_screen_name || `화면 ${flow.target_screen_id}`, + screenCode: "", + tableName: "", + companyCode: screen.companyCode, + isActive: "Y", + createdDate: new Date(), + updatedDate: new Date(), + } as ScreenDefinition); + } } - } - }); + }); + } // 화면 레이아웃 요약 정보 로드 const screenIds = screenList.map((s) => s.screenId); @@ -305,6 +324,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId subTablesData = subTablesRes.data as Record; // 서브 테이블 데이터 저장 (조인 컬럼 정보 포함) setSubTablesDataMap(subTablesData); + + // 전역 메인 테이블 목록 저장 (우선순위 적용용) + // 이 목록에 있는 테이블은 서브 테이블로 분류되지 않음 + const globalMainTablesArr = (subTablesRes as any).globalMainTables as string[] | undefined; + if (globalMainTablesArr && Array.isArray(globalMainTablesArr)) { + setGlobalMainTables(new Set(globalMainTablesArr)); + } } } catch (e) { console.error("레이아웃 요약/서브 테이블 로드 실패:", e); @@ -434,9 +460,27 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId if (rel.table_name) mainTableSet.add(rel.table_name); }); - // 서브 테이블 수집 (componentConfig에서 추출된 테이블들) - // 서브 테이블은 메인 테이블과 다른 테이블들 - // 화면별 서브 테이블 매핑도 함께 구축 + // ============================================================ + // 테이블 분류 (우선순위: 메인 > 서브) + // ============================================================ + // 메인 테이블 조건: + // 1. screen_definitions.table_name (컴포넌트 직접 연결) - 이미 mainTableSet에 추가됨 + // 2. globalMainTables (WHERE 조건 대상, 마스터-디테일의 디테일 테이블) + // + // 서브 테이블 조건: + // - 조인(JOIN)으로만 연결된 테이블 (autocomplete 등에서 참조) + // - 단, mainTableSet에 있으면 제외 (우선순위: 메인 > 서브) + + // 1. globalMainTables를 mainTableSet에 먼저 추가 (우선순위 적용) + const filterTableSet = new Set(); // 마스터-디테일의 디테일 테이블들 + globalMainTables.forEach((tableName) => { + if (!mainTableSet.has(tableName)) { + mainTableSet.add(tableName); + filterTableSet.add(tableName); // 필터 테이블로 분류 (보라색 테두리) + } + }); + + // 2. 서브 테이블 수집 (mainTableSet에 없는 것만) const newScreenSubTableMap: Record = {}; Object.entries(subTablesData).forEach(([screenIdStr, screenSubData]) => { @@ -444,11 +488,14 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId const subTableNames: string[] = []; screenSubData.subTables.forEach((subTable) => { - // 메인 테이블에 없는 것만 서브 테이블로 추가 - if (!mainTableSet.has(subTable.tableName)) { - subTableSet.add(subTable.tableName); - subTableNames.push(subTable.tableName); + // mainTableSet에 있으면 서브 테이블에서 제외 (우선순위: 메인 > 서브) + if (mainTableSet.has(subTable.tableName)) { + return; } + + // 조인으로만 연결된 테이블 → 서브 테이블 + subTableSet.add(subTable.tableName); + subTableNames.push(subTable.tableName); }); if (subTableNames.length > 0) { @@ -539,10 +586,19 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId isForeignKey: !!col.referenceTable || (col.columnName?.includes("_id") && col.columnName !== "id"), })); - // 여러 화면이 같은 테이블 사용하면 "공통 메인 테이블", 아니면 "메인 테이블" - const subLabel = linkedScreens.length > 1 - ? `메인 테이블 (${linkedScreens.length}개 화면)` - : "메인 테이블"; + // 테이블 분류에 따른 라벨 결정 + // 1. 필터 테이블 (마스터-디테일의 디테일): "필터 대상 테이블" + // 2. 여러 화면이 같은 테이블 사용: "공통 메인 테이블 (N개 화면)" + // 3. 일반 메인 테이블: "메인 테이블" + const isFilterTable = filterTableSet.has(tableName); + let subLabel: string; + if (isFilterTable) { + subLabel = "필터 대상 테이블 (마스터-디테일)"; + } else if (linkedScreens.length > 1) { + subLabel = `메인 테이블 (${linkedScreens.length}개 화면)`; + } else { + subLabel = "메인 테이블"; + } // 이 테이블을 참조하는 관계들 tableNodes.push({ @@ -552,7 +608,8 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId data: { label: tableName, subLabel: subLabel, - isMain: true, // mainTableSet의 모든 테이블은 메인 + isMain: !isFilterTable, // 필터 테이블은 isMain: false로 설정 (보라색 테두리 표시용) + isFilterTable: isFilterTable, // 필터 테이블 여부 표시 columns: formattedColumns, // referencedBy, filterColumns, saveInfos는 styledNodes에서 포커스 상태에 따라 동적으로 설정 }, diff --git a/frontend/stagewise.json b/frontend/stagewise.json new file mode 100644 index 00000000..d6cd6af2 --- /dev/null +++ b/frontend/stagewise.json @@ -0,0 +1,3 @@ +{ + "appPort": 9771 +} \ No newline at end of file From f821a7bff3d707199a9b4743832e60d41f2b0a99 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 3 Feb 2026 17:45:37 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20V2=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20API=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScreenModal 컴포넌트에서 V2 레이아웃 API를 사용하여 화면 정보와 레이아웃 데이터를 로딩하도록 수정하였습니다. - V2 레이아웃 데이터를 Legacy 형식으로 변환하는 로직을 추가하여 기본값 병합을 지원합니다. - V2 레이아웃이 없을 경우 기존 API로 fallback하는 기능을 구현하였습니다. - 관련 문서 및 주석을 업데이트하여 새로운 기능에 대한 이해를 돕도록 하였습니다. - frontend/stagewise.json 파일을 삭제하였습니다. --- docs/DDD1542/FLOW_BASED_RESPONSIVE_DESIGN.md | 729 ++++++++++++++++++ .../PC_RESPONSIVE_IMPLEMENTATION_PLAN.md | 688 +++++++++++++++++ .../본서버_개발서버_마이그레이션_가이드.md | 325 ++++++++ ...본서버_개발서버_마이그레이션_상세가이드.md | 553 +++++++++++++ docs/SCREEN_COPY_V2_MIGRATION_PLAN.md | 2 +- frontend/components/common/ScreenModal.tsx | 23 +- frontend/stagewise.json | 3 - 7 files changed, 2316 insertions(+), 7 deletions(-) create mode 100644 docs/DDD1542/FLOW_BASED_RESPONSIVE_DESIGN.md create mode 100644 docs/DDD1542/PC_RESPONSIVE_IMPLEMENTATION_PLAN.md create mode 100644 docs/DDD1542/본서버_개발서버_마이그레이션_가이드.md create mode 100644 docs/DDD1542/본서버_개발서버_마이그레이션_상세가이드.md delete mode 100644 frontend/stagewise.json diff --git a/docs/DDD1542/FLOW_BASED_RESPONSIVE_DESIGN.md b/docs/DDD1542/FLOW_BASED_RESPONSIVE_DESIGN.md new file mode 100644 index 00000000..f885debb --- /dev/null +++ b/docs/DDD1542/FLOW_BASED_RESPONSIVE_DESIGN.md @@ -0,0 +1,729 @@ +# Flow 기반 반응형 레이아웃 설계서 + +> 작성일: 2026-01-30 +> 목표: 진정한 반응형 구현 (PC/태블릿/모바일 전체 대응) + +--- + +## 1. 핵심 결론 + +### 1.1 현재 방식 vs 반응형 표준 + +| 항목 | 현재 시스템 | 웹 표준 (2025) | +|------|-------------|----------------| +| 배치 방식 | `position: absolute` | **Flexbox / CSS Grid** | +| 좌표 | 픽셀 고정 (x, y) | **Flow 기반 (순서)** | +| 화면 축소 시 | 그대로 (잘림) | **자동 재배치** | +| 용도 | 툴팁, 오버레이 | **전체 레이아웃** | + +> **결론**: `position: absolute`는 전체 레이아웃에 사용하면 안 됨 (웹 표준) + +### 1.2 구현 방향 + +``` +절대 좌표 (x, y 픽셀) + ↓ 변환 +Flow 기반 배치 (Flexbox + Grid) + ↓ 결과 +화면 크기에 따라 자동 재배치 +``` + +--- + +## 2. 실제 화면 데이터 분석 + +### 2.1 분석 대상 + +``` +총 레이아웃: 1,250개 +총 컴포넌트: 5,236개 +분석 샘플: 6개 화면 (23, 20, 18, 16, 18, 5개 컴포넌트) +``` + +### 2.2 화면 68 (수주 목록) - 가로 배치 패턴 + +``` +y=88: [분리] [저장] [수정] [삭제] ← 같은 행에 버튼 4개 + x=1277 x=1436 x=1594 x=1753 + +y=128: [────────── 테이블 ──────────] + x=8, width=1904 +``` + +**변환 후**: +```html +
+ + + + +
+
+ + +``` + +**반응형 동작**: +``` +1920px: [분리] [저장] [수정] [삭제] ← 가로 배치 +1280px: [분리] [저장] [수정] [삭제] ← 가로 배치 (공간 충분) + 768px: [분리] [저장] ← 줄바꿈 발생 + [수정] [삭제] + 375px: [분리] ← 세로 배치 + [저장] + [수정] + [삭제] +``` + +### 2.3 화면 119 (장치 관리) - 2열 폼 패턴 + +``` +y=80: [장치 코드 ] [시리얼넘버 ] + x=136, w=256 x=408, w=256 + +y=160: [제조사 ] + x=136, w=528 + +y=240: [품번 ] [모델명 ] + x=136, w=256 x=408, w=256 + +y=320: [구매일 ] [상태 ] +y=400: [공급사 ] [구매 가격 ] +y=480: [계약 번호 ] [공급사 전화 ] +... (2열 반복) + +y=840: [저장] + x=544 +``` + +**변환 후**: +```html +
+ + +
+
+ +
+
+ + + + +
+ + + +
+ + + +
+
+ + + +
+ +
+``` + +**반응형 동작**: +``` +1920px: [입력방식] [판매유형] [단가방식] [단가수정] ← 4열 +1280px: [입력방식] [판매유형] [단가방식] ← 3열 + [단가수정] + 768px: [입력방식] [판매유형] ← 2열 + [단가방식] [단가수정] + 375px: [입력방식] ← 1열 + [판매유형] + [단가방식] + [단가수정] +``` + +--- + +## 3. 변환 규칙 + +### 3.1 Row 그룹화 알고리즘 + +```typescript +const ROW_THRESHOLD = 40; // px + +function groupByRows(components: Component[]): Row[] { + // 1. y 좌표로 정렬 + const sorted = [...components].sort((a, b) => a.position.y - b.position.y); + + const rows: Row[] = []; + let currentRow: Component[] = []; + let currentY = -Infinity; + + for (const comp of sorted) { + if (comp.position.y - currentY > ROW_THRESHOLD) { + // 새로운 Row 시작 + if (currentRow.length > 0) { + rows.push({ + y: currentY, + components: currentRow.sort((a, b) => a.position.x - b.position.x) + }); + } + currentRow = [comp]; + currentY = comp.position.y; + } else { + // 같은 Row에 추가 + currentRow.push(comp); + } + } + + // 마지막 Row 추가 + if (currentRow.length > 0) { + rows.push({ + y: currentY, + components: currentRow.sort((a, b) => a.position.x - b.position.x) + }); + } + + return rows; +} +``` + +### 3.2 화면 68 적용 예시 + +**입력**: +```json +[ + { "id": "comp_1899", "position": { "x": 1277, "y": 88 }, "text": "분리" }, + { "id": "comp_1898", "position": { "x": 1436, "y": 88 }, "text": "저장" }, + { "id": "comp_1897", "position": { "x": 1594, "y": 88 }, "text": "수정" }, + { "id": "comp_1896", "position": { "x": 1753, "y": 88 }, "text": "삭제" }, + { "id": "comp_1895", "position": { "x": 8, "y": 128 }, "type": "table" } +] +``` + +**변환 결과**: +```json +{ + "rows": [ + { + "y": 88, + "justify": "end", + "components": ["comp_1899", "comp_1898", "comp_1897", "comp_1896"] + }, + { + "y": 128, + "justify": "start", + "components": ["comp_1895"] + } + ] +} +``` + +### 3.3 정렬 방향 결정 + +```typescript +function determineJustify(row: Row, screenWidth: number): string { + const firstX = row.components[0].position.x; + const lastComp = row.components[row.components.length - 1]; + const lastEnd = lastComp.position.x + lastComp.size.width; + + // 왼쪽 여백 vs 오른쪽 여백 비교 + const leftMargin = firstX; + const rightMargin = screenWidth - lastEnd; + + if (leftMargin > rightMargin * 2) { + return "end"; // 오른쪽 정렬 + } else if (rightMargin > leftMargin * 2) { + return "start"; // 왼쪽 정렬 + } else { + return "center"; // 중앙 정렬 + } +} + +// 화면 68 버튼 그룹: +// leftMargin = 1277, rightMargin = 1920 - 1912 = 8 +// → "end" (오른쪽 정렬) +``` + +--- + +## 4. 렌더링 구현 + +### 4.1 새로운 FlowLayout 컴포넌트 + +```tsx +// frontend/lib/registry/layouts/flow/FlowLayout.tsx + +interface FlowLayoutProps { + layout: LayoutData; + renderer: DynamicComponentRenderer; +} + +export function FlowLayout({ layout, renderer }: FlowLayoutProps) { + // 1. Row 그룹화 + const rows = useMemo(() => { + return groupByRows(layout.components); + }, [layout.components]); + + return ( +
+ {rows.map((row, index) => ( + + ))} +
+ ); +} + +function FlowRow({ row, renderer }: { row: Row; renderer: any }) { + const justify = determineJustify(row, 1920); + + const justifyClass = { + start: "justify-start", + center: "justify-center", + end: "justify-end", + }[justify]; + + return ( +
+ {row.components.map((comp) => ( +
+ {renderer.renderChild(comp)} +
+ ))} +
+ ); +} +``` + +### 4.2 기존 코드 수정 위치 + +**현재 (RealtimePreviewDynamic.tsx 라인 524-536)**: +```tsx +const baseStyle = { + left: `${adjustedPositionX}px`, // ❌ 절대 좌표 + top: `${position.y}px`, // ❌ 절대 좌표 + position: "absolute", // ❌ 절대 위치 +}; +``` + +**변경 후**: +```tsx +// FlowLayout 사용 시 position 관련 스타일 제거 +const baseStyle = isFlowMode ? { + // position, left, top 없음 + minWidth: size.width, + height: size.height, +} : { + left: `${adjustedPositionX}px`, + top: `${position.y}px`, + position: "absolute", +}; +``` + +--- + +## 5. 가상 시뮬레이션 + +### 5.1 시나리오 1: 화면 68 (버튼 4개 + 테이블) + +**렌더링 결과 (1920px)**: +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ [분리] [저장] [수정] [삭제] │ +│ flex-wrap, justify-end │ +├────────────────────────────────────────────────────────────────────────┤ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ 테이블 (w-full) │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────────────────┘ +✅ 정상: 버튼 오른쪽 정렬, 테이블 전체 너비 +``` + +**렌더링 결과 (1280px)**: +``` +┌─────────────────────────────────────────────┐ +│ [분리] [저장] [수정] [삭제] │ +│ flex-wrap, justify-end │ +├─────────────────────────────────────────────┤ +│ ┌─────────────────────────────────────────┐ │ +│ │ 테이블 (w-full) │ │ +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ +✅ 정상: 버튼 크기 유지, 테이블 너비 조정 +``` + +**렌더링 결과 (768px)**: +``` +┌──────────────────────────┐ +│ [분리] [저장] │ +│ [수정] [삭제] │ ← 자동 줄바꿈! +├──────────────────────────┤ +│ ┌──────────────────────┐ │ +│ │ 테이블 (w-full) │ │ +│ └──────────────────────┘ │ +└──────────────────────────┘ +✅ 정상: 버튼 줄바꿈, 테이블 너비 조정 +``` + +**렌더링 결과 (375px)**: +``` +┌─────────────┐ +│ [분리] │ +│ [저장] │ +│ [수정] │ +│ [삭제] │ ← 세로 배치 +├─────────────┤ +│ ┌─────────┐ │ +│ │ 테이블 │ │ (가로 스크롤) +│ └─────────┘ │ +└─────────────┘ +✅ 정상: 버튼 세로 배치, 테이블 가로 스크롤 +``` + +### 5.2 시나리오 2: 화면 119 (2열 폼) + +**렌더링 결과 (1920px)**: +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ [장치 코드 ] [시리얼넘버 ] │ +│ grid-cols-2 │ +├────────────────────────────────────────────────────────────────────────┤ +│ [제조사 ] │ +│ col-span-2 (전체 너비) │ +├────────────────────────────────────────────────────────────────────────┤ +│ [품번 ] [모델명▼ ] │ +│ ... │ +└────────────────────────────────────────────────────────────────────────┘ +✅ 정상: 2열 그리드 +``` + +**렌더링 결과 (768px)**: +``` +┌──────────────────────────┐ +│ [장치 코드 ] │ +│ [시리얼넘버 ] │ ← 1열로 변경 +├──────────────────────────┤ +│ [제조사 ] │ +├──────────────────────────┤ +│ [품번 ] │ +│ [모델명▼ ] │ +│ ... │ +└──────────────────────────┘ +✅ 정상: 1열 그리드 +``` + +### 5.3 시나리오 3: 분할 패널 + +**현재 SplitPanelLayout 동작**: +``` +좌측 60% | 우측 40% ← 이미 퍼센트 기반 +``` + +**변경 후 (768px 이하)**: +``` +┌────────────────────┐ +│ 좌측 100% │ +├────────────────────┤ +│ 우측 100% │ +└────────────────────┘ +← 세로 배치로 전환 +``` + +**구현**: +```tsx +// SplitPanelLayoutComponent.tsx +const isMobile = useMediaQuery("(max-width: 768px)"); + +return ( +
+
+ {/* 좌측 패널 */} +
+
+ {/* 우측 패널 */} +
+
+); +``` + +--- + +## 6. 엣지 케이스 검증 + +### 6.1 겹치는 컴포넌트 + +**현재 데이터 (화면 74)**: +```json +{ "id": "comp_2606", "position": { "x": 161, "y": 400 } }, // 분할 패널 +{ "id": "comp_fkk75q08", "position": { "x": 161, "y": 400 } } // 라디오 버튼 +``` + +**문제**: 같은 위치에 두 컴포넌트 → z-index로 겹쳐서 표시 + +**해결**: +- z-index가 높은 컴포넌트 우선 +- 또는 parent-child 관계면 중첩 처리 + +```typescript +function resolveOverlaps(row: Row): Row { + // z-index로 정렬하여 높은 것만 표시 + // 또는 parentId 확인하여 중첩 처리 +} +``` + +### 6.2 조건부 표시 컴포넌트 + +**현재 데이터 (화면 4103)**: +```json +{ + "id": "section-customer-info", + "conditionalConfig": { + "field": "input_method", + "value": "customer_first", + "action": "show" + } +} +``` + +**동작**: 조건에 따라 show/hide +**Flow 레이아웃에서**: 숨겨지면 공간도 사라짐 (flex 자동 조정) + +✅ 문제없음 + +### 6.3 테이블 + 버튼 조합 + +**패턴**: +``` +[버튼 그룹] ← flex-wrap, justify-end +[테이블] ← w-full +``` + +**테이블 가로 스크롤**: +- 테이블 내부는 가로 스크롤 지원 +- 외부 컨테이너는 w-full + +✅ 문제없음 + +### 6.4 섹션 카드 내부 컴포넌트 + +**현재**: 섹션 카드와 내부 컴포넌트가 별도로 저장됨 + +**변환 시**: +1. 섹션 카드의 y 범위 파악 +2. 해당 y 범위 내 컴포넌트들을 섹션 자식으로 그룹화 +3. 섹션 내부에서 다시 Row 그룹화 + +```typescript +function groupWithinSection( + section: Component, + allComponents: Component[] +): Component[] { + const sectionTop = section.position.y; + const sectionBottom = section.position.y + section.size.height; + + return allComponents.filter(comp => { + return comp.id !== section.id && + comp.position.y >= sectionTop && + comp.position.y < sectionBottom; + }); +} +``` + +--- + +## 7. 호환성 검증 + +### 7.1 기존 기능 호환 + +| 기능 | 호환 여부 | 설명 | +|------|----------|------| +| 디자인 모드 | ⚠️ 수정 필요 | 드래그 앤 드롭 로직 수정 | +| 미리보기 | ✅ 호환 | Flow 레이아웃으로 렌더링 | +| 조건부 표시 | ✅ 호환 | flex로 자동 조정 | +| 분할 패널 | ⚠️ 수정 필요 | 반응형 전환 로직 추가 | +| 테이블 | ✅ 호환 | w-full 적용 | +| 모달 | ✅ 호환 | 모달 내부도 Flow 적용 | + +### 7.2 디자인 모드 수정 + +**현재**: 드래그하면 x, y 픽셀 저장 +**변경 후**: 드래그하면 x, y 픽셀 저장 (동일) → 렌더링 시 변환 + +``` +저장: 픽셀 좌표 (기존 유지) +렌더링: Flow 기반으로 변환 +``` + +**장점**: DB 마이그레이션 불필요 + +--- + +## 8. 구현 계획 + +### Phase 1: 핵심 변환 로직 (1일) + +1. `groupByRows()` 함수 구현 +2. `determineJustify()` 함수 구현 +3. `FlowLayout` 컴포넌트 생성 + +### Phase 2: 렌더링 적용 (1일) + +1. `DynamicComponentRenderer`에 Flow 모드 추가 +2. `RealtimePreviewDynamic` 수정 +3. 기존 absolute 스타일 조건부 적용 + +### Phase 3: 특수 케이스 처리 (1일) + +1. 섹션 카드 내부 그룹화 +2. 겹치는 컴포넌트 처리 +3. 분할 패널 반응형 전환 + +### Phase 4: 테스트 (1일) + +1. 화면 68 (버튼 + 테이블) 테스트 +2. 화면 119 (2열 폼) 테스트 +3. 화면 4103 (복잡한 폼) 테스트 +4. PC 1920px → 1280px 테스트 +5. 태블릿 768px 테스트 +6. 모바일 375px 테스트 + +--- + +## 9. 예상 이슈 + +### 9.1 디자이너 의도 손실 + +**문제**: 디자이너가 의도적으로 배치한 위치가 변경될 수 있음 + +**해결**: +- 기본 Flow 레이아웃 적용 +- 필요시 `flexOrder` 속성으로 순서 조정 가능 +- 또는 `fixedPosition: true` 옵션으로 절대 좌표 유지 + +### 9.2 복잡한 레이아웃 + +**문제**: 일부 화면은 자유 배치가 필요할 수 있음 + +**해결**: +- 화면별 `layoutMode` 설정 + - `"flow"`: Flow 기반 (기본값) + - `"absolute"`: 기존 절대 좌표 + +### 9.3 성능 + +**문제**: 매 렌더링마다 Row 그룹화 계산 + +**해결**: +- `useMemo`로 캐싱 +- 컴포넌트 목록 변경 시에만 재계산 + +--- + +## 10. 최종 체크리스트 + +### 구현 전 + +- [ ] 현재 동작하는 화면 스크린샷 (비교용) +- [ ] 테스트 화면 목록 확정 (68, 119, 4103) + +### 구현 중 + +- [ ] `groupByRows()` 구현 +- [ ] `determineJustify()` 구현 +- [ ] `FlowLayout` 컴포넌트 생성 +- [ ] `DynamicComponentRenderer` 수정 +- [ ] `RealtimePreviewDynamic` 수정 + +### 테스트 + +- [ ] 1920px 테스트 +- [ ] 1280px 테스트 +- [ ] 768px 테스트 +- [ ] 375px 테스트 +- [ ] 디자인 모드 테스트 +- [ ] 분할 패널 테스트 +- [ ] 조건부 표시 테스트 + +--- + +## 11. 결론 + +### 11.1 구현 가능 여부 + +**✅ 가능** + +- 기존 데이터 구조 유지 (DB 변경 없음) +- 렌더링 레벨에서만 변환 +- 모든 화면 패턴 분석 완료 +- 엣지 케이스 해결책 확보 + +### 11.2 핵심 변경 사항 + +``` +Before: position: absolute + left/top 픽셀 +After: Flexbox + flex-wrap + justify-* +``` + +### 11.3 예상 효과 + +| 화면 크기 | Before | After | +|-----------|--------|-------| +| 1920px | 정상 | 정상 | +| 1280px | 버튼 잘림 | **자동 조정** | +| 768px | 레이아웃 깨짐 | **자동 재배치** | +| 375px | 사용 불가 | **자동 세로 배치** | diff --git a/docs/DDD1542/PC_RESPONSIVE_IMPLEMENTATION_PLAN.md b/docs/DDD1542/PC_RESPONSIVE_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..3d6ec12d --- /dev/null +++ b/docs/DDD1542/PC_RESPONSIVE_IMPLEMENTATION_PLAN.md @@ -0,0 +1,688 @@ +# PC 반응형 구현 계획서 + +> 작성일: 2026-01-30 +> 목표: PC 환경 (1280px ~ 1920px)에서 완벽한 반응형 구현 + +--- + +## 1. 목표 정의 + +### 1.1 범위 + +| 환경 | 화면 크기 | 우선순위 | +|------|-----------|----------| +| **PC (대형 모니터)** | 1920px | 기준 | +| **PC (노트북)** | 1280px ~ 1440px | **1순위** | +| 태블릿 | 768px ~ 1024px | 2순위 (추후) | +| 모바일 | < 768px | 3순위 (추후) | + +### 1.2 목표 동작 + +``` +1920px 화면에서 디자인 + ↓ +1280px 화면으로 축소 + ↓ +컴포넌트들이 비율에 맞게 재배치 (위치, 크기 모두) + ↓ +레이아웃 깨지지 않음 +``` + +### 1.3 성공 기준 + +- [ ] 1920px에서 디자인한 화면이 1280px에서 정상 표시 +- [ ] 버튼이 화면 밖으로 나가지 않음 +- [ ] 테이블이 화면 너비에 맞게 조정됨 +- [ ] 분할 패널이 비율 유지하며 축소됨 + +--- + +## 2. 현재 시스템 분석 + +### 2.1 렌더링 흐름 (현재) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 1. API 호출 │ +│ screenApi.getLayoutV2(screenId) │ +│ → screen_layouts_v2.layout_data (JSONB) │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 2. 데이터 변환 │ +│ convertV2ToLegacy(v2Response) │ +│ → components 배열 (position, size 포함) │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 3. 스케일 계산 (page.tsx 라인 395-460) │ +│ const designWidth = layout.screenResolution.width || 1200│ +│ const newScale = containerWidth / designWidth │ +│ → 전체 화면을 scale()로 축소 │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 4. 컴포넌트 렌더링 (RealtimePreviewDynamic.tsx 라인 524-536) │ +│ left: `${position.x}px` ← 픽셀 고정 │ +│ top: `${position.y}px` ← 픽셀 고정 │ +│ position: absolute ← 절대 위치 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 현재 방식의 문제점 + +**현재**: `transform: scale()` 방식 +```tsx +// page.tsx 라인 515-520 +
+``` + +| 문제 | 설명 | +|------|------| +| **축소만 됨** | 레이아웃 재배치 없음 | +| **폰트 작아짐** | 전체 scale로 폰트도 축소 | +| **클릭 영역 오차** | scale 적용 시 클릭 위치 계산 오류 가능 | +| **진정한 반응형 아님** | 비율만 유지, 레이아웃 최적화 없음 | + +### 2.3 position.x, position.y 사용 위치 + +| 파일 | 라인 | 용도 | +|------|------|------| +| `RealtimePreviewDynamic.tsx` | 524-526 | 컴포넌트 위치 스타일 | +| `AutoRegisteringComponentRenderer.ts` | 42-43 | 공통 컴포넌트 스타일 | +| `page.tsx` | 744-745 | 자식 컴포넌트 상대 위치 | +| `ScreenDesigner.tsx` | 2890-2894 | 드래그 앤 드롭 위치 | +| `ScreenModal.tsx` | 620-621 | 모달 내 오프셋 조정 | + +--- + +## 3. 구현 방식: 퍼센트 기반 배치 + +### 3.1 핵심 아이디어 + +``` +픽셀 좌표 (1920px 기준) + ↓ +퍼센트로 변환 + ↓ +화면 크기에 관계없이 비율 유지 +``` + +**예시**: +``` +버튼 위치: x=1753px (1920px 기준) + ↓ +퍼센트: 1753 / 1920 = 91.3% + ↓ +1280px 화면: 1280 * 0.913 = 1168px + ↓ +버튼이 화면 안에 정상 표시 +``` + +### 3.2 변환 공식 + +```typescript +// 픽셀 → 퍼센트 변환 +const DESIGN_WIDTH = 1920; + +function toPercent(pixelX: number): string { + return `${(pixelX / DESIGN_WIDTH) * 100}%`; +} + +// 사용 +left: toPercent(position.x) // "91.3%" +width: toPercent(size.width) // "8.2%" +``` + +### 3.3 Y축 처리 + +Y축은 두 가지 옵션: + +**옵션 A: Y축도 퍼센트 (권장)** +```typescript +const DESIGN_HEIGHT = 1080; +top: `${(position.y / DESIGN_HEIGHT) * 100}%` +``` + +**옵션 B: Y축은 픽셀 유지** +```typescript +top: `${position.y}px` // 세로는 스크롤로 해결 +``` + +**결정: 옵션 B (Y축 픽셀 유지)** +- 이유: 세로 스크롤은 자연스러움 +- 가로만 반응형이면 PC 환경에서 충분 + +--- + +## 4. 구현 상세 + +### 4.1 수정 파일 목록 + +| 파일 | 수정 내용 | +|------|-----------| +| `RealtimePreviewDynamic.tsx` | left, width를 퍼센트로 변경 | +| `AutoRegisteringComponentRenderer.ts` | left, width를 퍼센트로 변경 | +| `page.tsx` | scale 제거, 컨테이너 width: 100% | + +### 4.2 RealtimePreviewDynamic.tsx 수정 + +**현재 (라인 524-530)**: +```tsx +const baseStyle = { + left: `${adjustedPositionX}px`, + top: `${position.y}px`, + width: displayWidth, + height: displayHeight, + zIndex: component.type === "layout" ? 1 : position.z || 2, +}; +``` + +**변경 후**: +```tsx +const DESIGN_WIDTH = 1920; + +const baseStyle = { + left: `${(adjustedPositionX / DESIGN_WIDTH) * 100}%`, // 퍼센트 + top: `${position.y}px`, // Y축은 픽셀 유지 + width: `${(parseFloat(displayWidth) / DESIGN_WIDTH) * 100}%`, // 퍼센트 + height: displayHeight, // 높이는 픽셀 유지 + zIndex: component.type === "layout" ? 1 : position.z || 2, +}; +``` + +### 4.3 AutoRegisteringComponentRenderer.ts 수정 + +**현재 (라인 40-48)**: +```tsx +const baseStyle: React.CSSProperties = { + position: "absolute", + left: `${component.position?.x || 0}px`, + top: `${component.position?.y || 0}px`, + width: `${component.size?.width || 200}px`, + height: `${component.size?.height || 36}px`, + zIndex: component.position?.z || 1, +}; +``` + +**변경 후**: +```tsx +const DESIGN_WIDTH = 1920; + +const baseStyle: React.CSSProperties = { + position: "absolute", + left: `${((component.position?.x || 0) / DESIGN_WIDTH) * 100}%`, // 퍼센트 + top: `${component.position?.y || 0}px`, // Y축은 픽셀 유지 + width: `${((component.size?.width || 200) / DESIGN_WIDTH) * 100}%`, // 퍼센트 + height: `${component.size?.height || 36}px`, // 높이는 픽셀 유지 + zIndex: component.position?.z || 1, +}; +``` + +### 4.4 page.tsx 수정 + +**현재 (라인 515-528)**: +```tsx +
+``` + +**변경 후**: +```tsx +
+``` + +### 4.5 공통 상수 파일 생성 + +```typescript +// frontend/lib/constants/responsive.ts + +export const RESPONSIVE_CONFIG = { + DESIGN_WIDTH: 1920, + DESIGN_HEIGHT: 1080, + MIN_WIDTH: 1280, + MAX_WIDTH: 1920, +} as const; + +export function toPercentX(pixelX: number): string { + return `${(pixelX / RESPONSIVE_CONFIG.DESIGN_WIDTH) * 100}%`; +} + +export function toPercentWidth(pixelWidth: number): string { + return `${(pixelWidth / RESPONSIVE_CONFIG.DESIGN_WIDTH) * 100}%`; +} +``` + +--- + +## 5. 가상 시뮬레이션 + +### 5.1 시뮬레이션 시나리오 + +**테스트 화면**: screen_id = 68 (수주 목록) +```json +{ + "components": [ + { + "id": "comp_1895", + "url": "v2-table-list", + "position": { "x": 8, "y": 128 }, + "size": { "width": 1904, "height": 600 } + }, + { + "id": "comp_1896", + "url": "v2-button-primary", + "position": { "x": 1753, "y": 88 }, + "size": { "width": 158, "height": 40 } + }, + { + "id": "comp_1897", + "url": "v2-button-primary", + "position": { "x": 1594, "y": 88 }, + "size": { "width": 158, "height": 40 } + }, + { + "id": "comp_1898", + "url": "v2-button-primary", + "position": { "x": 1436, "y": 88 }, + "size": { "width": 158, "height": 40 } + } + ] +} +``` + +### 5.2 현재 방식 시뮬레이션 + +**1920px 화면**: +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ [분리] [저장] [수정] [삭제] │ +│ 1277 1436 1594 1753 │ +├────────────────────────────────────────────────────────────────────────┤ +│ x=8 x=1904 │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ 테이블 (width: 1904px) │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────────────────┘ +✅ 정상 표시 +``` + +**1280px 화면 (현재 scale 방식)**: +``` +┌─────────────────────────────────────────────┐ +│ scale(0.67) 적용 │ +│ ┌─────────────────────────────────────────┐ │ +│ │ [분리][저][수][삭] │ │ ← 전체 축소, 폰트 작아짐 +│ ├─────────────────────────────────────────┤ │ +│ │ ┌─────────────────────────────────────┐ │ │ +│ │ │ 테이블 (축소됨) │ │ │ +│ │ └─────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ (여백 발생) │ +└─────────────────────────────────────────────┘ +⚠️ 작동하지만 폰트/여백 문제 +``` + +### 5.3 퍼센트 방식 시뮬레이션 + +**변환 계산**: +``` +테이블: + x: 8px → 8/1920 = 0.42% + width: 1904px → 1904/1920 = 99.17% + +삭제 버튼: + x: 1753px → 1753/1920 = 91.30% + width: 158px → 158/1920 = 8.23% + +수정 버튼: + x: 1594px → 1594/1920 = 83.02% + width: 158px → 158/1920 = 8.23% + +저장 버튼: + x: 1436px → 1436/1920 = 74.79% + width: 158px → 158/1920 = 8.23% + +분리 버튼: + x: 1277px → 1277/1920 = 66.51% + width: 158px → 158/1920 = 8.23% +``` + +**1920px 화면**: +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ [분리] [저장] [수정] [삭제] │ +│ 66.5% 74.8% 83.0% 91.3% │ +├────────────────────────────────────────────────────────────────────────┤ +│ 0.42% 99.6% │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ 테이블 (width: 99.17%) │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────────────────┘ +✅ 정상 표시 (1920px와 동일) +``` + +**1280px 화면 (퍼센트 방식)**: +``` +┌─────────────────────────────────────────────┐ +│ [분리][저장][수정][삭제] │ +│ 66.5% 74.8% 83.0% 91.3% │ +│ = 851 957 1063 1169 │ ← 화면 안에 표시! +├─────────────────────────────────────────────┤ +│ 0.42% 99.6% │ +│ = 5px = 1275 │ +│ ┌─────────────────────────────────────────┐ │ +│ │ 테이블 (width: 99.17%) │ │ ← 화면 너비에 맞게 조정 +│ │ = 1280 * 0.9917 = 1269px │ │ +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ +✅ 비율 유지, 화면 안에 표시, 폰트 크기 유지 +``` + +### 5.4 버튼 간격 검증 + +**1920px**: +``` +분리: 1277px, 너비 158px → 끝: 1435px +저장: 1436px (간격: 1px) +수정: 1594px (간격: 1px) +삭제: 1753px (간격: 1px) +``` + +**1280px (퍼센트 변환 후)**: +``` +분리: 1280 * 0.665 = 851px, 너비 1280 * 0.082 = 105px → 끝: 956px +저장: 1280 * 0.748 = 957px (간격: 1px) ✅ +수정: 1280 * 0.830 = 1063px (간격: 1px) ✅ +삭제: 1280 * 0.913 = 1169px (간격: 1px) ✅ +``` + +**결론**: 버튼 간격 비율도 유지됨 + +--- + +## 6. 엣지 케이스 검증 + +### 6.1 분할 패널 (SplitPanelLayout) + +**현재 동작**: +- 좌측 패널: 60% 너비 +- 우측 패널: 40% 너비 +- **이미 퍼센트 기반!** + +**시뮬레이션**: +``` +1920px: 좌측 1152px, 우측 768px +1280px: 좌측 768px, 우측 512px +✅ 자동으로 비율 유지됨 +``` + +**분할 패널 내부 컴포넌트**: +- 문제: 내부 컴포넌트가 픽셀 고정이면 깨짐 +- 해결: 분할 패널 내부도 퍼센트 적용 필요 + +### 6.2 테이블 컴포넌트 (TableList) + +**현재**: +- 테이블 자체는 컨테이너 너비 100% 사용 +- 컬럼 너비는 내부적으로 조정 + +**시뮬레이션**: +``` +1920px: 테이블 컨테이너 width: 99.17% = 1904px +1280px: 테이블 컨테이너 width: 99.17% = 1269px +✅ 테이블이 자동으로 조정됨 +``` + +### 6.3 자식 컴포넌트 상대 위치 + +**현재 코드 (page.tsx 라인 744-745)**: +```typescript +const relativeChildComponent = { + position: { + x: child.position.x - component.position.x, + y: child.position.y - component.position.y, + }, +}; +``` + +**문제**: 상대 좌표도 픽셀 기반 + +**해결**: 부모 기준 퍼센트로 변환 +```typescript +const relativeChildComponent = { + position: { + // 부모 너비 기준 퍼센트 + xPercent: ((child.position.x - component.position.x) / component.size.width) * 100, + y: child.position.y - component.position.y, + }, +}; +``` + +### 6.4 드래그 앤 드롭 (디자인 모드) + +**ScreenDesigner.tsx**: +- 드롭 위치는 여전히 픽셀로 저장 +- 렌더링 시에만 퍼센트로 변환 +- **저장 방식 변경 없음!** + +**시뮬레이션**: +``` +1. 디자이너가 1920px 화면에서 버튼 드롭 +2. position: { x: 1753, y: 88 } 저장 (픽셀) +3. 렌더링 시 91.3%로 변환 +4. 1280px 화면에서도 정상 표시 +✅ 디자인 모드 호환 +``` + +### 6.5 모달 내 화면 + +**ScreenModal.tsx (라인 620-621)**: +```typescript +x: parseFloat(component.position?.x?.toString() || "0") - offsetX, +y: parseFloat(component.position?.y?.toString() || "0") - offsetY, +``` + +**문제**: 오프셋 계산이 픽셀 기반 + +**해결**: 모달 컨테이너도 퍼센트 기반으로 변경 +```typescript +// 모달 컨테이너 너비 기준으로 퍼센트 계산 +const modalWidth = containerRef.current?.clientWidth || DESIGN_WIDTH; +const xPercent = ((position.x - offsetX) / DESIGN_WIDTH) * 100; +``` + +--- + +## 7. 잠재적 문제 및 해결책 + +### 7.1 최소 너비 문제 + +**문제**: 버튼이 너무 작아질 수 있음 +``` +158px 버튼 → 1280px 화면에서 105px +→ 텍스트가 잘릴 수 있음 +``` + +**해결**: min-width 설정 +```css +min-width: 80px; +``` + +### 7.2 겹침 문제 + +**문제**: 화면이 작아지면 컴포넌트가 겹칠 수 있음 + +**시뮬레이션**: +``` +1920px: 버튼 4개가 간격 1px로 배치 +1280px: 버튼 4개가 간격 1px로 배치 (비율 유지) +✅ 겹치지 않음 (간격도 비율로 축소) +``` + +### 7.3 폰트 크기 + +**현재**: 폰트는 px 고정 +**변경 후**: 폰트 크기 유지 (scale이 아니므로) + +**결과**: 폰트 크기는 그대로, 레이아웃만 비율 조정 +✅ 가독성 유지 + +### 7.4 height 처리 + +**결정**: height는 픽셀 유지 +- 이유: 세로 스크롤은 자연스러움 +- 세로 반응형은 불필요 (PC 환경) + +--- + +## 8. 호환성 검증 + +### 8.1 기존 화면 호환 + +| 항목 | 호환 여부 | 이유 | +|------|----------|------| +| 일반 버튼 | ✅ | 퍼센트로 변환, 위치 유지 | +| 테이블 | ✅ | 컨테이너 비율 유지 | +| 분할 패널 | ✅ | 이미 퍼센트 기반 | +| 탭 레이아웃 | ✅ | 컨테이너 비율 유지 | +| 그리드 레이아웃 | ✅ | 내부는 기존 방식 | +| 인풋 필드 | ✅ | 컨테이너 비율 유지 | + +### 8.2 디자인 모드 호환 + +| 항목 | 호환 여부 | 이유 | +|------|----------|------| +| 드래그 앤 드롭 | ✅ | 저장은 픽셀, 렌더링만 퍼센트 | +| 리사이즈 | ✅ | 저장은 픽셀, 렌더링만 퍼센트 | +| 그리드 스냅 | ✅ | 스냅은 픽셀 기준 유지 | +| 미리보기 | ✅ | 렌더링 동일 방식 | + +### 8.3 API 호환 + +| 항목 | 호환 여부 | 이유 | +|------|----------|------| +| DB 저장 | ✅ | 구조 변경 없음 (픽셀 저장) | +| API 응답 | ✅ | 구조 변경 없음 | +| V2 변환 | ✅ | 변환 로직 변경 없음 | + +--- + +## 9. 구현 순서 + +### Phase 1: 공통 유틸리티 생성 (30분) + +```typescript +// frontend/lib/constants/responsive.ts +export const RESPONSIVE_CONFIG = { + DESIGN_WIDTH: 1920, +} as const; + +export function toPercentX(pixelX: number): string { + return `${(pixelX / RESPONSIVE_CONFIG.DESIGN_WIDTH) * 100}%`; +} + +export function toPercentWidth(pixelWidth: number): string { + return `${(pixelWidth / RESPONSIVE_CONFIG.DESIGN_WIDTH) * 100}%`; +} +``` + +### Phase 2: RealtimePreviewDynamic.tsx 수정 (1시간) + +1. import 추가 +2. baseStyle의 left, width를 퍼센트로 변경 +3. 분할 패널 위 버튼 조정 로직도 퍼센트 적용 + +### Phase 3: AutoRegisteringComponentRenderer.ts 수정 (30분) + +1. import 추가 +2. getComponentStyle()의 left, width를 퍼센트로 변경 + +### Phase 4: page.tsx 수정 (1시간) + +1. scale 로직 제거 또는 수정 +2. 컨테이너 width: 100%로 변경 +3. 자식 컴포넌트 상대 위치 계산 수정 + +### Phase 5: 테스트 (1시간) + +1. 1920px 화면에서 기존 화면 정상 동작 확인 +2. 1280px 화면으로 축소 테스트 +3. 분할 패널 화면 테스트 +4. 디자인 모드 테스트 + +--- + +## 10. 최종 체크리스트 + +### 구현 전 + +- [ ] 현재 동작하는 화면 스크린샷 캡처 (비교용) +- [ ] 테스트 화면 목록 선정 + +### 구현 중 + +- [ ] responsive.ts 생성 +- [ ] RealtimePreviewDynamic.tsx 수정 +- [ ] AutoRegisteringComponentRenderer.ts 수정 +- [ ] page.tsx 수정 + +### 구현 후 + +- [ ] 1920px 화면 테스트 +- [ ] 1440px 화면 테스트 +- [ ] 1280px 화면 테스트 +- [ ] 분할 패널 화면 테스트 +- [ ] 디자인 모드 테스트 +- [ ] 모달 내 화면 테스트 + +--- + +## 11. 예상 소요 시간 + +| 작업 | 시간 | +|------|------| +| 유틸리티 생성 | 30분 | +| RealtimePreviewDynamic.tsx | 1시간 | +| AutoRegisteringComponentRenderer.ts | 30분 | +| page.tsx | 1시간 | +| 테스트 | 1시간 | +| **합계** | **4시간** | + +--- + +## 12. 결론 + +**퍼센트 기반 배치**가 PC 반응형의 가장 확실한 해결책입니다. + +| 항목 | scale 방식 | 퍼센트 방식 | +|------|-----------|------------| +| 폰트 크기 | 축소됨 | **유지** | +| 레이아웃 비율 | 유지 | **유지** | +| 클릭 영역 | 오차 가능 | **정확** | +| 구현 복잡도 | 낮음 | **중간** | +| 진정한 반응형 | ❌ | **✅** | + +**DB 변경 없이, 렌더링 로직만 수정**하여 완벽한 PC 반응형을 구현할 수 있습니다. diff --git a/docs/DDD1542/본서버_개발서버_마이그레이션_가이드.md b/docs/DDD1542/본서버_개발서버_마이그레이션_가이드.md new file mode 100644 index 00000000..e8f7b39e --- /dev/null +++ b/docs/DDD1542/본서버_개발서버_마이그레이션_가이드.md @@ -0,0 +1,325 @@ +# 본서버 → 개발서버 마이그레이션 가이드 (공용) + +> **이 문서는 다음 AI 에이전트가 마이그레이션 작업을 이어받을 때 참고하는 핵심 가이드입니다.** + +--- + +## 빠른 시작 + +### 마이그레이션 방향 (절대 잊지 말 것) + +``` +본서버 (Production) → 개발서버 (Development) +211.115.91.141:11134 39.117.244.52:11132 +screen_layouts (V1) screen_layouts_v2 (V2) +``` + +**반대로 하면 안 됨!** 개발서버 완성 후 → 본서버로 배포 예정 + +### DB 접속 정보 + +```bash +# 본서버 (Production) +docker exec pms-backend-mac node -e ' +const { Pool } = require("pg"); +const pool = new Pool({ + connectionString: "postgresql://postgres:vexplor0909!!@211.115.91.141:11134/plm?sslmode=disable", + ssl: false +}); +// 쿼리 실행 +' + +# 개발서버 (Development) +docker exec pms-backend-mac node -e ' +const { Pool } = require("pg"); +const pool = new Pool({ + connectionString: "postgresql://postgres:ph0909!!@39.117.244.52:11132/plm?sslmode=disable", + ssl: false +}); +// 쿼리 실행 +' +``` + +--- + +## 핵심 개념 + +### V1 vs V2 구조 차이 + +| 구분 | V1 (본서버) | V2 (개발서버) | +|------|-------------|---------------| +| 테이블 | screen_layouts | screen_layouts_v2 | +| 레코드 | 컴포넌트별 1개 | 화면당 1개 | +| 설정 저장 | properties JSONB | layout_data.components[].overrides | +| 채번/카테고리 | menu_objid 기반 | table_name + column_name 기반 | +| 컴포넌트 참조 | component_type 문자열 | url 경로 (@/lib/registry/...) | + +### 데이터 타입 관리 (V2) + +``` +table_type_columns (input_type) +├── 'category' → category_values 테이블 +├── 'numbering' → numbering_rules 테이블 (detail_settings.numberingRuleId) +├── 'entity' → 엔티티 검색 +└── 'text', 'number', 'date', etc. +``` + +### 컴포넌트 URL 매핑 + +```typescript +const V1_TO_V2_MAPPING = { + 'table-list': '@/lib/registry/components/v2-table-list', + 'button-primary': '@/lib/registry/components/v2-button-primary', + 'text-input': '@/lib/registry/components/v2-text-input', + 'select-basic': '@/lib/registry/components/v2-select', + 'date-input': '@/lib/registry/components/v2-date-input', + 'entity-search-input': '@/lib/registry/components/v2-entity-search', + 'category-manager': '@/lib/registry/components/v2-category-manager', + 'numbering-rule': '@/lib/registry/components/v2-numbering-rule', + 'tabs-widget': '@/lib/registry/components/v2-tabs-widget', + 'textarea-basic': '@/lib/registry/components/v2-textarea', +}; +``` + +### 모달 처리 방식 변경 + +- **V1**: 별도 화면(screen_id)으로 모달 관리 +- **V2**: 부모 화면에 overlay/dialog 컴포넌트로 통합 + +--- + +## 마이그레이션 대상 메뉴 현황 + +### 품질관리 (우선순위 1) + +| 본서버 코드 | 화면명 | 상태 | 비고 | +|-------------|--------|------|------| +| COMPANY_7_126 | 검사정보 관리 | ✅ V2 존재 | 컴포넌트 검증 필요 | +| COMPANY_7_127 | 품목옵션 설정 | ✅ V2 존재 | v2-category-manager 사용중 | +| COMPANY_7_138 | 카테고리 설정 | ❌ 누락 | table_name 기반으로 변경 | +| COMPANY_7_139 | 코드 설정 | ❌ 누락 | table_name 기반으로 변경 | +| COMPANY_7_142 | 검사장비 관리 | ❌ 누락 | 모달 통합 필요 | +| COMPANY_7_143 | 검사장비 등록모달 | ❌ 누락 | → 142에 통합 | +| COMPANY_7_144 | 불량기준 정보 | ❌ 누락 | 모달 통합 필요 | +| COMPANY_7_145 | 불량기준 등록모달 | ❌ 누락 | → 144에 통합 | + +### 다음 마이그레이션 대상 (미정) + +- [ ] 물류관리 +- [ ] 생산관리 +- [ ] 영업관리 +- [ ] 기타 메뉴들 + +--- + +## 마이그레이션 작업 절차 + +### Step 1: 분석 + +```sql +-- 본서버 특정 메뉴 화면 목록 조회 +SELECT + sd.screen_id, sd.screen_code, sd.screen_name, sd.table_name, + COUNT(sl.layout_id) as component_count +FROM screen_definitions sd +LEFT JOIN screen_layouts sl ON sd.screen_id = sl.screen_id +WHERE sd.screen_name LIKE '%[메뉴명]%' + AND sd.company_code = 'COMPANY_7' +GROUP BY sd.screen_id, sd.screen_code, sd.screen_name, sd.table_name; + +-- 개발서버 V2 현황 확인 +SELECT + sd.screen_id, sd.screen_code, sd.screen_name, + sv2.layout_id IS NOT NULL as has_v2 +FROM screen_definitions sd +LEFT JOIN screen_layouts_v2 sv2 ON sd.screen_id = sv2.screen_id +WHERE sd.company_code = 'COMPANY_7'; +``` + +### Step 2: screen_definitions 동기화 + +본서버에만 있는 화면을 개발서버에 추가 + +### Step 3: V1 → V2 레이아웃 변환 + +```typescript +// layout_data 구조 +{ + "version": "2.0", + "components": [ + { + "id": "comp_xxx", + "url": "@/lib/registry/components/v2-table-list", + "position": { "x": 0, "y": 0 }, + "size": { "width": 100, "height": 50 }, + "displayOrder": 0, + "overrides": { + "tableName": "테이블명", + "columns": ["컬럼1", "컬럼2"] + } + } + ] +} +``` + +### Step 4: 카테고리 데이터 확인/생성 + +```sql +-- 테이블의 category 컬럼 확인 +SELECT column_name, column_label +FROM table_type_columns +WHERE table_name = '[테이블명]' + AND input_type = 'category'; + +-- category_values 데이터 확인 +SELECT value_id, value_code, value_label +FROM category_values +WHERE table_name = '[테이블명]' + AND column_name = '[컬럼명]' + AND company_code = 'COMPANY_7'; +``` + +### Step 5: 채번 규칙 확인/생성 + +```sql +-- numbering 컬럼 확인 +SELECT column_name, column_label, detail_settings +FROM table_type_columns +WHERE table_name = '[테이블명]' + AND input_type = 'numbering'; + +-- numbering_rules 데이터 확인 +SELECT rule_id, rule_name, table_name, column_name +FROM numbering_rules +WHERE company_code = 'COMPANY_7'; +``` + +### Step 6: 검증 + +- [ ] 화면 렌더링 확인 +- [ ] 컴포넌트 동작 확인 +- [ ] 저장/수정/삭제 테스트 +- [ ] 카테고리 드롭다운 동작 +- [ ] 채번 규칙 동작 + +--- + +## 핵심 테이블 스키마 + +### screen_layouts_v2 + +```sql +CREATE TABLE screen_layouts_v2 ( + layout_id SERIAL PRIMARY KEY, + screen_id INTEGER NOT NULL, + company_code VARCHAR(20) NOT NULL, + layout_data JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(screen_id, company_code) +); +``` + +### category_values + +```sql +-- 핵심 컬럼 +value_id, table_name, column_name, value_code, value_label, +parent_value_id, depth, path, company_code +``` + +### numbering_rules + numbering_rule_parts + +```sql +-- numbering_rules 핵심 컬럼 +rule_id, rule_name, table_name, column_name, separator, +reset_period, current_sequence, company_code + +-- numbering_rule_parts 핵심 컬럼 +rule_id, part_order, part_type, generation_method, +auto_config, manual_config, company_code +``` + +### table_type_columns + +```sql +-- 핵심 컬럼 +table_name, column_name, input_type, column_label, +detail_settings, company_code +``` + +--- + +## 참고 문서 + +### 필수 읽기 + +1. **[본서버_개발서버_마이그레이션_상세가이드.md](./본서버_개발서버_마이그레이션_상세가이드.md)** - 상세 마이그레이션 절차 +2. **[화면개발_표준_가이드.md](../screen-implementation-guide/화면개발_표준_가이드.md)** - V2 화면 개발 표준 +3. **[SCREEN_DEVELOPMENT_STANDARD.md](../screen-implementation-guide/SCREEN_DEVELOPMENT_STANDARD.md)** - 영문 표준 가이드 + +### 코드 참조 + +| 파일 | 설명 | +|------|------| +| `backend-node/src/services/categoryTreeService.ts` | 카테고리 관리 서비스 | +| `backend-node/src/services/numberingRuleService.ts` | 채번 규칙 서비스 | +| `frontend/lib/registry/components/v2-category-manager/` | V2 카테고리 컴포넌트 | +| `frontend/lib/registry/components/v2-numbering-rule/` | V2 채번 컴포넌트 | + +### 관련 문서 + +- `docs/V2_컴포넌트_분석_가이드.md` +- `docs/V2_컴포넌트_연동_가이드.md` +- `docs/DDD1542/COMPONENT_LAYOUT_V2_ARCHITECTURE.md` +- `docs/DDD1542/COMPONENT_MIGRATION_PLAN.md` + +--- + +## 주의사항 + +### 절대 하지 말 것 + +1. **개발서버 → 본서버 마이그레이션** (반대 방향) +2. **본서버 데이터 직접 수정** (SELECT만 허용) +3. **company_code 누락** (멀티테넌시 필수) + +### 반드시 할 것 + +1. 마이그레이션 전 **개발서버 백업** +2. 컴포넌트 변환 시 **V2 컴포넌트만 사용** (v2- prefix) +3. 모달 화면은 **부모 화면에 통합** +4. 카테고리/채번은 **table_name + column_name 기반** + +--- + +## 마이그레이션 진행 로그 + +| 날짜 | 메뉴 | 담당 | 상태 | 비고 | +|------|------|------|------|------| +| 2026-02-03 | 품질관리 | DDD1542 | 분석 완료 | 마이그레이션 대기 | +| | 물류관리 | - | 미시작 | | +| | 생산관리 | - | 미시작 | | +| | 영업관리 | - | 미시작 | | + +--- + +## 다음 작업 요청 예시 + +다음 AI에게 요청할 때 이렇게 말하면 됩니다: + +``` +"본서버_개발서버_마이그레이션_가이드.md 읽고 품질관리 메뉴 마이그레이션 진행해줘" + +"본서버_개발서버_마이그레이션_가이드.md 참고해서 물류관리 메뉴 분석해줘" + +"본서버_개발서버_마이그레이션_상세가이드.md 보고 COMPANY_7_142 화면 V2로 변환해줘" +``` + +--- + +## 변경 이력 + +| 날짜 | 작성자 | 내용 | +|------|--------|------| +| 2026-02-03 | DDD1542 | 초안 작성 | diff --git a/docs/DDD1542/본서버_개발서버_마이그레이션_상세가이드.md b/docs/DDD1542/본서버_개발서버_마이그레이션_상세가이드.md new file mode 100644 index 00000000..42ce37f1 --- /dev/null +++ b/docs/DDD1542/본서버_개발서버_마이그레이션_상세가이드.md @@ -0,0 +1,553 @@ +# 본서버 → 개발서버 마이그레이션 가이드 + +## 개요 + +본 문서는 **본서버(Production)**의 `screen_layouts` (V1) 데이터를 **개발서버(Development)**의 `screen_layouts_v2` 시스템으로 마이그레이션하는 절차를 정의합니다. + +### 마이그레이션 방향 +``` +본서버 (Production) 개발서버 (Development) +┌─────────────────────┐ ┌─────────────────────┐ +│ screen_layouts (V1) │ → │ screen_layouts_v2 │ +│ - 컴포넌트별 레코드 │ │ - 화면당 1개 레코드 │ +│ - properties JSONB │ │ - layout_data JSONB │ +└─────────────────────┘ └─────────────────────┘ +``` + +### 최종 목표 +개발서버에서 완성 후 **개발서버 → 본서버**로 배포 + +--- + +## 1. V1 vs V2 구조 차이 + +### 1.1 screen_layouts (V1) - 본서버 + +```sql +-- 컴포넌트별 1개 레코드 +CREATE TABLE screen_layouts ( + layout_id SERIAL PRIMARY KEY, + screen_id INTEGER, + component_type VARCHAR(50), + component_id VARCHAR(100), + properties JSONB, -- 모든 설정값 포함 + ... +); +``` + +**특징:** +- 화면당 N개 레코드 (컴포넌트 수만큼) +- `properties`에 모든 설정 저장 (defaults + overrides 구분 없음) +- `menu_objid` 기반 채번/카테고리 관리 + +### 1.2 screen_layouts_v2 - 개발서버 + +```sql +-- 화면당 1개 레코드 +CREATE TABLE screen_layouts_v2 ( + layout_id SERIAL PRIMARY KEY, + screen_id INTEGER NOT NULL, + company_code VARCHAR(20) NOT NULL, + layout_data JSONB NOT NULL DEFAULT '{}'::jsonb, + UNIQUE(screen_id, company_code) +); +``` + +**layout_data 구조:** +```json +{ + "version": "2.0", + "components": [ + { + "id": "comp_xxx", + "url": "@/lib/registry/components/v2-table-list", + "position": { "x": 0, "y": 0 }, + "size": { "width": 100, "height": 50 }, + "displayOrder": 0, + "overrides": { + "tableName": "inspection_standard", + "columns": ["id", "name"] + } + } + ], + "updatedAt": "2026-02-03T12:00:00Z" +} +``` + +**특징:** +- 화면당 1개 레코드 +- `url` + `overrides` 방식 (Zod 스키마 defaults와 병합) +- `table_name + column_name` 기반 채번/카테고리 관리 (전역) + +--- + +## 2. 데이터 타입 관리 구조 (V2) + +### 2.1 핵심 테이블 관계 + +``` +table_type_columns (컬럼 타입 정의) +├── input_type = 'category' → category_values +├── input_type = 'numbering' → numbering_rules +└── input_type = 'text', 'date', 'number', etc. +``` + +### 2.2 table_type_columns + +각 테이블의 컬럼별 입력 타입을 정의합니다. + +```sql +SELECT table_name, column_name, input_type, column_label +FROM table_type_columns +WHERE input_type IN ('category', 'numbering'); +``` + +**주요 input_type:** +| input_type | 설명 | 연결 테이블 | +|------------|------|-------------| +| text | 텍스트 입력 | - | +| number | 숫자 입력 | - | +| date | 날짜 입력 | - | +| category | 카테고리 드롭다운 | category_values | +| numbering | 자동 채번 | numbering_rules | +| entity | 엔티티 검색 | - | + +### 2.3 category_values (카테고리 관리) + +```sql +-- 카테고리 값 조회 +SELECT value_id, table_name, column_name, value_code, value_label, + parent_value_id, depth, company_code +FROM category_values +WHERE table_name = 'inspection_standard' + AND column_name = 'inspection_method' + AND company_code = 'COMPANY_7'; +``` + +**V1 vs V2 차이:** +| 구분 | V1 | V2 | +|------|----|----| +| 키 | menu_objid | table_name + column_name | +| 범위 | 화면별 | 전역 (테이블.컬럼별) | +| 계층 | 단일 | 3단계 (대/중/소분류) | + +### 2.4 numbering_rules (채번 규칙) + +```sql +-- 채번 규칙 조회 +SELECT rule_id, rule_name, table_name, column_name, separator, + reset_period, current_sequence, company_code +FROM numbering_rules +WHERE company_code = 'COMPANY_7'; +``` + +**연결 방식:** +``` +table_type_columns.detail_settings = '{"numberingRuleId": "rule-xxx"}' + ↓ + numbering_rules.rule_id = "rule-xxx" +``` + +--- + +## 3. 컴포넌트 매핑 + +### 3.1 기본 컴포넌트 매핑 + +| V1 (본서버) | V2 (개발서버) | 비고 | +|-------------|---------------|------| +| table-list | v2-table-list | 테이블 목록 | +| button-primary | v2-button-primary | 버튼 | +| text-input | v2-text-input | 텍스트 입력 | +| select-basic | v2-select | 드롭다운 | +| date-input | v2-date-input | 날짜 입력 | +| entity-search-input | v2-entity-search | 엔티티 검색 | +| tabs-widget | v2-tabs-widget | 탭 | + +### 3.2 특수 컴포넌트 매핑 + +| V1 (본서버) | V2 (개발서버) | 마이그레이션 방식 | +|-------------|---------------|-------------------| +| category-manager | v2-category-manager | table_name 기반으로 변경 | +| numbering-rule | v2-numbering-rule | table_name 기반으로 변경 | +| 모달 화면 | overlay 통합 | 부모 화면에 통합 | + +### 3.3 모달 처리 방식 변경 + +**V1 (본서버):** +``` +화면 A (screen_id: 142) - 검사장비관리 + └── 버튼 클릭 → 화면 B (screen_id: 143) - 검사장비 등록모달 +``` + +**V2 (개발서버):** +``` +화면 A (screen_id: 142) - 검사장비관리 + └── v2-dialog-form 컴포넌트로 모달 통합 +``` + +--- + +## 4. 마이그레이션 절차 + +### 4.1 사전 분석 + +```sql +-- 1. 본서버 화면 목록 확인 +SELECT sd.screen_id, sd.screen_code, sd.screen_name, sd.table_name, + COUNT(sl.layout_id) as component_count +FROM screen_definitions sd +LEFT JOIN screen_layouts sl ON sd.screen_id = sl.screen_id +WHERE sd.screen_code LIKE 'COMPANY_7_%' + AND sd.screen_name LIKE '%품질%' +GROUP BY sd.screen_id, sd.screen_code, sd.screen_name, sd.table_name; + +-- 2. 개발서버 V2 화면 현황 확인 +SELECT sd.screen_id, sd.screen_code, sd.screen_name, + sv2.layout_data IS NOT NULL as has_v2_layout +FROM screen_definitions sd +LEFT JOIN screen_layouts_v2 sv2 ON sd.screen_id = sv2.screen_id +WHERE sd.company_code = 'COMPANY_7'; +``` + +### 4.2 Step 1: screen_definitions 동기화 + +```sql +-- 본서버에만 있는 화면을 개발서버에 추가 +INSERT INTO screen_definitions (screen_code, screen_name, table_name, company_code, ...) +SELECT screen_code, screen_name, table_name, company_code, ... +FROM [본서버].screen_definitions +WHERE screen_code NOT IN (SELECT screen_code FROM screen_definitions); +``` + +### 4.3 Step 2: V1 → V2 레이아웃 변환 + +```typescript +// 변환 로직 (pseudo-code) +async function convertV1toV2(screenId: number, companyCode: string) { + // 1. V1 레이아웃 조회 + const v1Layouts = await getV1Layouts(screenId); + + // 2. V2 형식으로 변환 + const v2Layout = { + version: "2.0", + components: v1Layouts.map(v1 => ({ + id: v1.component_id, + url: mapComponentUrl(v1.component_type), + position: { x: v1.position_x, y: v1.position_y }, + size: { width: v1.width, height: v1.height }, + displayOrder: v1.display_order, + overrides: extractOverrides(v1.properties) + })), + updatedAt: new Date().toISOString() + }; + + // 3. V2 테이블에 저장 + await saveV2Layout(screenId, companyCode, v2Layout); +} + +function mapComponentUrl(v1Type: string): string { + const mapping = { + 'table-list': '@/lib/registry/components/v2-table-list', + 'button-primary': '@/lib/registry/components/v2-button-primary', + 'category-manager': '@/lib/registry/components/v2-category-manager', + 'numbering-rule': '@/lib/registry/components/v2-numbering-rule', + // ... 기타 매핑 + }; + return mapping[v1Type] || `@/lib/registry/components/v2-${v1Type}`; +} +``` + +### 4.4 Step 3: 카테고리 데이터 마이그레이션 + +```sql +-- 본서버 카테고리 데이터 → 개발서버 category_values +INSERT INTO category_values ( + table_name, column_name, value_code, value_label, + value_order, parent_value_id, depth, company_code +) +SELECT + -- V1 카테고리 데이터를 table_name + column_name 기반으로 변환 + 'inspection_standard' as table_name, + 'inspection_method' as column_name, + value_code, + value_label, + sort_order, + NULL as parent_value_id, + 1 as depth, + 'COMPANY_7' as company_code +FROM [본서버_카테고리_데이터]; +``` + +### 4.5 Step 4: 채번 규칙 마이그레이션 + +```sql +-- 본서버 채번 규칙 → 개발서버 numbering_rules +INSERT INTO numbering_rules ( + rule_id, rule_name, table_name, column_name, + separator, reset_period, current_sequence, company_code +) +SELECT + rule_id, + rule_name, + 'inspection_standard' as table_name, + 'inspection_code' as column_name, + separator, + reset_period, + 0 as current_sequence, -- 시퀀스 초기화 + 'COMPANY_7' as company_code +FROM [본서버_채번_규칙]; +``` + +### 4.6 Step 5: table_type_columns 설정 + +```sql +-- 카테고리 컬럼 설정 +UPDATE table_type_columns +SET input_type = 'category' +WHERE table_name = 'inspection_standard' + AND column_name = 'inspection_method' + AND company_code = 'COMPANY_7'; + +-- 채번 컬럼 설정 +UPDATE table_type_columns +SET + input_type = 'numbering', + detail_settings = '{"numberingRuleId": "rule-xxx"}' +WHERE table_name = 'inspection_standard' + AND column_name = 'inspection_code' + AND company_code = 'COMPANY_7'; +``` + +--- + +## 5. 품질관리 메뉴 마이그레이션 현황 + +### 5.1 화면 매핑 현황 + +| 본서버 코드 | 화면명 | 테이블 | 개발서버 상태 | 비고 | +|-------------|--------|--------|---------------|------| +| COMPANY_7_126 | 검사정보 관리 | inspection_standard | ✅ V2 존재 | 컴포넌트 수 확인 필요 | +| COMPANY_7_127 | 품목옵션 설정 | - | ✅ V2 존재 | v2-category-manager 사용중 | +| COMPANY_7_138 | 카테고리 설정 | inspection_standard | ❌ 누락 | V2: table_name 기반으로 변경 | +| COMPANY_7_139 | 코드 설정 | inspection_standard | ❌ 누락 | V2: table_name 기반으로 변경 | +| COMPANY_7_142 | 검사장비 관리 | inspection_equipment_mng | ❌ 누락 | 모달 통합 필요 | +| COMPANY_7_143 | 검사장비 등록모달 | inspection_equipment_mng | ❌ 누락 | COMPANY_7_142에 통합 | +| COMPANY_7_144 | 불량기준 정보 | defect_standard_mng | ❌ 누락 | 모달 통합 필요 | +| COMPANY_7_145 | 불량기준 등록모달 | defect_standard_mng | ❌ 누락 | COMPANY_7_144에 통합 | + +### 5.2 카테고리/채번 컬럼 현황 + +**inspection_standard:** +| 컬럼 | input_type | 라벨 | +|------|------------|------| +| inspection_method | category | 검사방법 | +| unit | category | 단위 | +| apply_type | category | 적용구분 | +| inspection_type | category | 유형 | + +**inspection_equipment_mng:** +| 컬럼 | input_type | 라벨 | +|------|------------|------| +| equipment_type | category | 장비유형 | +| installation_location | category | 설치장소 | +| equipment_status | category | 장비상태 | + +**defect_standard_mng:** +| 컬럼 | input_type | 라벨 | +|------|------------|------| +| defect_type | category | 불량유형 | +| severity | category | 심각도 | +| inspection_type | category | 검사유형 | + +--- + +## 6. 자동화 스크립트 + +### 6.1 마이그레이션 실행 스크립트 + +```typescript +// backend-node/src/scripts/migrateV1toV2.ts +import { getPool } from "../database/db"; + +interface MigrationResult { + screenCode: string; + success: boolean; + message: string; + componentCount?: number; +} + +async function migrateScreenToV2( + screenCode: string, + companyCode: string +): Promise { + const pool = getPool(); + + try { + // 1. V1 레이아웃 조회 (본서버에서) + const v1Result = await pool.query(` + SELECT sl.*, sd.table_name, sd.screen_name + FROM screen_layouts sl + JOIN screen_definitions sd ON sl.screen_id = sd.screen_id + WHERE sd.screen_code = $1 + ORDER BY sl.display_order + `, [screenCode]); + + if (v1Result.rows.length === 0) { + return { screenCode, success: false, message: "V1 레이아웃 없음" }; + } + + // 2. V2 형식으로 변환 + const components = v1Result.rows + .filter(row => row.component_type !== '_metadata') + .map(row => ({ + id: row.component_id || `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + url: mapComponentUrl(row.component_type), + position: { x: row.position_x || 0, y: row.position_y || 0 }, + size: { width: row.width || 100, height: row.height || 50 }, + displayOrder: row.display_order || 0, + overrides: extractOverrides(row.properties, row.component_type) + })); + + const layoutData = { + version: "2.0", + components, + migratedFrom: "V1", + migratedAt: new Date().toISOString() + }; + + // 3. 개발서버 V2 테이블에 저장 + const screenId = v1Result.rows[0].screen_id; + + await pool.query(` + INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data) + VALUES ($1, $2, $3) + ON CONFLICT (screen_id, company_code) + DO UPDATE SET layout_data = $3, updated_at = NOW() + `, [screenId, companyCode, JSON.stringify(layoutData)]); + + return { + screenCode, + success: true, + message: "마이그레이션 완료", + componentCount: components.length + }; + } catch (error: any) { + return { screenCode, success: false, message: error.message }; + } +} + +function mapComponentUrl(v1Type: string): string { + const mapping: Record = { + 'table-list': '@/lib/registry/components/v2-table-list', + 'button-primary': '@/lib/registry/components/v2-button-primary', + 'text-input': '@/lib/registry/components/v2-text-input', + 'select-basic': '@/lib/registry/components/v2-select', + 'date-input': '@/lib/registry/components/v2-date-input', + 'entity-search-input': '@/lib/registry/components/v2-entity-search', + 'category-manager': '@/lib/registry/components/v2-category-manager', + 'numbering-rule': '@/lib/registry/components/v2-numbering-rule', + 'tabs-widget': '@/lib/registry/components/v2-tabs-widget', + 'textarea-basic': '@/lib/registry/components/v2-textarea', + }; + return mapping[v1Type] || `@/lib/registry/components/v2-${v1Type}`; +} + +function extractOverrides(properties: any, componentType: string): Record { + if (!properties) return {}; + + // V2 Zod 스키마 defaults와 비교하여 다른 값만 추출 + // (실제 구현 시 각 컴포넌트의 defaultConfig와 비교) + const overrides: Record = {}; + + // 필수 설정만 추출 + if (properties.tableName) overrides.tableName = properties.tableName; + if (properties.columns) overrides.columns = properties.columns; + if (properties.label) overrides.label = properties.label; + if (properties.onClick) overrides.onClick = properties.onClick; + + return overrides; +} +``` + +--- + +## 7. 검증 체크리스트 + +### 7.1 마이그레이션 전 + +- [ ] 본서버 화면 목록 확인 +- [ ] 개발서버 기존 V2 데이터 백업 +- [ ] 컴포넌트 매핑 테이블 검토 +- [ ] 카테고리/채번 데이터 분석 + +### 7.2 마이그레이션 후 + +- [ ] screen_definitions 동기화 확인 +- [ ] screen_layouts_v2 데이터 생성 확인 +- [ ] 컴포넌트 렌더링 테스트 +- [ ] 카테고리 드롭다운 동작 확인 +- [ ] 채번 규칙 동작 확인 +- [ ] 저장/수정/삭제 기능 테스트 + +### 7.3 모달 통합 확인 + +- [ ] 기존 모달 화면 → overlay 통합 완료 +- [ ] 부모-자식 데이터 연동 확인 +- [ ] 모달 열기/닫기 동작 확인 + +--- + +## 8. 롤백 계획 + +마이그레이션 실패 시 롤백 절차: + +```sql +-- 1. V2 레이아웃 롤백 +DELETE FROM screen_layouts_v2 +WHERE screen_id IN ( + SELECT screen_id FROM screen_definitions + WHERE screen_code LIKE 'COMPANY_7_%' +); + +-- 2. 추가된 screen_definitions 롤백 +DELETE FROM screen_definitions +WHERE screen_code IN ('신규_추가된_코드들') + AND company_code = 'COMPANY_7'; + +-- 3. category_values 롤백 +DELETE FROM category_values +WHERE company_code = 'COMPANY_7' + AND created_at > '[마이그레이션_시작_시간]'; + +-- 4. numbering_rules 롤백 +DELETE FROM numbering_rules +WHERE company_code = 'COMPANY_7' + AND created_at > '[마이그레이션_시작_시간]'; +``` + +--- + +## 9. 참고 자료 + +### 관련 코드 파일 + +- **V2 Category Manager**: `frontend/lib/registry/components/v2-category-manager/` +- **V2 Numbering Rule**: `frontend/lib/registry/components/v2-numbering-rule/` +- **Category Service**: `backend-node/src/services/categoryTreeService.ts` +- **Numbering Service**: `backend-node/src/services/numberingRuleService.ts` + +### 관련 문서 + +- [V2 컴포넌트 분석 가이드](../V2_컴포넌트_분석_가이드.md) +- [V2 컴포넌트 연동 가이드](../V2_컴포넌트_연동_가이드.md) +- [화면 개발 표준 가이드](../screen-implementation-guide/SCREEN_DEVELOPMENT_STANDARD.md) +- [컴포넌트 레이아웃 V2 아키텍처](./COMPONENT_LAYOUT_V2_ARCHITECTURE.md) + +--- + +## 변경 이력 + +| 날짜 | 작성자 | 내용 | +|------|--------|------| +| 2026-02-03 | DDD1542 | 초안 작성 | diff --git a/docs/SCREEN_COPY_V2_MIGRATION_PLAN.md b/docs/SCREEN_COPY_V2_MIGRATION_PLAN.md index bcb2a765..c60f1dfb 100644 --- a/docs/SCREEN_COPY_V2_MIGRATION_PLAN.md +++ b/docs/SCREEN_COPY_V2_MIGRATION_PLAN.md @@ -522,4 +522,4 @@ V2 전환 롤백 (필요시): | 2026-01-28 | 시뮬레이션 검증 - updateTabScreenReferences V2 지원 추가 | Claude | | 2026-01-28 | V2 경로 지원 추가 - action/sections 직접 경로 (componentConfig 없이) | Claude | | 2026-01-30 | **실제 코드 구현 완료** - copyScreen(), copyScreens() V2 전환 | Claude | -| 2026-01-30 | **Phase 1 테스트 완료** - 단위/통합 테스트 통과 확인 | Claude | +| 2026-01-30 | **Phase 1 테스트 완료** - 단위/통합 테스트 통과 확인 | Claude | \ No newline at end of file diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 68fa0cb1..066ecc40 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -13,6 +13,7 @@ import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext"; import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; +import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; interface ScreenModalState { isOpen: boolean; @@ -322,12 +323,28 @@ export const ScreenModal: React.FC = ({ className }) => { try { setLoading(true); - // 화면 정보와 레이아웃 데이터 로딩 - const [screenInfo, layoutData] = await Promise.all([ + // 화면 정보와 레이아웃 데이터 로딩 (V2 API 사용으로 기본값 병합) + const [screenInfo, v2LayoutData] = await Promise.all([ screenApi.getScreen(screenId), - screenApi.getLayout(screenId), + screenApi.getLayoutV2(screenId), ]); + // V2 → Legacy 변환 (기본값 병합 포함) + let layoutData: any = null; + if (v2LayoutData && isValidV2Layout(v2LayoutData)) { + layoutData = convertV2ToLegacy(v2LayoutData); + if (layoutData) { + // screenResolution은 V2 레이아웃에서 직접 가져오기 + layoutData.screenResolution = v2LayoutData.screenResolution || layoutData.screenResolution; + } + } + + // V2 레이아웃이 없으면 기존 API로 fallback + if (!layoutData) { + console.log("📦 V2 레이아웃 없음, 기존 API로 fallback"); + layoutData = await screenApi.getLayout(screenId); + } + // 🆕 URL 파라미터 확인 (수정 모드) if (typeof window !== "undefined") { const urlParams = new URLSearchParams(window.location.search); diff --git a/frontend/stagewise.json b/frontend/stagewise.json deleted file mode 100644 index d6cd6af2..00000000 --- a/frontend/stagewise.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "appPort": 9771 -} \ No newline at end of file From d13cd478de03bb99f72c0a30866606564350f07c Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 4 Feb 2026 09:28:16 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EB=B0=8F=20=EB=8F=99=EC=A0=81=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A1=B0=EC=A0=95=20?= =?UTF-8?q?=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 - 파일 미리보기 API에 공개 접근을 허용하여 인증되지 않은 사용자도 이미지 미리보기를 할 수 있도록 수정하였습니다. - ScreenModal 컴포넌트에서 숨겨진 컴포넌트의 동적 y 좌표 조정 로직을 추가하여 사용자 인터페이스의 일관성을 개선하였습니다. - V2Media 및 V2Select 컴포넌트에서 기본값 설정 기능을 추가하여 사용자 경험을 향상시켰습니다. - RepeaterTable 및 SimpleRepeaterTableComponent에서 키 값을 개선하여 렌더링 성능을 최적화하였습니다. - formData의 디버깅 로그를 추가하여 개발 중 상태 확인을 용이하게 하였습니다. --- .../src/controllers/fileController.ts | 5 +- backend-node/src/routes/fileRoutes.ts | 14 +- docs/v2-sales-order-modal-layout.json | 557 ++++++++++++++++++ .../app/(main)/screens/[screenId]/page.tsx | 3 +- frontend/components/common/ScreenModal.tsx | 139 ++++- .../screen/InteractiveScreenViewerDynamic.tsx | 48 +- frontend/components/screen/ScreenDesigner.tsx | 47 +- .../webtype-configs/SelectTypeConfigPanel.tsx | 29 + frontend/components/v2/V2Media.tsx | 452 ++++++++++---- .../v2/config-panels/V2SelectConfigPanel.tsx | 26 + .../lib/registry/DynamicComponentRenderer.tsx | 96 ++- .../modal-repeater-table/RepeaterTable.tsx | 17 +- .../SimpleRepeaterTableComponent.tsx | 17 +- .../ButtonPrimaryComponent.tsx | 18 + .../components/v2-media/V2MediaRenderer.tsx | 29 +- .../components/v2-select/V2SelectRenderer.tsx | 16 +- frontend/lib/utils/buttonActions.ts | 28 + 17 files changed, 1332 insertions(+), 209 deletions(-) create mode 100644 docs/v2-sales-order-modal-layout.json diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index d4e8d0cf..a648a4f9 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -793,8 +793,9 @@ export const previewFile = async ( return; } - // 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외) - if (companyCode !== "*" && fileRecord.company_code !== companyCode) { + // 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 및 공개 접근 제외) + // 공개 접근(req.user가 없는 경우)은 미리보기 허용 (이미지 표시용) + if (companyCode && companyCode !== "*" && fileRecord.company_code !== companyCode) { console.warn("⚠️ 다른 회사 파일 접근 시도:", { userId: req.user?.userId, userCompanyCode: companyCode, diff --git a/backend-node/src/routes/fileRoutes.ts b/backend-node/src/routes/fileRoutes.ts index 64f02d14..4514e37f 100644 --- a/backend-node/src/routes/fileRoutes.ts +++ b/backend-node/src/routes/fileRoutes.ts @@ -24,6 +24,13 @@ const router = Router(); */ router.get("/public/:token", getFileByToken); +/** + * @route GET /api/files/preview/:objid + * @desc 파일 미리보기 (이미지 등) - 공개 접근 허용 + * @access Public + */ +router.get("/preview/:objid", previewFile); + // 모든 파일 API는 인증 필요 router.use(authenticateToken); @@ -64,12 +71,7 @@ router.get("/linked/:tableName/:recordId", getLinkedFiles); */ router.delete("/:objid", deleteFile); -/** - * @route GET /api/files/preview/:objid - * @desc 파일 미리보기 (이미지 등) - * @access Private - */ -router.get("/preview/:objid", previewFile); +// preview 라우트는 상단 공개 접근 구역으로 이동됨 /** * @route GET /api/files/download/:objid diff --git a/docs/v2-sales-order-modal-layout.json b/docs/v2-sales-order-modal-layout.json new file mode 100644 index 00000000..6c8287e0 --- /dev/null +++ b/docs/v2-sales-order-modal-layout.json @@ -0,0 +1,557 @@ +{ + "version": "2.0", + "screenResolution": { + "width": 1400, + "height": 900, + "name": "수주등록 모달", + "category": "modal" + }, + "components": [ + { + "id": "section-options", + "url": "@/lib/registry/components/v2-section-card", + "position": { "x": 20, "y": 20, "z": 1 }, + "size": { "width": 1360, "height": 80 }, + "overrides": { + "componentConfig": { + "title": "", + "showHeader": false, + "padding": "md", + "borderStyle": "solid" + } + }, + "displayOrder": 0 + }, + { + "id": "select-input-method", + "url": "@/lib/registry/components/v2-select", + "position": { "x": 40, "y": 35, "z": 2 }, + "size": { "width": 300, "height": 40 }, + "overrides": { + "label": "입력 방식", + "columnName": "input_method", + "mode": "dropdown", + "source": "static", + "options": [ + { "value": "customer_first", "label": "거래처 우선" }, + { "value": "item_first", "label": "품목 우선" } + ], + "placeholder": "입력 방식 선택" + }, + "displayOrder": 1 + }, + { + "id": "select-sales-type", + "url": "@/lib/registry/components/v2-select", + "position": { "x": 360, "y": 35, "z": 2 }, + "size": { "width": 300, "height": 40 }, + "overrides": { + "label": "판매 유형", + "columnName": "sales_type", + "mode": "dropdown", + "source": "static", + "options": [ + { "value": "domestic", "label": "국내 판매" }, + { "value": "overseas", "label": "해외 판매" } + ], + "placeholder": "판매 유형 선택" + }, + "displayOrder": 2 + }, + { + "id": "select-price-method", + "url": "@/lib/registry/components/v2-select", + "position": { "x": 680, "y": 35, "z": 2 }, + "size": { "width": 250, "height": 40 }, + "overrides": { + "label": "단가 방식", + "columnName": "price_method", + "mode": "dropdown", + "source": "static", + "options": [ + { "value": "standard", "label": "기준 단가" }, + { "value": "contract", "label": "계약 단가" }, + { "value": "custom", "label": "개별 입력" } + ], + "placeholder": "단가 방식" + }, + "displayOrder": 3 + }, + { + "id": "checkbox-price-edit", + "url": "@/lib/registry/components/v2-select", + "position": { "x": 950, "y": 35, "z": 2 }, + "size": { "width": 150, "height": 40 }, + "overrides": { + "label": "단가 수정 허용", + "columnName": "allow_price_edit", + "mode": "check", + "source": "static", + "options": [{ "value": "Y", "label": "허용" }] + }, + "displayOrder": 4 + }, + + { + "id": "section-customer-info", + "url": "@/lib/registry/components/v2-section-card", + "position": { "x": 20, "y": 110, "z": 1 }, + "size": { "width": 1360, "height": 120 }, + "overrides": { + "componentConfig": { + "title": "거래처 정보", + "showHeader": true, + "padding": "md", + "borderStyle": "solid" + }, + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 5 + }, + { + "id": "select-customer", + "url": "@/lib/registry/components/v2-select", + "position": { "x": 40, "y": 155, "z": 3 }, + "size": { "width": 320, "height": 40 }, + "overrides": { + "label": "거래처 *", + "columnName": "partner_id", + "mode": "dropdown", + "source": "entity", + "entityTable": "customer_mng", + "entityValueColumn": "customer_code", + "entityLabelColumn": "customer_name", + "searchable": true, + "placeholder": "거래처명 입력하여 검색", + "required": true, + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 6 + }, + { + "id": "input-manager", + "url": "@/lib/registry/components/v2-input", + "position": { "x": 380, "y": 155, "z": 3 }, + "size": { "width": 240, "height": 40 }, + "overrides": { + "label": "담당자", + "columnName": "manager_name", + "placeholder": "담당자", + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 7 + }, + { + "id": "input-delivery-partner", + "url": "@/lib/registry/components/v2-input", + "position": { "x": 640, "y": 155, "z": 3 }, + "size": { "width": 240, "height": 40 }, + "overrides": { + "label": "납품처", + "columnName": "delivery_partner_id", + "placeholder": "납품처", + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 8 + }, + { + "id": "input-delivery-address", + "url": "@/lib/registry/components/v2-input", + "position": { "x": 900, "y": 155, "z": 3 }, + "size": { "width": 460, "height": 40 }, + "overrides": { + "label": "납품장소", + "columnName": "delivery_address", + "placeholder": "납품장소", + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 9 + }, + + { + "id": "section-item-first", + "url": "@/lib/registry/components/v2-section-card", + "position": { "x": 20, "y": 110, "z": 1 }, + "size": { "width": 1360, "height": 200 }, + "overrides": { + "componentConfig": { + "title": "품목 및 거래처별 수주", + "showHeader": true, + "padding": "md", + "borderStyle": "solid" + }, + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "item_first", + "action": "show" + } + }, + "displayOrder": 10 + }, + + { + "id": "section-items", + "url": "@/lib/registry/components/v2-section-card", + "position": { "x": 20, "y": 240, "z": 1 }, + "size": { "width": 1360, "height": 280 }, + "overrides": { + "componentConfig": { + "title": "추가된 품목", + "showHeader": true, + "padding": "md", + "borderStyle": "solid" + }, + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 11 + }, + { + "id": "btn-item-search", + "url": "@/lib/registry/components/v2-button-primary", + "position": { "x": 1140, "y": 245, "z": 5 }, + "size": { "width": 100, "height": 36 }, + "overrides": { + "label": "품목 검색", + "action": { + "type": "openModal", + "modalType": "itemSelection" + }, + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 12 + }, + { + "id": "btn-shipping-plan", + "url": "@/lib/registry/components/v2-button-primary", + "position": { "x": 1250, "y": 245, "z": 5 }, + "size": { "width": 100, "height": 36 }, + "overrides": { + "label": "출하계획", + "webTypeConfig": { + "variant": "destructive" + }, + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 13 + }, + { + "id": "repeater-items", + "url": "@/lib/registry/components/v2-repeater", + "position": { "x": 40, "y": 290, "z": 3 }, + "size": { "width": 1320, "height": 200 }, + "overrides": { + "renderMode": "modal", + "dataSource": { + "tableName": "sales_order_detail", + "foreignKey": "order_no", + "referenceKey": "order_no" + }, + "columns": [ + { "field": "part_code", "header": "품번", "width": 100 }, + { "field": "part_name", "header": "품명", "width": 150 }, + { "field": "spec", "header": "규격", "width": 100 }, + { "field": "unit", "header": "단위", "width": 80 }, + { "field": "qty", "header": "수량", "width": 100, "editable": true }, + { "field": "unit_price", "header": "단가", "width": 100, "editable": true }, + { "field": "amount", "header": "금액", "width": 100 }, + { "field": "due_date", "header": "납기일", "width": 120, "editable": true } + ], + "modal": { + "sourceTable": "item_info", + "sourceColumns": ["part_code", "part_name", "spec", "material", "unit_price"], + "filterCondition": {} + }, + "features": { + "showAddButton": false, + "showDeleteButton": true, + "inlineEdit": true + }, + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 14 + }, + + { + "id": "section-trade-info", + "url": "@/lib/registry/components/v2-section-card", + "position": { "x": 20, "y": 530, "z": 1 }, + "size": { "width": 1360, "height": 150 }, + "overrides": { + "componentConfig": { + "title": "무역 정보", + "showHeader": true, + "padding": "md", + "borderStyle": "solid" + }, + "conditionalConfig": { + "enabled": true, + "field": "sales_type", + "operator": "=", + "value": "overseas", + "action": "show" + } + }, + "displayOrder": 15 + }, + { + "id": "select-incoterms", + "url": "@/lib/registry/components/v2-select", + "position": { "x": 40, "y": 575, "z": 3 }, + "size": { "width": 200, "height": 40 }, + "overrides": { + "label": "인코텀즈", + "columnName": "incoterms", + "mode": "dropdown", + "source": "static", + "options": [ + { "value": "FOB", "label": "FOB" }, + { "value": "CIF", "label": "CIF" }, + { "value": "EXW", "label": "EXW" }, + { "value": "DDP", "label": "DDP" } + ], + "placeholder": "선택", + "conditionalConfig": { + "enabled": true, + "field": "sales_type", + "operator": "=", + "value": "overseas", + "action": "show" + } + }, + "displayOrder": 16 + }, + { + "id": "select-payment-term", + "url": "@/lib/registry/components/v2-select", + "position": { "x": 260, "y": 575, "z": 3 }, + "size": { "width": 200, "height": 40 }, + "overrides": { + "label": "결제 조건", + "columnName": "payment_term", + "mode": "dropdown", + "source": "static", + "options": [ + { "value": "TT", "label": "T/T" }, + { "value": "LC", "label": "L/C" }, + { "value": "DA", "label": "D/A" }, + { "value": "DP", "label": "D/P" } + ], + "placeholder": "선택", + "conditionalConfig": { + "enabled": true, + "field": "sales_type", + "operator": "=", + "value": "overseas", + "action": "show" + } + }, + "displayOrder": 17 + }, + { + "id": "select-currency", + "url": "@/lib/registry/components/v2-select", + "position": { "x": 480, "y": 575, "z": 3 }, + "size": { "width": 200, "height": 40 }, + "overrides": { + "label": "통화", + "columnName": "currency", + "mode": "dropdown", + "source": "static", + "options": [ + { "value": "KRW", "label": "KRW (원)" }, + { "value": "USD", "label": "USD (달러)" }, + { "value": "EUR", "label": "EUR (유로)" }, + { "value": "JPY", "label": "JPY (엔)" }, + { "value": "CNY", "label": "CNY (위안)" } + ], + "conditionalConfig": { + "enabled": true, + "field": "sales_type", + "operator": "=", + "value": "overseas", + "action": "show" + } + }, + "displayOrder": 18 + }, + { + "id": "input-port-loading", + "url": "@/lib/registry/components/v2-input", + "position": { "x": 40, "y": 625, "z": 3 }, + "size": { "width": 200, "height": 40 }, + "overrides": { + "label": "선적항", + "columnName": "port_of_loading", + "placeholder": "선적항", + "conditionalConfig": { + "enabled": true, + "field": "sales_type", + "operator": "=", + "value": "overseas", + "action": "show" + } + }, + "displayOrder": 19 + }, + { + "id": "input-port-discharge", + "url": "@/lib/registry/components/v2-input", + "position": { "x": 260, "y": 625, "z": 3 }, + "size": { "width": 200, "height": 40 }, + "overrides": { + "label": "도착항", + "columnName": "port_of_discharge", + "placeholder": "도착항", + "conditionalConfig": { + "enabled": true, + "field": "sales_type", + "operator": "=", + "value": "overseas", + "action": "show" + } + }, + "displayOrder": 20 + }, + { + "id": "input-hs-code", + "url": "@/lib/registry/components/v2-input", + "position": { "x": 480, "y": 625, "z": 3 }, + "size": { "width": 200, "height": 40 }, + "overrides": { + "label": "HS Code", + "columnName": "hs_code", + "placeholder": "HS Code", + "conditionalConfig": { + "enabled": true, + "field": "sales_type", + "operator": "=", + "value": "overseas", + "action": "show" + } + }, + "displayOrder": 21 + }, + + { + "id": "section-additional", + "url": "@/lib/registry/components/v2-section-card", + "position": { "x": 20, "y": 690, "z": 1 }, + "size": { "width": 1360, "height": 130 }, + "overrides": { + "componentConfig": { + "title": "추가 정보", + "showHeader": true, + "padding": "md", + "borderStyle": "solid" + } + }, + "displayOrder": 22 + }, + { + "id": "input-memo", + "url": "@/lib/registry/components/v2-input", + "position": { "x": 40, "y": 735, "z": 3 }, + "size": { "width": 1320, "height": 70 }, + "overrides": { + "label": "메모", + "columnName": "memo", + "type": "textarea", + "placeholder": "메모를 입력하세요" + }, + "displayOrder": 23 + }, + + { + "id": "btn-cancel", + "url": "@/lib/registry/components/v2-button-primary", + "position": { "x": 1180, "y": 840, "z": 5 }, + "size": { "width": 90, "height": 40 }, + "overrides": { + "label": "취소", + "webTypeConfig": { + "variant": "outline" + }, + "action": { + "type": "close" + } + }, + "displayOrder": 24 + }, + { + "id": "btn-save", + "url": "@/lib/registry/components/v2-button-primary", + "position": { "x": 1280, "y": 840, "z": 5 }, + "size": { "width": 90, "height": 40 }, + "overrides": { + "label": "저장", + "action": { + "type": "save" + } + }, + "displayOrder": 25 + } + ], + "gridSettings": { + "columns": 12, + "gap": 16, + "padding": 20, + "snapToGrid": true, + "showGrid": false + } +} diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 828d1aca..0ce2bae5 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -238,7 +238,8 @@ function ScreenViewPage() { compType?.includes("select") || compType?.includes("textarea") || compType?.includes("v2-input") || - compType?.includes("v2-select"); + compType?.includes("v2-select") || + compType?.includes("v2-media"); // 🆕 미디어 컴포넌트 추가 const hasColumnName = !!(comp as any).columnName; return isInputType && hasColumnName; }); diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 68fa0cb1..d991e553 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -604,23 +604,135 @@ export const ScreenModal: React.FC = ({ className }) => { transformOrigin: "center center", }} > - {screenData.components.map((component) => { + {(() => { + // 🆕 동적 y 좌표 조정을 위해 먼저 숨겨지는 컴포넌트들 파악 + const isComponentHidden = (comp: any) => { + const cc = comp.componentConfig?.conditionalConfig || comp.conditionalConfig; + if (!cc?.enabled || !formData) return false; + + const { field, operator, value, action } = cc; + const fieldValue = formData[field]; + + let conditionMet = false; + switch (operator) { + case "=": + case "==": + case "===": + conditionMet = fieldValue === value; + break; + case "!=": + case "!==": + conditionMet = fieldValue !== value; + break; + default: + conditionMet = fieldValue === value; + } + + return (action === "show" && !conditionMet) || (action === "hide" && conditionMet); + }; + + // 표시되는 컴포넌트들의 y 범위 수집 + const visibleRanges: { y: number; bottom: number }[] = []; + screenData.components.forEach((comp: any) => { + if (!isComponentHidden(comp)) { + const y = parseFloat(comp.position?.y?.toString() || "0"); + const height = parseFloat(comp.size?.height?.toString() || "0"); + visibleRanges.push({ y, bottom: y + height }); + } + }); + + // 숨겨지는 컴포넌트의 "실제 빈 공간" 계산 (표시되는 컴포넌트와 겹치지 않는 영역) + const getActualGap = (hiddenY: number, hiddenBottom: number): number => { + // 숨겨지는 영역 중 표시되는 컴포넌트와 겹치는 부분을 제외 + let gapStart = hiddenY; + let gapEnd = hiddenBottom; + + for (const visible of visibleRanges) { + // 겹치는 영역 확인 + if (visible.y < gapEnd && visible.bottom > gapStart) { + // 겹치는 부분을 제외 + if (visible.y <= gapStart && visible.bottom >= gapEnd) { + // 완전히 덮힘 - 빈 공간 없음 + return 0; + } else if (visible.y <= gapStart) { + // 위쪽이 덮힘 + gapStart = visible.bottom; + } else if (visible.bottom >= gapEnd) { + // 아래쪽이 덮힘 + gapEnd = visible.y; + } + } + } + + return Math.max(0, gapEnd - gapStart); + }; + + // 숨겨지는 컴포넌트들의 실제 빈 공간 수집 + const hiddenGaps: { bottom: number; gap: number }[] = []; + screenData.components.forEach((comp: any) => { + if (isComponentHidden(comp)) { + const y = parseFloat(comp.position?.y?.toString() || "0"); + const height = parseFloat(comp.size?.height?.toString() || "0"); + const bottom = y + height; + const gap = getActualGap(y, bottom); + if (gap > 0) { + hiddenGaps.push({ bottom, gap }); + } + } + }); + + // bottom 기준으로 정렬 및 중복 제거 (같은 bottom은 가장 큰 gap만 유지) + const mergedGaps = new Map(); + hiddenGaps.forEach(({ bottom, gap }) => { + const existing = mergedGaps.get(bottom) || 0; + mergedGaps.set(bottom, Math.max(existing, gap)); + }); + + const sortedGaps = Array.from(mergedGaps.entries()) + .map(([bottom, gap]) => ({ bottom, gap })) + .sort((a, b) => a.bottom - b.bottom); + + console.log('🔍 [Y조정] visibleRanges:', visibleRanges.filter(r => r.bottom - r.y > 50).map(r => `${r.y}~${r.bottom}`)); + console.log('🔍 [Y조정] hiddenGaps:', sortedGaps); + + // 각 컴포넌트의 y 조정값 계산 함수 + const getYOffset = (compY: number, compId?: string) => { + let offset = 0; + for (const { bottom, gap } of sortedGaps) { + // 컴포넌트가 숨겨진 영역 아래에 있으면 그 빈 공간만큼 위로 이동 + if (compY > bottom) { + offset += gap; + } + } + if (offset > 0 && compId) { + console.log(`🔍 [Y조정] ${compId}: y=${compY} → ${compY - offset} (offset=${offset})`); + } + return offset; + }; + + return screenData.components.map((component: any) => { + // 숨겨지는 컴포넌트는 렌더링 안함 + if (isComponentHidden(component)) { + return null; + } + // 화면 관리 해상도를 사용하는 경우 offset 조정 불필요 const offsetX = screenDimensions?.offsetX || 0; const offsetY = screenDimensions?.offsetY || 0; + + // 🆕 동적 y 좌표 조정 (숨겨진 컴포넌트 높이만큼 위로 이동) + const compY = parseFloat(component.position?.y?.toString() || "0"); + const yAdjustment = getYOffset(compY, component.id); // offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시) - const adjustedComponent = - offsetX === 0 && offsetY === 0 - ? component - : { - ...component, - position: { - ...component.position, - x: parseFloat(component.position?.x?.toString() || "0") - offsetX, - y: parseFloat(component.position?.y?.toString() || "0") - offsetY, - }, - }; + const adjustedComponent = { + ...component, + position: { + ...component.position, + x: parseFloat(component.position?.x?.toString() || "0") - offsetX, + y: compY - offsetY - yAdjustment, // 🆕 동적 조정 적용 + }, + }; return ( = ({ className }) => { companyCode={user?.companyCode} /> ); - })} + }); + })()}
diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 735fb53c..8dc5da89 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -335,13 +335,42 @@ export const InteractiveScreenViewerDynamic: React.FC { - // 조건부 표시 평가 + // 조건부 표시 평가 (기존 conditional 시스템) const conditionalResult = evaluateConditional(comp.conditional, formData, allComponents); // 조건에 따라 숨김 처리 if (!conditionalResult.visible) { return null; } + + // 🆕 conditionalConfig 시스템 체크 (V2 레이아웃용) + const conditionalConfig = (comp as any).componentConfig?.conditionalConfig; + if (conditionalConfig?.enabled && formData) { + const { field, operator, value, action } = conditionalConfig; + const fieldValue = formData[field]; + + let conditionMet = false; + switch (operator) { + case "=": + case "==": + case "===": + conditionMet = fieldValue === value; + break; + case "!=": + case "!==": + conditionMet = fieldValue !== value; + break; + default: + conditionMet = fieldValue === value; + } + + if (action === "show" && !conditionMet) { + return null; + } + if (action === "hide" && conditionMet) { + return null; + } + } // 데이터 테이블 컴포넌트 처리 if (isDataTableComponent(comp)) { @@ -533,11 +562,26 @@ export const InteractiveScreenViewerDynamic: React.FC = {}; + + // v2-media 컴포넌트의 columnName 목록 수집 + const mediaColumnNames = new Set( + allComponents + .filter((c: any) => c.componentType === "v2-media" || c.url?.includes("v2-media")) + .map((c: any) => c.columnName || c.componentConfig?.columnName) + .filter(Boolean) + ); + Object.entries(formData).forEach(([key, value]) => { - // 배열 데이터는 리피터 데이터이므로 제외 if (!Array.isArray(value)) { + // 배열이 아닌 값은 그대로 저장 masterFormData[key] = value; + } else if (mediaColumnNames.has(key)) { + // v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응) + // 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용 + masterFormData[key] = value.length > 0 ? value[0] : null; + console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`); } else { console.log(`🔄 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`); } diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index c73e6598..192bd16c 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1623,55 +1623,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU }; }, [MIN_ZOOM, MAX_ZOOM]); - // 격자 설정 업데이트 및 컴포넌트 자동 스냅 + // 격자 설정 업데이트 (컴포넌트 자동 조정 제거됨) const updateGridSettings = useCallback( (newGridSettings: GridSettings) => { const newLayout = { ...layout, gridSettings: newGridSettings }; - - // 격자 스냅이 활성화된 경우, 모든 컴포넌트를 새로운 격자에 맞게 조정 - if (newGridSettings.snapToGrid && screenResolution.width > 0) { - // 새로운 격자 설정으로 격자 정보 재계산 (해상도 기준) - const newGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: newGridSettings.columns, - gap: newGridSettings.gap, - padding: newGridSettings.padding, - snapToGrid: newGridSettings.snapToGrid || false, - }); - - const gridUtilSettings = { - columns: newGridSettings.columns, - gap: newGridSettings.gap, - padding: newGridSettings.padding, - snapToGrid: true, // 항상 10px 스냅 활성화 - }; - - const adjustedComponents = layout.components.map((comp) => { - const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings); - const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings); - - // gridColumns가 없거나 범위를 벗어나면 자동 조정 - let adjustedGridColumns = comp.gridColumns; - if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > newGridSettings.columns) { - adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings); - } - - return { - ...comp, - position: snappedPosition, - size: snappedSize, - gridColumns: adjustedGridColumns, // gridColumns 속성 추가/조정 - }; - }); - - newLayout.components = adjustedComponents; - // console.log("격자 설정 변경으로 컴포넌트 위치 및 크기 자동 조정:", adjustedComponents.length, "개"); - // console.log("새로운 격자 정보:", newGridInfo); - } - + // 🆕 격자 설정 변경 시 컴포넌트 크기/위치 자동 조정 로직 제거됨 + // 사용자가 명시적으로 "격자 재조정" 버튼을 클릭해야만 조정됨 setLayout(newLayout); saveToHistory(newLayout); }, - [layout, screenResolution, saveToHistory], + [layout, saveToHistory], ); // 해상도 변경 핸들러 (컴포넌트 크기/위치 유지) diff --git a/frontend/components/screen/panels/webtype-configs/SelectTypeConfigPanel.tsx b/frontend/components/screen/panels/webtype-configs/SelectTypeConfigPanel.tsx index f59171d1..e83c17f6 100644 --- a/frontend/components/screen/panels/webtype-configs/SelectTypeConfigPanel.tsx +++ b/frontend/components/screen/panels/webtype-configs/SelectTypeConfigPanel.tsx @@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Plus, X } from "lucide-react"; import { SelectTypeConfig } from "@/types/screen"; @@ -22,6 +23,7 @@ export const SelectTypeConfigPanel: React.FC = ({ co placeholder: "", allowClear: false, maxSelections: undefined, + defaultValue: "", ...config, }; @@ -32,6 +34,7 @@ export const SelectTypeConfigPanel: React.FC = ({ co placeholder: safeConfig.placeholder, allowClear: safeConfig.allowClear, maxSelections: safeConfig.maxSelections?.toString() || "", + defaultValue: safeConfig.defaultValue || "", }); const [newOption, setNewOption] = useState({ label: "", value: "" }); @@ -53,6 +56,7 @@ export const SelectTypeConfigPanel: React.FC = ({ co placeholder: safeConfig.placeholder, allowClear: safeConfig.allowClear, maxSelections: safeConfig.maxSelections?.toString() || "", + defaultValue: safeConfig.defaultValue || "", }); setLocalOptions( @@ -68,6 +72,7 @@ export const SelectTypeConfigPanel: React.FC = ({ co safeConfig.placeholder, safeConfig.allowClear, safeConfig.maxSelections, + safeConfig.defaultValue, JSON.stringify(safeConfig.options), // 옵션 배열의 전체 내용 변화 감지 ]); @@ -174,6 +179,30 @@ export const SelectTypeConfigPanel: React.FC = ({ co />
+ {/* 기본값 설정 */} +
+ + +

화면 로드 시 자동으로 선택될 값

+
+ {/* 다중 선택 */}
{/* 드래그 핸들 헤더 - 좌측 고정 */} - {/* 체크박스 헤더 - 좌측 고정 */} - - {visibleColumns.map((col) => { + {visibleColumns.map((col, colIndex) => { const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0; const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId; const activeOption = hasDynamicSource @@ -677,7 +677,7 @@ export function RepeaterTable({ return ( {data.length === 0 ? ( - + {/* 데이터 컬럼들 */} - {visibleColumns.map((col) => ( + {visibleColumns.map((col, colIndex) => ( {showRowNumber && ( - )} {columns.map((col) => ( ))} {!readOnly && allowDelete && ( - )} @@ -707,8 +707,9 @@ export function SimpleRepeaterTableComponent({ {value.length === 0 ? ( - + ) : ( value.map((row, rowIndex) => ( - + {showRowNumber && ( - )} {columns.map((col) => ( - ))} {!readOnly && allowDelete && ( -
+ 순서 + handleDoubleClick(col.field)} @@ -765,8 +765,9 @@ export function RepeaterTable({
@@ -787,6 +788,7 @@ export function RepeaterTable({ <> {/* 드래그 핸들 - 좌측 고정 */} {/* 체크박스 - 좌측 고정 */}
+ # @@ -699,7 +699,7 @@ export function SimpleRepeaterTableComponent({ + 삭제
@@ -724,19 +725,19 @@ export function SimpleRepeaterTableComponent({
+ {rowIndex + 1} + {renderCell(row, col, rowIndex)} +