From ef9f1b94ff9ba6bdb7451b9f2a64fbee88d73e2b Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 3 Feb 2026 15:50:23 +0900 Subject: [PATCH 01/24] =?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 -- 2.43.0 From f821a7bff3d707199a9b4743832e60d41f2b0a99 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 3 Feb 2026 17:45:37 +0900 Subject: [PATCH 02/24] =?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 -- 2.43.0 From d13cd478de03bb99f72c0a30866606564350f07c Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 4 Feb 2026 09:28:16 +0900 Subject: [PATCH 03/24] =?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)} + @@ -409,6 +420,7 @@ export const V2Date = forwardRef((props, ref) => { maxDate={config.maxDate} disabled={isDisabled} readonly={readonly} + placeholder={config.placeholder} /> ); @@ -444,6 +456,7 @@ export const V2Date = forwardRef((props, ref) => { showToday={config.showToday} disabled={isDisabled} readonly={readonly} + placeholder={config.placeholder} /> ); } @@ -453,37 +466,40 @@ export const V2Date = forwardRef((props, ref) => { const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; + // 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정) + const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14; + const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; + const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; + return (
+ {/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */} {showLabel && ( )} -
+
{renderDatePicker()}
diff --git a/frontend/components/v2/V2Hierarchy.tsx b/frontend/components/v2/V2Hierarchy.tsx index 23e4fd85..28f51fee 100644 --- a/frontend/components/v2/V2Hierarchy.tsx +++ b/frontend/components/v2/V2Hierarchy.tsx @@ -462,37 +462,40 @@ export const V2Hierarchy = forwardRef( const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; + // 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정) + const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14; + const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; + const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; + return (
+ {/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */} {showLabel && ( )} -
+
{renderHierarchy()}
diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index 10be3bf2..85929cbc 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -792,37 +792,40 @@ export const V2Input = forwardRef((props, ref) => const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; + // 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정) + const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14; + const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; + const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; // 라벨 높이 + 여백 + return (
+ {/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 (높이에 포함되지 않음) */} {showLabel && ( )} -
+
{renderInput()}
diff --git a/frontend/components/v2/V2Media.tsx b/frontend/components/v2/V2Media.tsx index c457b3f7..27503c3e 100644 --- a/frontend/components/v2/V2Media.tsx +++ b/frontend/components/v2/V2Media.tsx @@ -536,37 +536,40 @@ export const V2Media = forwardRef( const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; + // 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정) + const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14; + const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; + const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; + return (
+ {/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */} {showLabel && ( )} -
+
{renderMedia()}
diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 1aeac80d..d1954350 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -744,37 +744,40 @@ export const V2Select = forwardRef( const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; + // 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정) + const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14; + const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; + const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; + return (
+ {/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */} {showLabel && ( )} -
+
{renderSelect()}
diff --git a/frontend/components/v2/config-panels/V2DateConfigPanel.tsx b/frontend/components/v2/config-panels/V2DateConfigPanel.tsx index 1308a700..6d7ba5d7 100644 --- a/frontend/components/v2/config-panels/V2DateConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2DateConfigPanel.tsx @@ -48,6 +48,20 @@ export const V2DateConfigPanel: React.FC = ({ + {/* 플레이스홀더 */} +
+ + updateConfig("placeholder", e.target.value)} + placeholder="날짜 선택" + className="h-8 text-xs" + /> +

날짜가 선택되지 않았을 때 표시할 텍스트

+
+ + + {/* 표시 형식 */}
diff --git a/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx b/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx index dfbbceb1..1550bbe3 100644 --- a/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx +++ b/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx @@ -29,10 +29,15 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer { } }; + // 라벨: style.labelText 우선, 없으면 component.label 사용 + // style.labelDisplay가 false면 라벨 숨김 + const style = component.style || {}; + const effectiveLabel = style.labelDisplay === false ? undefined : (style.labelText || component.label); + return ( = ({ return (
- {/* 라벨 렌더링 */} - {component.label && (component.style?.labelDisplay ?? true) && ( - - )} - + {/* v2-text-display는 텍스트 표시 전용이므로 별도 라벨 불필요 */}
{componentConfig.text || "텍스트를 입력하세요"}
-- 2.43.0 From 88ba2f62d2183469726cdb12a05345170fa41fa4 Mon Sep 17 00:00:00 2001 From: kmh Date: Wed, 4 Feb 2026 13:57:04 +0900 Subject: [PATCH 09/24] =?UTF-8?q?feat:=20Docker=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Docker Compose 설정에서 프론트엔드 메모리 제한을 6G로 설정하고, 2G의 메모리 예약을 추가하여 성능을 향상시켰습니다. - Node.js의 최대 메모리 크기를 4096MB로 설정하여 메모리 부족 문제를 방지합니다. - Windows에서 모든 컨테이너 및 이미지를 정리하는 스크립트를 업데이트하여, 불필요한 리소스를 제거하고 빌드 프로세스를 최적화하였습니다. - 백엔드 및 프론트엔드 빌드를 캐시 없이 완전 재빌드하도록 수정하여, 최신 변경 사항이 반영되도록 하였습니다. --- backend-node/package-lock.json | 13 +++++++++++-- docker-compose.frontend.win.yml | 7 +++++++ scripts/dev/start-all-parallel.bat | 16 +++++++++------- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 7e1108c3..43b698d2 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -1044,6 +1044,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2371,6 +2372,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -3474,6 +3476,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3710,6 +3713,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3927,6 +3931,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4453,6 +4458,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5663,6 +5669,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7425,6 +7432,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8394,7 +8402,6 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -9283,6 +9290,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -10133,7 +10141,6 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -10942,6 +10949,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11047,6 +11055,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/docker-compose.frontend.win.yml b/docker-compose.frontend.win.yml index f81e2287..79589463 100644 --- a/docker-compose.frontend.win.yml +++ b/docker-compose.frontend.win.yml @@ -12,6 +12,13 @@ services: environment: - NEXT_PUBLIC_API_URL=http://localhost:8080/api - WATCHPACK_POLLING=true + - NODE_OPTIONS=--max-old-space-size=4096 + deploy: + resources: + limits: + memory: 6G + reservations: + memory: 2G volumes: - ./frontend:/app - /app/node_modules diff --git a/scripts/dev/start-all-parallel.bat b/scripts/dev/start-all-parallel.bat index ea10551e..08049b48 100644 --- a/scripts/dev/start-all-parallel.bat +++ b/scripts/dev/start-all-parallel.bat @@ -26,12 +26,14 @@ if %errorlevel% neq 0 ( echo [OK] Docker Desktop이 실행 중입니다. echo. -REM 기존 컨테이너 정리 -echo [2/5] 기존 컨테이너 정리 중... +REM 기존 컨테이너 및 이미지 정리 +echo [2/5] 기존 컨테이너 및 이미지 정리 중... docker rm -f pms-backend-win pms-frontend-win 2>nul +docker rmi -f erp-node-backend erp-node-frontend 2>nul docker network rm pms-network 2>nul docker network create pms-network 2>nul -echo [OK] 컨테이너 정리 완료 +docker system prune -f >nul 2>&1 +echo [OK] 컨테이너 및 이미지 정리 완료 echo. REM 병렬 빌드 (docker-compose 자체가 병렬 처리) @@ -39,8 +41,8 @@ echo [3/5] 이미지 빌드 중... (백엔드 + 프론트엔드 병렬) echo 이 작업은 시간이 걸릴 수 있습니다... echo. -REM 백엔드 빌드 -docker-compose -f docker-compose.backend.win.yml build +REM 백엔드 빌드 (캐시 없이 완전 재빌드) +docker-compose -f docker-compose.backend.win.yml build --no-cache if %errorlevel% neq 0 ( echo [ERROR] 백엔드 빌드 실패! pause @@ -49,8 +51,8 @@ if %errorlevel% neq 0 ( echo [OK] 백엔드 빌드 완료 echo. -REM 프론트엔드 빌드 -docker-compose -f docker-compose.frontend.win.yml build +REM 프론트엔드 빌드 (캐시 없이 완전 재빌드) +docker-compose -f docker-compose.frontend.win.yml build --no-cache if %errorlevel% neq 0 ( echo [ERROR] 프론트엔드 빌드 실패! pause -- 2.43.0 From 52fd37046098ad8a18f2bf1e908b7671f6d2e1c9 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 4 Feb 2026 14:12:24 +0900 Subject: [PATCH 10/24] =?UTF-8?q?feat:=20=EC=88=98=EB=8F=99=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EC=BD=94=EB=93=9C=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EC=BD=94=EB=93=9C=20=EC=A0=84=EB=8B=AC=20=EA=B8=B0?= =?UTF-8?q?=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 - allocateCode 함수에 사용자가 편집한 최종 코드를 전달하여 수동 입력 부분을 추출할 수 있도록 수정하였습니다. - 여러 컴포넌트에서 사용자 입력 값을 처리할 수 있는 이벤트 리스너를 추가하여, 채번 생성 시 수동 입력 값을 반영하도록 개선하였습니다. - V2Input 및 관련 컴포넌트에서 formData에 수동 입력 값을 주입하는 로직을 추가하여 사용자 경험을 향상시켰습니다. - 코드 할당 요청 시 사용자 입력 코드와 폼 데이터를 함께 전달하여, 보다 유연한 코드 할당이 가능하도록 하였습니다. --- .../controllers/numberingRuleController.ts | 6 +- .../src/services/numberingRuleService.ts | 77 ++++++++++++++++++- frontend/components/common/ScreenModal.tsx | 33 +++++++- frontend/components/screen/EditModal.tsx | 8 +- .../components/unified/UnifiedRepeater.tsx | 8 +- frontend/components/v2/V2Input.tsx | 46 +++++++++++ frontend/components/v2/V2Repeater.tsx | 8 +- frontend/lib/api/numberingRule.ts | 12 ++- .../RepeatScreenModalComponent.tsx | 4 +- .../UniversalFormModalComponent.tsx | 5 +- .../components/v2-input/V2InputRenderer.tsx | 12 +++ frontend/lib/utils/buttonActions.ts | 19 +++-- 12 files changed, 211 insertions(+), 27 deletions(-) diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index f5cbc91a..a8f99b36 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -225,12 +225,12 @@ router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequ router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; const { ruleId } = req.params; - const { formData } = req.body; // 폼 데이터 (날짜 컬럼 기준 생성 시 사용) + const { formData, userInputCode } = req.body; // 폼 데이터 + 사용자가 편집한 코드 - logger.info("코드 할당 요청", { ruleId, companyCode, hasFormData: !!formData }); + logger.info("코드 할당 요청", { ruleId, companyCode, hasFormData: !!formData, userInputCode }); try { - const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData); + const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData, userInputCode); logger.info("코드 할당 성공", { ruleId, allocatedCode }); return res.json({ success: true, data: { generatedCode: allocatedCode } }); } catch (error: any) { diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index abdfd739..0bdec037 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -886,8 +886,9 @@ class NumberingRuleService { .sort((a: any, b: any) => a.order - b.order) .map((part: any) => { if (part.generationMethod === "manual") { - // 수동 입력 - 플레이스홀더 표시 (실제 값은 사용자가 입력) - return part.manualConfig?.placeholder || "____"; + // 수동 입력 - 항상 ____ 마커 사용 (프론트엔드에서 편집 가능하게 처리) + // placeholder 텍스트는 프론트엔드에서 별도로 표시 + return "____"; } const autoConfig = part.autoConfig || {}; @@ -1014,11 +1015,13 @@ class NumberingRuleService { * @param ruleId 채번 규칙 ID * @param companyCode 회사 코드 * @param formData 폼 데이터 (날짜 컬럼 기준 생성 시 사용) + * @param userInputCode 사용자가 편집한 최종 코드 (수동 입력 부분 추출용) */ async allocateCode( ruleId: string, companyCode: string, - formData?: Record + formData?: Record, + userInputCode?: string ): Promise { const pool = getPool(); const client = await pool.connect(); @@ -1029,11 +1032,77 @@ class NumberingRuleService { const rule = await this.getRuleById(ruleId, companyCode); if (!rule) throw new Error("규칙을 찾을 수 없습니다"); + // 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출 + const manualParts = rule.parts.filter((p: any) => p.generationMethod === "manual"); + let extractedManualValues: string[] = []; + + if (manualParts.length > 0 && userInputCode) { + // 프리뷰 코드를 생성해서 ____ 위치 파악 + const previewParts = rule.parts + .sort((a: any, b: any) => a.order - b.order) + .map((part: any) => { + if (part.generationMethod === "manual") { + return "____"; + } + const autoConfig = part.autoConfig || {}; + switch (part.partType) { + case "sequence": { + const length = autoConfig.sequenceLength || 3; + return "X".repeat(length); // 순번 자리 표시 + } + case "text": + return autoConfig.textValue || ""; + case "date": + return "DATEPART"; // 날짜 자리 표시 + default: + return ""; + } + }); + + const separator = rule.separator || ""; + const previewTemplate = previewParts.join(separator); + + // 사용자 입력 코드에서 수동 입력 부분 추출 + // 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출 + const templateParts = previewTemplate.split("____"); + if (templateParts.length > 1) { + let remainingCode = userInputCode; + for (let i = 0; i < templateParts.length - 1; i++) { + const prefix = templateParts[i]; + const suffix = templateParts[i + 1]; + + // prefix 이후 부분 추출 + if (prefix && remainingCode.startsWith(prefix)) { + remainingCode = remainingCode.slice(prefix.length); + } + + // suffix 이전까지가 수동 입력 값 + if (suffix) { + // suffix에서 순번(XXX)이나 날짜 부분을 제외한 실제 구분자 찾기 + const suffixStart = suffix.replace(/X+|DATEPART/g, ""); + const manualEndIndex = suffixStart ? remainingCode.indexOf(suffixStart) : remainingCode.length; + if (manualEndIndex > 0) { + extractedManualValues.push(remainingCode.slice(0, manualEndIndex)); + remainingCode = remainingCode.slice(manualEndIndex); + } + } else { + extractedManualValues.push(remainingCode); + } + } + } + + logger.info(`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}`); + } + + let manualPartIndex = 0; const parts = rule.parts .sort((a: any, b: any) => a.order - b.order) .map((part: any) => { if (part.generationMethod === "manual") { - return part.manualConfig?.value || ""; + // 추출된 수동 입력 값 사용, 없으면 기본값 사용 + const manualValue = extractedManualValues[manualPartIndex] || part.manualConfig?.value || ""; + manualPartIndex++; + return manualValue; } const autoConfig = part.autoConfig || {}; diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index dbb1e923..3a8958c8 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -127,6 +127,24 @@ export const ScreenModal: React.FC = ({ className }) => { // 모달이 열린 시간 추적 (저장 성공 이벤트 무시용) const modalOpenedAtRef = React.useRef(0); + // 🆕 채번 필드 수동 입력 값 변경 이벤트 리스너 + useEffect(() => { + const handleNumberingValueChanged = (event: CustomEvent) => { + const { columnName, value } = event.detail; + if (columnName && modalState.isOpen) { + setFormData((prev) => ({ + ...prev, + [columnName]: value, + })); + } + }; + + window.addEventListener("numberingValueChanged", handleNumberingValueChanged as EventListener); + return () => { + window.removeEventListener("numberingValueChanged", handleNumberingValueChanged as EventListener); + }; + }, [modalState.isOpen]); + // 전역 모달 이벤트 리스너 useEffect(() => { const handleOpenModal = (event: CustomEvent) => { @@ -140,6 +158,7 @@ export const ScreenModal: React.FC = ({ className }) => { splitPanelParentData, selectedData: eventSelectedData, selectedIds, + isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함) } = event.detail; // 🆕 모달 열린 시간 기록 @@ -163,7 +182,8 @@ export const ScreenModal: React.FC = ({ className }) => { } // 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드) - if (editData) { + // 🔧 단, isCreateMode가 true이면 (복사 모드) originalData를 설정하지 않음 → 채번 생성 가능 + if (editData && !isCreateMode) { // 🆕 배열인 경우 두 가지 데이터를 설정: // 1. formData: 첫 번째 요소(객체) - 일반 입력 필드용 (TextInput 등) // 2. selectedData: 전체 배열 - 다중 항목 컴포넌트용 (SelectedItemsDetailInput 등) @@ -177,6 +197,17 @@ export const ScreenModal: React.FC = ({ className }) => { setSelectedData([editData]); // 🔧 단일 객체도 배열로 변환하여 저장 setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) } + } else if (editData && isCreateMode) { + // 🆕 복사 모드: formData만 설정하고 originalData는 null로 유지 (채번 생성 가능) + if (Array.isArray(editData)) { + const firstRecord = editData[0] || {}; + setFormData(firstRecord); + setSelectedData(editData); + } else { + setFormData(editData); + setSelectedData([editData]); + } + setOriginalData(null); // 🔧 복사 모드에서는 originalData를 null로 설정 } else { // 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정 // 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함 diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index db722991..5856df0e 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -772,12 +772,14 @@ export const EditModal: React.FC = ({ className }) => { for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { try { - console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`); - const allocateResult = await allocateNumberingCode(ruleId); + // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) + const userInputCode = dataToSave[fieldName] as string; + console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}, 사용자입력: ${userInputCode}`); + const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData); if (allocateResult.success && allocateResult.data?.generatedCode) { const newCode = allocateResult.data.generatedCode; - console.log(`✅ [EditModal] ${fieldName} 새 코드 할당: ${dataToSave[fieldName]} → ${newCode}`); + console.log(`✅ [EditModal] ${fieldName} 새 코드 할당: ${userInputCode} → ${newCode}`); dataToSave[fieldName] = newCode; } else { console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error); diff --git a/frontend/components/unified/UnifiedRepeater.tsx b/frontend/components/unified/UnifiedRepeater.tsx index 606d1730..d802baa7 100644 --- a/frontend/components/unified/UnifiedRepeater.tsx +++ b/frontend/components/unified/UnifiedRepeater.tsx @@ -700,9 +700,10 @@ export const UnifiedRepeater: React.FC = ({ ); // 🆕 채번 API 호출 (비동기) - const generateNumberingCode = useCallback(async (ruleId: string): Promise => { + // 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가 + const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record): Promise => { try { - const result = await allocateNumberingCode(ruleId); + const result = await allocateNumberingCode(ruleId, userInputCode, formData); if (result.success && result.data?.generatedCode) { return result.data.generatedCode; } @@ -831,7 +832,8 @@ export const UnifiedRepeater: React.FC = ({ if (match) { const ruleId = match[1]; try { - const result = await allocateNumberingCode(ruleId); + // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) + const result = await allocateNumberingCode(ruleId, undefined, newRow); if (result.success && result.data?.generatedCode) { newRow[key] = result.data.generatedCode; } else { diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index c19b3820..03a58c78 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -625,6 +625,40 @@ export const V2Input = forwardRef((props, ref) => // eslint-disable-next-line react-hooks/exhaustive-deps }, [tableName, columnName, isEditMode, categoryValuesForNumbering]); + // 🆕 beforeFormSave 이벤트 리스너 - 저장 직전에 현재 조합된 값을 formData에 주입 + useEffect(() => { + const inputType = propsInputType || config.inputType || config.type || "text"; + if (inputType !== "numbering" || !columnName) return; + + const handleBeforeFormSave = (event: CustomEvent) => { + const template = numberingTemplateRef.current; + if (!template || !template.includes("____")) return; + + // 템플릿에서 prefix와 suffix 추출 + const templateParts = template.split("____"); + const templatePrefix = templateParts[0] || ""; + const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : ""; + + // 현재 조합된 값 생성 + const currentValue = templatePrefix + manualInputValue + templateSuffix; + + // formData에 직접 주입 + if (event.detail?.formData && columnName) { + event.detail.formData[columnName] = currentValue; + console.log("🔧 [V2Input] beforeFormSave에서 채번 값 주입:", { + columnName, + manualInputValue, + currentValue, + }); + } + }; + + window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener); + return () => { + window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener); + }; + }, [columnName, manualInputValue, propsInputType, config.inputType, config.type]); + // 실제 표시할 값 (자동생성 값 또는 props value) const displayValue = autoGeneratedValue ?? value; @@ -769,7 +803,19 @@ export const V2Input = forwardRef((props, ref) => const newValue = templatePrefix + newUserInput + templateSuffix; userEditedNumberingRef.current = true; setAutoGeneratedValue(newValue); + + // 모든 방법으로 formData 업데이트 시도 onChange?.(newValue); + if (onFormDataChange && columnName) { + onFormDataChange(columnName, newValue); + } + + // 커스텀 이벤트로도 전달 (최후의 보루) + if (typeof window !== "undefined" && columnName) { + window.dispatchEvent(new CustomEvent("numberingValueChanged", { + detail: { columnName, value: newValue } + })); + } }} placeholder="입력" className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none" diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index ee80d0d7..5c66ba00 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -567,9 +567,10 @@ export const V2Repeater: React.FC = ({ ); // 🆕 채번 API 호출 (비동기) - const generateNumberingCode = useCallback(async (ruleId: string): Promise => { + // 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가 + const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record): Promise => { try { - const result = await allocateNumberingCode(ruleId); + const result = await allocateNumberingCode(ruleId, userInputCode, formData); if (result.success && result.data?.generatedCode) { return result.data.generatedCode; } @@ -690,7 +691,8 @@ export const V2Repeater: React.FC = ({ if (match) { const ruleId = match[1]; try { - const result = await allocateNumberingCode(ruleId); + // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) + const result = await allocateNumberingCode(ruleId, undefined, newRow); if (result.success && result.data?.generatedCode) { newRow[key] = result.data.generatedCode; } else { diff --git a/frontend/lib/api/numberingRule.ts b/frontend/lib/api/numberingRule.ts index 3a9b7930..0800e752 100644 --- a/frontend/lib/api/numberingRule.ts +++ b/frontend/lib/api/numberingRule.ts @@ -139,12 +139,20 @@ export async function previewNumberingCode( /** * 코드 할당 (저장 시점에 실제 순번 증가) * 실제 저장할 때만 호출 + * @param ruleId 채번 규칙 ID + * @param userInputCode 사용자가 편집한 최종 코드 (수동 입력 부분 추출용) + * @param formData 폼 데이터 (카테고리/날짜 기반 채번용) */ export async function allocateNumberingCode( - ruleId: string + ruleId: string, + userInputCode?: string, + formData?: Record ): Promise> { try { - const response = await apiClient.post(`/numbering-rules/${ruleId}/allocate`); + const response = await apiClient.post(`/numbering-rules/${ruleId}/allocate`, { + userInputCode, + formData, + }); return response.data; } catch (error: any) { return { success: false, error: error.message || "코드 할당 실패" }; diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 16cf7dfc..0cfdd542 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -856,8 +856,10 @@ export function RepeatScreenModalComponent({ }); // 채번 API 호출 (allocate: 실제 시퀀스 증가) + // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); - const response = await allocateNumberingCode(rowNumbering.numberingRuleId); + const userInputCode = newRowData[rowNumbering.targetColumn] as string; + const response = await allocateNumberingCode(rowNumbering.numberingRuleId, userInputCode, newRowData); if (response.success && response.data) { newRowData[rowNumbering.targetColumn] = response.data.generatedCode; diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index ca4d57d0..0f5c851b 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -1443,8 +1443,9 @@ export function UniversalFormModalComponent({ if (isNewRecord || hasNoValue) { try { - // allocateNumberingCode로 실제 순번 증가 - const response = await allocateNumberingCode(field.numberingRule.ruleId); + // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) + const userInputCode = mainData[field.columnName] as string; + const response = await allocateNumberingCode(field.numberingRule.ruleId, userInputCode, mainData); if (response.success && response.data?.generatedCode) { mainData[field.columnName] = response.data.generatedCode; } diff --git a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx index 52a230fa..83a2f761 100644 --- a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx +++ b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx @@ -25,8 +25,20 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer { // 값 변경 핸들러 const handleChange = (value: any) => { + console.log("🔄 [V2InputRenderer] handleChange 호출:", { + columnName, + value, + isInteractive, + hasOnFormDataChange: !!onFormDataChange, + }); if (isInteractive && onFormDataChange && columnName) { onFormDataChange(columnName, value); + } else { + console.warn("⚠️ [V2InputRenderer] onFormDataChange 호출 스킵:", { + isInteractive, + hasOnFormDataChange: !!onFormDataChange, + columnName, + }); } }; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index b1d66eea..869bdd0a 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -737,7 +737,9 @@ export class ButtonActionExecutor { for (const [fieldName, ruleId] of Object.entries(fieldsWithNumberingRepeater)) { try { - const allocateResult = await allocateNumberingCode(ruleId); + // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) + const userInputCode = context.formData[fieldName] as string; + const allocateResult = await allocateNumberingCode(ruleId, userInputCode, context.formData); if (allocateResult.success && allocateResult.data?.generatedCode) { const newCode = allocateResult.data.generatedCode; @@ -1030,7 +1032,9 @@ export class ButtonActionExecutor { for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { try { - const allocateResult = await allocateNumberingCode(ruleId); + // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) + const userInputCode = formData[fieldName] as string; + const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData); if (allocateResult.success && allocateResult.data?.generatedCode) { const newCode = allocateResult.data.generatedCode; @@ -2054,7 +2058,9 @@ export class ButtonActionExecutor { for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { try { - const allocateResult = await allocateNumberingCode(ruleId); + // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) + const userInputCode = commonFieldsData[fieldName] as string; + const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData); if (allocateResult.success && allocateResult.data?.generatedCode) { const newCode = allocateResult.data.generatedCode; @@ -3485,10 +3491,13 @@ export class ButtonActionExecutor { const screenModalEvent = new CustomEvent("openScreenModal", { detail: { screenId: config.targetScreenId, - title: config.editModalTitle || "데이터 수정", + title: isCreateMode ? config.editModalTitle || "데이터 복사" : config.editModalTitle || "데이터 수정", description: description, size: config.modalSize || "lg", - editData: rowData, // 🆕 수정 데이터 전달 + // 🔧 복사 모드에서는 editData 대신 splitPanelParentData로 전달하여 채번이 생성되도록 함 + editData: isCreateMode ? undefined : rowData, + splitPanelParentData: isCreateMode ? rowData : undefined, + isCreateMode: isCreateMode, // 🆕 복사 모드 플래그 전달 }, }); window.dispatchEvent(screenModalEvent); -- 2.43.0 From 80a7a8e4556efd1f052dbd01ab491937b33af639 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 4 Feb 2026 15:00:48 +0900 Subject: [PATCH 11/24] =?UTF-8?q?feat:=20=EB=8B=A4=EC=A4=91=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EA=B0=92=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테이블 관리 서비스에서 파이프로 구분된 문자열을 처리하는 로직을 추가하여, 날짜 타입은 날짜 범위로, 그 외 타입은 다중 선택(IN 조건)으로 처리하도록 개선하였습니다. - 엔티티 조인 검색 및 일반 컬럼 검색에서 다중 선택값을 처리하는 로직을 추가하여, 사용자 입력에 따른 필터링 기능을 강화하였습니다. - 버튼 컴포넌트에서 기본 텍스트 결정 로직을 개선하여 다양한 소스에서 버튼 텍스트를 가져올 수 있도록 하였습니다. - 테이블 리스트 컴포넌트에서 joinColumnMapping을 추가하여 필터링 기능을 개선하였습니다. --- .../src/services/tableManagementService.ts | 98 ++++++++++++++++--- .../config-panels/ButtonConfigPanel.tsx | 4 +- .../ButtonPrimaryComponent.tsx | 26 ++++- .../v2-table-list/TableListComponent.tsx | 15 ++- 4 files changed, 123 insertions(+), 20 deletions(-) diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 09a9691d..da7a3981 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1461,6 +1461,40 @@ export class TableManagementService { }); } + // 🔧 파이프로 구분된 문자열 처리 (객체에서 추출한 actualValue도 처리) + if (typeof actualValue === "string" && actualValue.includes("|")) { + const columnInfo = await this.getColumnWebTypeInfo( + tableName, + columnName + ); + + // 날짜 타입이면 날짜 범위로 처리 + if ( + columnInfo && + (columnInfo.webType === "date" || columnInfo.webType === "datetime") + ) { + return this.buildDateRangeCondition(columnName, actualValue, paramIndex); + } + + // 그 외 타입이면 다중선택(IN 조건)으로 처리 + const multiValues = actualValue + .split("|") + .filter((v: string) => v.trim() !== ""); + if (multiValues.length > 0) { + const placeholders = multiValues + .map((_: string, idx: number) => `$${paramIndex + idx}`) + .join(", "); + logger.info( + `🔍 다중선택 필터 적용 (객체에서 추출): ${columnName} IN (${multiValues.join(", ")})` + ); + return { + whereClause: `${columnName}::text IN (${placeholders})`, + values: multiValues, + paramCount: multiValues.length, + }; + } + } + // "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음 if ( actualValue === "__ALL__" || @@ -3428,15 +3462,37 @@ export class TableManagementService { // 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색 const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`; const alias = aliasMap.get(aliasKey); - whereConditions.push( - `${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'` - ); - entitySearchColumns.push( - `${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})` - ); - logger.info( - `🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})` - ); + + // 🔧 파이프로 구분된 다중 선택값 처리 + if (safeValue.includes("|")) { + const multiValues = safeValue + .split("|") + .filter((v: string) => v.trim() !== ""); + if (multiValues.length > 0) { + const inClause = multiValues + .map((v: string) => `'${v}'`) + .join(", "); + whereConditions.push( + `${alias}.${joinConfig.displayColumn}::text IN (${inClause})` + ); + entitySearchColumns.push( + `${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})` + ); + logger.info( + `🎯 Entity 조인 다중선택 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} IN (${multiValues.join(", ")}) (별칭: ${alias})` + ); + } + } else { + whereConditions.push( + `${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'` + ); + entitySearchColumns.push( + `${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})` + ); + logger.info( + `🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})` + ); + } } else if (key === "writer_dept_code") { // writer_dept_code: user_info.dept_code에서 검색 const userAliasKey = Array.from(aliasMap.keys()).find((k) => @@ -3473,10 +3529,26 @@ export class TableManagementService { } } else { // 일반 컬럼인 경우: 메인 테이블에서 검색 - whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`); - logger.info( - `🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'` - ); + // 🔧 파이프로 구분된 다중 선택값 처리 + if (safeValue.includes("|")) { + const multiValues = safeValue + .split("|") + .filter((v: string) => v.trim() !== ""); + if (multiValues.length > 0) { + const inClause = multiValues + .map((v: string) => `'${v}'`) + .join(", "); + whereConditions.push(`main.${key}::text IN (${inClause})`); + logger.info( + `🔍 다중선택 컬럼 검색: ${key} → main.${key} IN (${multiValues.join(", ")})` + ); + } + } else { + whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`); + logger.info( + `🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'` + ); + } } } } diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index b822aeee..6ea347c2 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -834,8 +834,10 @@ export const ButtonConfigPanel: React.FC = ({ {/* 이벤트 버스 */} 이벤트 발송 - {/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김 + {/* 복사 */} 복사 (품목코드 초기화) + + {/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김 연관 데이터 버튼 모달 열기 (deprecated) 데이터 전달 + 모달 열기 테이블 이력 보기 diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index 83a7771d..918d7560 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -1324,7 +1324,31 @@ export const ButtonPrimaryComponent: React.FC = ({ ...userStyle, }; - const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"; + // 버튼 텍스트 결정 (다양한 소스에서 가져옴) + // "기본 버튼"은 컴포넌트 생성 시 기본값이므로 무시 + const labelValue = component.label === "기본 버튼" ? undefined : component.label; + + // 액션 타입에 따른 기본 텍스트 (modal 액션과 동일하게) + const actionType = processedConfig.action?.type || component.componentConfig?.action?.type; + const actionDefaultText: Record = { + save: "저장", + delete: "삭제", + modal: "등록", + edit: "수정", + copy: "복사", + close: "닫기", + cancel: "취소", + }; + + const buttonContent = + processedConfig.text || + component.webTypeConfig?.text || + component.componentConfig?.text || + component.config?.text || + component.style?.labelText || + labelValue || + actionDefaultText[actionType as string] || + "버튼"; return ( <> diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index c99f9876..02ef8643 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -459,6 +459,9 @@ export const TableListComponent: React.FC = ({ // 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함 const [filterGroups, setFilterGroups] = useState([]); + + // 🆕 joinColumnMapping - filteredData에서 사용하므로 먼저 정의해야 함 + const [joinColumnMapping, setJoinColumnMapping] = useState>({}); // 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터 const filteredData = useMemo(() => { @@ -473,14 +476,17 @@ export const TableListComponent: React.FC = ({ }); } - // 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용) + // 2. 헤더 필터 적용 (joinColumnMapping 사용 - 조인된 컬럼과 일치해야 함) if (Object.keys(headerFilters).length > 0) { result = result.filter((row) => { return Object.entries(headerFilters).every(([columnName, values]) => { if (values.size === 0) return true; - // 여러 가능한 컬럼명 시도 - const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; + // joinColumnMapping을 사용하여 조인된 컬럼명 확인 + const mappedColumnName = joinColumnMapping[columnName] || columnName; + + // 여러 가능한 컬럼명 시도 (mappedColumnName 우선) + const cellValue = row[mappedColumnName] ?? row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : ""; return values.has(cellStr); @@ -541,7 +547,7 @@ export const TableListComponent: React.FC = ({ } return result; - }, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups]); + }, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups, joinColumnMapping]); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); @@ -554,7 +560,6 @@ export const TableListComponent: React.FC = ({ const [tableLabel, setTableLabel] = useState(""); const [localPageSize, setLocalPageSize] = useState(tableConfig.pagination?.pageSize || 20); const [displayColumns, setDisplayColumns] = useState([]); - const [joinColumnMapping, setJoinColumnMapping] = useState>({}); const [columnMeta, setColumnMeta] = useState< Record >({}); -- 2.43.0 From 7ec5a438d450df44f50b09abf3e0beaf48bf4974 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 4 Feb 2026 17:25:49 +0900 Subject: [PATCH 12/24] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=A0=88=EA=B1=B0?= =?UTF-8?q?=EC=8B=9C=20=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테이블 관리 서비스에서 검색 옵션에 operator를 추가하여 정확한 일치(equals) 및 부분 일치(contains) 검색을 지원하도록 개선하였습니다. - 파일 업로드 컴포넌트에서 레거시 file-upload 기능을 통합하여 안정적인 파일 업로드를 제공하며, V2Media와의 호환성을 강화하였습니다. - DynamicComponentRenderer에서 파일 업로드 컴포넌트의 디버깅 로깅을 추가하여 문제 해결을 용이하게 하였습니다. - 웹 타입 매핑에서 파일 및 이미지 타입을 레거시 file-upload로 변경하여 일관성을 유지하였습니다. --- .../src/services/tableManagementService.ts | 23 +- .../app/(main)/screens/[screenId]/page.tsx | 3 +- .../screen/InteractiveScreenViewerDynamic.tsx | 11 +- .../screen/panels/ComponentsPanel.tsx | 2 +- frontend/components/v2/V2Media.tsx | 1528 +++++++++-------- .../lib/registry/DynamicComponentRenderer.tsx | 7 +- .../components/v2-media/V2MediaRenderer.tsx | 120 +- .../TableSearchWidget.tsx | 12 + frontend/lib/utils/webTypeMapping.ts | 20 +- frontend/types/v2-components.ts | 18 +- 10 files changed, 957 insertions(+), 787 deletions(-) diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index da7a3981..6e8d0b7b 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -3403,14 +3403,16 @@ export class TableManagementService { if (options.search) { for (const [key, value] of Object.entries(options.search)) { - // 검색값 추출 (객체 형태일 수 있음) + // 검색값 및 operator 추출 (객체 형태일 수 있음) let searchValue = value; + let operator = "contains"; // 기본값: 부분 일치 if ( typeof value === "object" && value !== null && "value" in value ) { searchValue = value.value; + operator = (value as any).operator || "contains"; } // 빈 값이면 스킵 @@ -3482,7 +3484,19 @@ export class TableManagementService { `🎯 Entity 조인 다중선택 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} IN (${multiValues.join(", ")}) (별칭: ${alias})` ); } + } else if (operator === "equals") { + // 🔧 equals 연산자: 정확히 일치 + whereConditions.push( + `${alias}.${joinConfig.displayColumn}::text = '${safeValue}'` + ); + entitySearchColumns.push( + `${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})` + ); + logger.info( + `🎯 Entity 조인 정확히 일치 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} = '${safeValue}' (별칭: ${alias})` + ); } else { + // 기본: 부분 일치 (ILIKE) whereConditions.push( `${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'` ); @@ -3543,7 +3557,14 @@ export class TableManagementService { `🔍 다중선택 컬럼 검색: ${key} → main.${key} IN (${multiValues.join(", ")})` ); } + } else if (operator === "equals") { + // 🔧 equals 연산자: 정확히 일치 + whereConditions.push(`main.${key}::text = '${safeValue}'`); + logger.info( + `🔍 정확히 일치 검색: ${key} → main.${key} = '${safeValue}'` + ); } else { + // 기본: 부분 일치 (ILIKE) whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`); logger.info( `🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'` diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 0ce2bae5..14230b14 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -239,7 +239,8 @@ function ScreenViewPage() { compType?.includes("textarea") || compType?.includes("v2-input") || compType?.includes("v2-select") || - compType?.includes("v2-media"); // 🆕 미디어 컴포넌트 추가 + compType?.includes("v2-media") || + compType?.includes("file-upload"); // 🆕 레거시 파일 업로드 포함 const hasColumnName = !!(comp as any).columnName; return isInputType && hasColumnName; }); diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 8dc5da89..5770a468 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -562,13 +562,18 @@ export const InteractiveScreenViewerDynamic: React.FC = {}; - // v2-media 컴포넌트의 columnName 목록 수집 + // 파일 업로드 컴포넌트의 columnName 목록 수집 (v2-media, file-upload 모두 포함) const mediaColumnNames = new Set( allComponents - .filter((c: any) => c.componentType === "v2-media" || c.url?.includes("v2-media")) + .filter((c: any) => + c.componentType === "v2-media" || + c.componentType === "file-upload" || + c.url?.includes("v2-media") || + c.url?.includes("file-upload") + ) .map((c: any) => c.columnName || c.componentConfig?.columnName) .filter(Boolean) ); diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index 8f055bc3..9464a204 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -80,7 +80,7 @@ export function ComponentsPanel({ "textarea-basic", // V2 컴포넌트로 대체됨 "image-widget", // → V2Media (image) - "file-upload", // → V2Media (file) + // "file-upload", // 🆕 레거시 컴포넌트 노출 (안정적인 파일 업로드) "entity-search-input", // → V2Select (entity 모드) "autocomplete-search-input", // → V2Select (autocomplete 모드) // DataFlow 전용 (일반 화면에서 불필요) diff --git a/frontend/components/v2/V2Media.tsx b/frontend/components/v2/V2Media.tsx index 6a154863..7321808f 100644 --- a/frontend/components/v2/V2Media.tsx +++ b/frontend/components/v2/V2Media.tsx @@ -3,650 +3,79 @@ /** * V2Media * - * 통합 미디어 컴포넌트 + * 통합 미디어 컴포넌트 (레거시 FileUploadComponent 기능 통합) * - file: 파일 업로드 * - image: 이미지 업로드/표시 * - video: 비디오 * - audio: 오디오 + * + * 핵심 기능: + * - FileViewerModal / FileManagerModal (자세히보기) + * - 대표 이미지 설정 + * - 레코드 모드 (테이블/레코드 연결) + * - 전역 파일 상태 관리 + * - 파일 다운로드/삭제 + * - DB에서 기존 파일 로드 */ import React, { forwardRef, useCallback, useRef, useState, useEffect } from "react"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import { V2MediaProps } from "@/types/v2-components"; -import { Upload, X, File, Image as ImageIcon, Video, Music, Eye, Download, Trash2, Plus } from "lucide-react"; +import { + Upload, X, File, Image as ImageIcon, Video, Music, Eye, Download, Trash2, Plus, + FileText, Archive, Presentation, FileImage, FileVideo, FileAudio +} from "lucide-react"; import { apiClient } from "@/lib/api/client"; +import { toast } from "sonner"; +import { uploadFiles, downloadFile, deleteFile, getComponentFiles } from "@/lib/api/file"; +import { GlobalFileManager } from "@/lib/api/globalFile"; +import { formatFileSize } from "@/lib/utils"; +import { useAuth } from "@/hooks/useAuth"; + +// 레거시 모달 컴포넌트 import +import { FileViewerModal } from "@/lib/registry/components/file-upload/FileViewerModal"; +import { FileManagerModal } from "@/lib/registry/components/file-upload/FileManagerModal"; +import type { FileInfo, FileUploadConfig } from "@/lib/registry/components/file-upload/types"; /** - * 파일 크기 포맷팅 + * 파일 아이콘 매핑 */ -function formatFileSize(bytes: number): string { - if (bytes === 0) return "0 Bytes"; - const k = 1024; - const sizes = ["Bytes", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; -} +const getFileIcon = (extension: string) => { + const ext = extension.toLowerCase().replace(".", ""); -/** - * 파일 타입 아이콘 가져오기 - */ -function getFileIcon(type: string) { - if (type.startsWith("image/")) return ImageIcon; - if (type.startsWith("video/")) return Video; - if (type.startsWith("audio/")) return Music; - return File; -} - -/** - * 파일 업로드 컴포넌트 - */ -const FileUploader = forwardRef void; - multiple?: boolean; - accept?: string; - maxSize?: number; - disabled?: boolean; - uploadEndpoint?: string; - className?: string; -}>(({ - value, - onChange, - multiple = false, - accept = "*", - maxSize = 10485760, // 10MB - disabled, - uploadEndpoint = "/files/upload", - className -}, ref) => { - const inputRef = useRef(null); - const [isDragging, setIsDragging] = useState(false); - const [isUploading, setIsUploading] = useState(false); - const [error, setError] = useState(null); - // 업로드 직후 미리보기를 위한 로컬 상태 - const [localPreviewUrls, setLocalPreviewUrls] = useState([]); - - // objid를 미리보기 URL로 변환 - const toPreviewUrl = (val: any): string => { - if (!val) return ""; - const strVal = String(val); - if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal; - if (/^\d+$/.test(strVal)) return `/api/files/preview/${strVal}`; - return strVal; - }; - - // value를 URL 형태의 files 배열로 변환 - const rawFiles = Array.isArray(value) ? value : value ? [value] : []; - const filesFromValue = rawFiles.map(toPreviewUrl).filter(Boolean); - - console.log("[FileUploader] value:", value, "rawFiles:", rawFiles, "filesFromValue:", filesFromValue, "localPreviewUrls:", localPreviewUrls); - - // value가 변경되면 로컬 상태 초기화 - useEffect(() => { - if (filesFromValue.length > 0) { - setLocalPreviewUrls([]); - } - }, [filesFromValue.length]); - - // 최종 files: value에서 온 파일 + 로컬 미리보기 (중복 제거) - const files = filesFromValue.length > 0 ? filesFromValue : localPreviewUrls; - - console.log("[FileUploader] final files:", files); - - // 파일 선택 핸들러 - const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => { - if (!selectedFiles || selectedFiles.length === 0) return; - - setError(null); - const fileArray = Array.from(selectedFiles); - - // 크기 검증 - for (const file of fileArray) { - if (file.size > maxSize) { - setError(`파일 크기가 ${formatFileSize(maxSize)}를 초과합니다: ${file.name}`); - return; - } - } - - setIsUploading(true); - - try { - const uploadedUrls: string[] = []; - - for (const file of fileArray) { - const formData = new FormData(); - formData.append("files", file); - - const response = await apiClient.post(uploadEndpoint, formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }); - - const data = response.data; - console.log("[FileUploader] 업로드 응답:", data); - // 백엔드 응답: { success: true, files: [{ filePath, objid, ... }] } - if (data.success && data.files && data.files.length > 0) { - const uploadedFile = data.files[0]; - const objid = String(uploadedFile.objid); - uploadedUrls.push(objid); - // 즉시 미리보기를 위해 로컬 상태에 URL 저장 - const previewUrl = `/api/files/preview/${objid}`; - setLocalPreviewUrls(prev => multiple ? [...prev, previewUrl] : [previewUrl]); - } else if (data.objid) { - const objid = String(data.objid); - uploadedUrls.push(objid); - const previewUrl = `/api/files/preview/${objid}`; - setLocalPreviewUrls(prev => multiple ? [...prev, previewUrl] : [previewUrl]); - } else if (data.url) { - uploadedUrls.push(data.url); - setLocalPreviewUrls(prev => multiple ? [...prev, data.url] : [data.url]); - } else if (data.filePath) { - uploadedUrls.push(data.filePath); - setLocalPreviewUrls(prev => multiple ? [...prev, data.filePath] : [data.filePath]); - } - } - - if (multiple) { - const newValue = [...filesFromValue, ...uploadedUrls]; - console.log("[FileUploader] onChange called with:", newValue); - onChange?.(newValue); - } else { - const newValue = uploadedUrls[0] || ""; - console.log("[FileUploader] onChange called with:", newValue); - onChange?.(newValue); - } - } catch (err) { - setError(err instanceof Error ? err.message : "업로드 중 오류가 발생했습니다"); - } finally { - setIsUploading(false); - } - }, [filesFromValue, multiple, maxSize, uploadEndpoint, onChange]); - - // 드래그 앤 드롭 핸들러 - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(true); - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - }, []); - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - handleFileSelect(e.dataTransfer.files); - }, [handleFileSelect]); - - // 파일 삭제 핸들러 - const handleRemove = useCallback((index: number) => { - // 로컬 미리보기도 삭제 - setLocalPreviewUrls(prev => prev.filter((_, i) => i !== index)); - // value에서 온 파일 삭제 - const newFiles = filesFromValue.filter((_, i) => i !== index); - onChange?.(multiple ? newFiles : ""); - }, [filesFromValue, multiple, onChange]); - - // 첫 번째 파일이 이미지인지 확인 - const firstFile = files[0]; - const isFirstFileImage = firstFile && ( - /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(firstFile) || - firstFile.includes("/preview/") || - firstFile.includes("/api/files/preview/") - ); - - return ( -
- {/* 메인 업로드 박스 - 이미지가 있으면 박스 안에 표시 */} -
!disabled && !firstFile && inputRef.current?.click()} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - > - handleFileSelect(e.target.files)} - className="hidden" - /> - - {firstFile ? ( - // 파일이 있으면 박스 안에 표시 -
- {isFirstFileImage ? ( - // 이미지 미리보기 - 업로드된 이미지 - ) : ( - // 일반 파일 -
- - - {firstFile.split("/").pop()} - -
- )} - {/* 호버 시 액션 버튼 */} -
- {isFirstFileImage && ( - - )} - - -
-
- ) : isUploading ? ( -
-
- 업로드 중... -
- ) : ( -
- -
- 클릭 - 또는 파일을 드래그하세요 -
-
- 최대 {formatFileSize(maxSize)} - {accept !== "*" && ` (${accept})`} -
-
- )} -
- - {/* 에러 메시지 */} - {error && ( -
{error}
- )} - - {/* 추가 파일 목록 (multiple일 때 2번째 파일부터) */} - {multiple && files.length > 1 && ( -
- {files.slice(1).map((file, index) => { - const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(file) || - file.includes("/preview/") || - file.includes("/api/files/preview/"); - - return ( -
- {isImage ? ( - {`파일 - ) : ( - - )} -
- -
-
- ); - })} - {/* 추가 버튼 */} -
!disabled && inputRef.current?.click()} - > - -
-
- )} -
- ); -}); -FileUploader.displayName = "FileUploader"; - -/** - * 이미지 업로드/표시 컴포넌트 - */ -const ImageUploader = forwardRef void; - multiple?: boolean; - accept?: string; - maxSize?: number; - preview?: boolean; - disabled?: boolean; - uploadEndpoint?: string; - className?: string; -}>(({ - value, - onChange, - multiple = false, - accept = "image/*", - maxSize = 10485760, - preview = true, - disabled, - uploadEndpoint = "/files/upload", - className -}, ref) => { - const inputRef = useRef(null); - const [isDragging, setIsDragging] = useState(false); - const [isUploading, setIsUploading] = useState(false); - const [previewUrl, setPreviewUrl] = useState(null); - - // objid를 미리보기 URL로 변환 - const toPreviewUrl = (val: any): string => { - if (!val) return ""; - const strVal = String(val); - if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal; - if (/^\d+$/.test(strVal)) return `/api/files/preview/${strVal}`; - return strVal; - }; - - // value를 URL 형태의 images 배열로 변환 - const rawImages = Array.isArray(value) ? value : value ? [value] : []; - const images = rawImages.map(toPreviewUrl).filter(Boolean); - - // 파일 선택 핸들러 - const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => { - if (!selectedFiles || selectedFiles.length === 0) return; - - setIsUploading(true); - - try { - const fileArray = Array.from(selectedFiles); - const uploadedUrls: string[] = []; - - for (const file of fileArray) { - // 미리보기 생성 - if (preview) { - const reader = new FileReader(); - reader.onload = () => setPreviewUrl(reader.result as string); - reader.readAsDataURL(file); - } - - const formData = new FormData(); - formData.append("files", file); - - try { - const response = await apiClient.post(uploadEndpoint, formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }); - - const data = response.data; - // 백엔드 응답: { success: true, files: [{ filePath, objid, ... }] } - if (data.success && data.files && data.files.length > 0) { - const uploadedFile = data.files[0]; - // objid만 저장 (DB 저장용) - 표시는 V2MediaRenderer에서 URL로 변환 - uploadedUrls.push(String(uploadedFile.objid)); - } else if (data.objid) { - uploadedUrls.push(String(data.objid)); - } else if (data.url) { - uploadedUrls.push(data.url); - } else if (data.filePath) { - uploadedUrls.push(data.filePath); - } - } catch (err) { - console.error("이미지 업로드 실패:", err); - } - } - - if (multiple) { - onChange?.([...images, ...uploadedUrls]); - } else { - onChange?.(uploadedUrls[0] || ""); - } - } finally { - setIsUploading(false); - setPreviewUrl(null); - } - }, [images, multiple, preview, uploadEndpoint, onChange]); - - // 이미지 삭제 핸들러 - const handleRemove = useCallback((index: number) => { - const newImages = images.filter((_, i) => i !== index); - onChange?.(multiple ? newImages : ""); - }, [images, multiple, onChange]); - - // 첫 번째 이미지 (메인 박스에 표시) - const mainImage = images[0]; - // 추가 이미지들 (multiple일 때만) - const additionalImages = multiple ? images.slice(1) : []; - - return ( -
- {/* 메인 업로드 박스 - 첫 번째 이미지가 있으면 박스 안에 표시 */} -
!disabled && !mainImage && inputRef.current?.click()} - onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} - onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }} - onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFileSelect(e.dataTransfer.files); }} - > - handleFileSelect(e.target.files)} - className="hidden" - /> - - {mainImage ? ( - // 이미지가 있으면 박스 안에 표시 -
- 업로드된 이미지 - {/* 호버 시 액션 버튼 */} -
- - - -
-
- ) : isUploading ? ( -
-
- 업로드 중... -
- ) : ( -
- - - 클릭 또는 파일을 드래그하세요 - - - 최대 {Math.round(maxSize / 1024 / 1024)} MB (*/*) - -
- )} -
- - {/* 추가 이미지 목록 (multiple일 때만) */} - {multiple && additionalImages.length > 0 && ( -
- {additionalImages.map((src, index) => ( -
- {`이미지 -
- -
-
- ))} - {/* 추가 버튼 */} -
!disabled && inputRef.current?.click()} - > - -
-
- )} -
- ); -}); -ImageUploader.displayName = "ImageUploader"; - -/** - * 비디오 컴포넌트 - */ -const VideoPlayer = forwardRef(({ value, className }, ref) => { - if (!value) { - return ( -
-
- ); + if (["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(ext)) { + return ; + } + if (["mp4", "avi", "mov", "wmv", "flv", "webm"].includes(ext)) { + return ; + } + if (["mp3", "wav", "flac", "aac", "ogg"].includes(ext)) { + return ; + } + if (["pdf"].includes(ext)) { + return ; + } + if (["doc", "docx", "hwp", "hwpx", "pages"].includes(ext)) { + return ; + } + if (["xls", "xlsx", "hcell", "numbers"].includes(ext)) { + return ; + } + if (["ppt", "pptx", "hanshow", "keynote"].includes(ext)) { + return ; + } + if (["zip", "rar", "7z", "tar", "gz"].includes(ext)) { + return ; } - return ( -
-
- ); -}); -VideoPlayer.displayName = "VideoPlayer"; + return ; +}; /** - * 오디오 컴포넌트 - */ -const AudioPlayer = forwardRef(({ value, className }, ref) => { - if (!value) { - return ( -
- -
- ); - } - - return ( -
-
- ); -}); -AudioPlayer.displayName = "AudioPlayer"; - -/** - * 메인 V2Media 컴포넌트 + * V2 미디어 컴포넌트 (레거시 기능 통합) */ export const V2Media = forwardRef( (props, ref) => { @@ -661,83 +90,660 @@ export const V2Media = forwardRef( config: configProp, value, onChange, + formData, + columnName, + tableName, + onFormDataChange, + isDesignMode = false, + isInteractive = true, + onUpdate, + ...restProps } = props; - // config가 없으면 기본값 사용 - const config = configProp || { type: "image" as const }; + // 인증 정보 + const { user } = useAuth(); + + // config 기본값 + const config = configProp || { type: "file" as const }; + const mediaType = config.type || "file"; + + // 파일 상태 + const [uploadedFiles, setUploadedFiles] = useState([]); + const [uploadStatus, setUploadStatus] = useState<"idle" | "uploading" | "success" | "error">("idle"); + const [dragOver, setDragOver] = useState(false); + const [representativeImageUrl, setRepresentativeImageUrl] = useState(null); - // objid를 미리보기 URL로 변환하는 함수 - const toPreviewUrl = (val: any): string => { - if (!val) return ""; - const strVal = String(val); - if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal; - if (/^\d+$/.test(strVal)) return `/api/files/preview/${strVal}`; - return strVal; - }; + // 모달 상태 + const [viewerFile, setViewerFile] = useState(null); + const [isViewerOpen, setIsViewerOpen] = useState(false); + const [isFileManagerOpen, setIsFileManagerOpen] = useState(false); - // value를 URL로 변환 (배열 또는 단일 값) - const convertedValue = Array.isArray(value) - ? value.map(toPreviewUrl) - : value ? toPreviewUrl(value) : value; - - console.log("[V2Media] original value:", value, "-> converted:", convertedValue, "onChange:", typeof onChange); + const fileInputRef = useRef(null); - // 타입별 미디어 컴포넌트 렌더링 - const renderMedia = () => { - const isDisabled = disabled || readonly; - const mediaType = config.type || "image"; + // 레코드 모드 판단 + const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_')); + const recordTableName = formData?.tableName || tableName; + const recordId = formData?.id; + const effectiveColumnName = isRecordMode ? 'attachments' : (columnName || id || 'attachments'); - switch (mediaType) { - case "file": - return ( - - ); - - case "image": - return ( - - ); - - case "video": - return ( - - ); - - case "audio": - return ( - - ); - - default: - return ( - - ); + // 레코드용 targetObjid 생성 + const getRecordTargetObjid = useCallback(() => { + if (isRecordMode && recordTableName && recordId) { + return `${recordTableName}:${recordId}:${effectiveColumnName}`; } + return null; + }, [isRecordMode, recordTableName, recordId, effectiveColumnName]); + + // 레코드별 고유 키 생성 + const getUniqueKey = useCallback(() => { + if (isRecordMode && recordTableName && recordId) { + return `v2media_${recordTableName}_${recordId}_${id}`; + } + return `v2media_${id}`; + }, [isRecordMode, recordTableName, recordId, id]); + + // 레코드 ID 변경 시 파일 목록 초기화 + const prevRecordIdRef = useRef(null); + useEffect(() => { + if (prevRecordIdRef.current !== recordId) { + prevRecordIdRef.current = recordId; + if (isRecordMode) { + setUploadedFiles([]); + } + } + }, [recordId, isRecordMode]); + + // 컴포넌트 마운트 시 localStorage에서 파일 복원 + useEffect(() => { + if (!id) return; + + try { + const backupKey = getUniqueKey(); + const backupFiles = localStorage.getItem(backupKey); + if (backupFiles) { + const parsedFiles = JSON.parse(backupFiles); + if (parsedFiles.length > 0) { + setUploadedFiles(parsedFiles); + + if (typeof window !== "undefined") { + (window as any).globalFileState = { + ...(window as any).globalFileState, + [backupKey]: parsedFiles, + }; + } + } + } + } catch (e) { + console.warn("파일 복원 실패:", e); + } + }, [id, getUniqueKey, recordId]); + + // DB에서 파일 목록 로드 + const loadComponentFiles = useCallback(async () => { + if (!id) return false; + + try { + let screenId = formData?.screenId; + + if (!screenId && typeof window !== "undefined") { + const pathname = window.location.pathname; + const screenMatch = pathname.match(/\/screens\/(\d+)/); + if (screenMatch) { + screenId = parseInt(screenMatch[1]); + } + } + + if (!screenId && isDesignMode) { + screenId = 999999; + } + + if (!screenId) { + screenId = 0; + } + + const params = { + screenId, + componentId: id, + tableName: recordTableName || formData?.tableName || tableName, + recordId: recordId || formData?.id, + columnName: effectiveColumnName, + }; + + const response = await getComponentFiles(params); + + if (response.success) { + const formattedFiles = response.totalFiles.map((file: any) => ({ + objid: file.objid || file.id, + savedFileName: file.savedFileName || file.saved_file_name, + realFileName: file.realFileName || file.real_file_name, + fileSize: file.fileSize || file.file_size, + fileExt: file.fileExt || file.file_ext, + regdate: file.regdate, + status: file.status || "ACTIVE", + uploadedAt: file.uploadedAt || new Date().toISOString(), + targetObjid: file.targetObjid || file.target_objid, + filePath: file.filePath || file.file_path, + ...file, + })); + + // localStorage와 병합 + let finalFiles = formattedFiles; + const uniqueKey = getUniqueKey(); + try { + const backupFiles = localStorage.getItem(uniqueKey); + if (backupFiles) { + const parsedBackupFiles = JSON.parse(backupFiles); + const serverObjIds = new Set(formattedFiles.map((f: any) => f.objid)); + const additionalFiles = parsedBackupFiles.filter((f: any) => !serverObjIds.has(f.objid)); + finalFiles = [...formattedFiles, ...additionalFiles]; + } + } catch (e) { + console.warn("파일 병합 오류:", e); + } + + setUploadedFiles(finalFiles); + + if (typeof window !== "undefined") { + (window as any).globalFileState = { + ...(window as any).globalFileState, + [uniqueKey]: finalFiles, + }; + + GlobalFileManager.registerFiles(finalFiles, { + uploadPage: window.location.pathname, + componentId: id, + screenId: formData?.screenId, + recordId: recordId, + }); + + try { + localStorage.setItem(uniqueKey, JSON.stringify(finalFiles)); + } catch (e) { + console.warn("localStorage 백업 실패:", e); + } + } + return true; + } + } catch (error) { + console.error("파일 조회 오류:", error); + } + return false; + }, [id, tableName, columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, effectiveColumnName, isDesignMode]); + + // 파일 동기화 + useEffect(() => { + loadComponentFiles(); + }, [loadComponentFiles]); + + // 전역 상태 변경 감지 + useEffect(() => { + const handleGlobalFileStateChange = (event: CustomEvent) => { + const { componentId, files, isRestore } = event.detail; + + if (componentId === id) { + setUploadedFiles(files); + + try { + const backupKey = getUniqueKey(); + localStorage.setItem(backupKey, JSON.stringify(files)); + } catch (e) { + console.warn("localStorage 백업 실패:", e); + } + } + }; + + if (typeof window !== "undefined") { + window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); + return () => { + window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); + }; + } + }, [id, getUniqueKey]); + + // 파일 업로드 처리 + const handleFileUpload = useCallback( + async (files: File[]) => { + if (!files.length) return; + + // 중복 체크 + const existingFileNames = uploadedFiles.map((f) => f.realFileName.toLowerCase()); + const duplicates: string[] = []; + const uniqueFiles: File[] = []; + + files.forEach((file) => { + const fileName = file.name.toLowerCase(); + if (existingFileNames.includes(fileName)) { + duplicates.push(file.name); + } else { + uniqueFiles.push(file); + } + }); + + if (duplicates.length > 0) { + toast.error(`중복된 파일: ${duplicates.join(", ")}`); + if (uniqueFiles.length === 0) return; + toast.info(`${uniqueFiles.length}개의 새로운 파일만 업로드합니다.`); + } + + const filesToUpload = uniqueFiles.length > 0 ? uniqueFiles : files; + setUploadStatus("uploading"); + toast.loading("파일 업로드 중...", { id: "file-upload" }); + + try { + const effectiveTableName = recordTableName || formData?.tableName || tableName || "default_table"; + const effectiveRecordId = recordId || formData?.id; + + let screenId = formData?.screenId; + if (!screenId && typeof window !== "undefined") { + const pathname = window.location.pathname; + const screenMatch = pathname.match(/\/screens\/(\d+)/); + if (screenMatch) { + screenId = parseInt(screenMatch[1]); + } + } + + let targetObjid; + const effectiveIsRecordMode = isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith('temp_')); + + if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) { + targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`; + } else if (screenId) { + targetObjid = `screen_files:${screenId}:${id}:${effectiveColumnName}`; + } else { + targetObjid = `temp_${id}`; + } + + const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode; + + const finalLinkedTable = effectiveIsRecordMode + ? effectiveTableName + : (formData?.linkedTable || effectiveTableName); + + const uploadData = { + autoLink: formData?.autoLink || true, + linkedTable: finalLinkedTable, + recordId: effectiveRecordId || `temp_${id}`, + columnName: effectiveColumnName, + isVirtualFileColumn: formData?.isVirtualFileColumn || true, + docType: config?.docType || "DOCUMENT", + docTypeName: config?.docTypeName || "일반 문서", + companyCode: userCompanyCode, + tableName: effectiveTableName, + fieldName: effectiveColumnName, + targetObjid: targetObjid, + isRecordMode: effectiveIsRecordMode, + }; + + const response = await uploadFiles({ + files: filesToUpload, + ...uploadData, + }); + + if (response.success) { + const fileData = response.files || (response as any).data || []; + + if (fileData.length === 0) { + throw new Error("업로드된 파일 데이터를 받지 못했습니다."); + } + + const newFiles = fileData.map((file: any) => ({ + objid: file.objid || file.id, + savedFileName: file.saved_file_name || file.savedFileName, + realFileName: file.real_file_name || file.realFileName || file.name, + fileSize: file.file_size || file.fileSize || file.size, + fileExt: file.file_ext || file.fileExt || file.extension, + filePath: file.file_path || file.filePath || file.path, + docType: file.doc_type || file.docType, + docTypeName: file.doc_type_name || file.docTypeName, + targetObjid: file.target_objid || file.targetObjid, + parentTargetObjid: file.parent_target_objid || file.parentTargetObjid, + companyCode: file.company_code || file.companyCode, + writer: file.writer, + regdate: file.regdate, + status: file.status || "ACTIVE", + uploadedAt: new Date().toISOString(), + ...file, + })); + + const updatedFiles = [...uploadedFiles, ...newFiles]; + setUploadedFiles(updatedFiles); + setUploadStatus("success"); + + // localStorage 백업 + try { + const backupKey = getUniqueKey(); + localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); + } catch (e) { + console.warn("localStorage 백업 실패:", e); + } + + // 전역 상태 업데이트 + if (typeof window !== "undefined") { + const globalFileState = (window as any).globalFileState || {}; + const uniqueKey = getUniqueKey(); + globalFileState[uniqueKey] = updatedFiles; + (window as any).globalFileState = globalFileState; + + GlobalFileManager.registerFiles(newFiles, { + uploadPage: window.location.pathname, + componentId: id, + screenId: formData?.screenId, + recordId: recordId, + }); + + const syncEvent = new CustomEvent("globalFileStateChanged", { + detail: { + componentId: id, + uniqueKey: uniqueKey, + recordId: recordId, + files: updatedFiles, + fileCount: updatedFiles.length, + timestamp: Date.now(), + }, + }); + window.dispatchEvent(syncEvent); + } + + // 부모 컴포넌트 업데이트 + if (onUpdate) { + onUpdate({ + uploadedFiles: updatedFiles, + lastFileUpdate: Date.now(), + }); + } + + // onChange 콜백 (objid 배열 또는 단일 값) + const fileIds = updatedFiles.map((f) => f.objid); + const finalValue = config.multiple ? fileIds : fileIds[0] || ""; + const targetColumn = columnName || effectiveColumnName; + + console.log("📤 [V2Media] 파일 업로드 완료 - 값 전달:", { + columnName: targetColumn, + fileIds, + finalValue, + hasOnChange: !!onChange, + hasOnFormDataChange: !!onFormDataChange, + }); + + if (onChange) { + onChange(finalValue); + } + + // 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식 + if (onFormDataChange && targetColumn) { + console.log("📝 [V2Media] formData 업데이트:", { + columnName: targetColumn, + fileIds, + isRecordMode: effectiveIsRecordMode, + }); + // (fieldName: string, value: any) 형식으로 호출 + onFormDataChange(targetColumn, fileIds); + } + + // 그리드 파일 상태 새로고침 이벤트 발생 + if (typeof window !== "undefined") { + const refreshEvent = new CustomEvent("refreshFileStatus", { + detail: { + tableName: effectiveTableName, + recordId: effectiveRecordId, + columnName: targetColumn, + targetObjid: targetObjid, + fileCount: updatedFiles.length, + }, + }); + window.dispatchEvent(refreshEvent); + } + + toast.dismiss("file-upload"); + toast.success(`${newFiles.length}개 파일 업로드 완료`); + } else { + throw new Error(response.message || (response as any).error || "파일 업로드 실패"); + } + } catch (error) { + console.error("파일 업로드 오류:", error); + setUploadStatus("error"); + toast.dismiss("file-upload"); + toast.error(`업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`); + } + }, + [config, uploadedFiles, onChange, id, getUniqueKey, recordId, isRecordMode, recordTableName, effectiveColumnName, tableName, onUpdate, onFormDataChange, user, columnName], + ); + + // 파일 뷰어 열기/닫기 + const handleFileView = useCallback((file: FileInfo) => { + setViewerFile(file); + setIsViewerOpen(true); + }, []); + + const handleViewerClose = useCallback(() => { + setIsViewerOpen(false); + setViewerFile(null); + }, []); + + // 파일 다운로드 + const handleFileDownload = useCallback(async (file: FileInfo) => { + try { + await downloadFile({ + fileId: file.objid, + serverFilename: file.savedFileName, + originalName: file.realFileName, + }); + toast.success(`${file.realFileName} 다운로드 완료`); + } catch (error) { + console.error("파일 다운로드 오류:", error); + toast.error("파일 다운로드 실패"); + } + }, []); + + // 파일 삭제 + const handleFileDelete = useCallback( + async (file: FileInfo | string) => { + try { + const fileId = typeof file === "string" ? file : file.objid; + const fileName = typeof file === "string" ? "파일" : file.realFileName; + const serverFilename = typeof file === "string" ? "temp_file" : file.savedFileName; + + await deleteFile(fileId, serverFilename); + + const updatedFiles = uploadedFiles.filter((f) => f.objid !== fileId); + setUploadedFiles(updatedFiles); + + // localStorage 백업 + try { + const backupKey = getUniqueKey(); + localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); + } catch (e) { + console.warn("localStorage 백업 실패:", e); + } + + // 전역 상태 업데이트 + if (typeof window !== "undefined") { + const globalFileState = (window as any).globalFileState || {}; + const uniqueKey = getUniqueKey(); + globalFileState[uniqueKey] = updatedFiles; + (window as any).globalFileState = globalFileState; + + const syncEvent = new CustomEvent("globalFileStateChanged", { + detail: { + componentId: id, + uniqueKey: uniqueKey, + recordId: recordId, + files: updatedFiles, + fileCount: updatedFiles.length, + timestamp: Date.now(), + action: "delete", + }, + }); + window.dispatchEvent(syncEvent); + } + + if (onUpdate) { + onUpdate({ + uploadedFiles: updatedFiles, + lastFileUpdate: Date.now(), + }); + } + + // onChange 콜백 + const fileIds = updatedFiles.map((f) => f.objid); + const finalValue = config.multiple ? fileIds : fileIds[0] || ""; + const targetColumn = columnName || effectiveColumnName; + + console.log("🗑️ [V2Media] 파일 삭제 완료 - 값 전달:", { + columnName: targetColumn, + fileIds, + finalValue, + }); + + if (onChange) { + onChange(finalValue); + } + + // 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식 + if (onFormDataChange && targetColumn) { + console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", { + columnName: targetColumn, + fileIds, + }); + // (fieldName: string, value: any) 형식으로 호출 + onFormDataChange(targetColumn, fileIds); + } + + toast.success(`${fileName} 삭제 완료`); + } catch (error) { + console.error("파일 삭제 오류:", error); + toast.error("파일 삭제 실패"); + } + }, + [uploadedFiles, onUpdate, id, isRecordMode, onFormDataChange, recordTableName, recordId, effectiveColumnName, getUniqueKey, onChange, config.multiple, columnName], + ); + + // 대표 이미지 로드 + const loadRepresentativeImage = useCallback( + async (file: FileInfo) => { + try { + const isImage = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes( + file.fileExt.toLowerCase().replace(".", "") + ); + + if (!isImage) { + setRepresentativeImageUrl(null); + return; + } + + if (!file.objid || file.objid === "0" || file.objid === "") { + setRepresentativeImageUrl(null); + return; + } + + const response = await apiClient.get(`/files/download/${file.objid}`, { + params: { serverFilename: file.savedFileName }, + responseType: "blob", + }); + + const blob = new Blob([response.data]); + const url = window.URL.createObjectURL(blob); + + if (representativeImageUrl) { + window.URL.revokeObjectURL(representativeImageUrl); + } + + setRepresentativeImageUrl(url); + } catch (error) { + console.error("대표 이미지 로드 실패:", error); + setRepresentativeImageUrl(null); + } + }, + [representativeImageUrl], + ); + + // 대표 이미지 설정 + const handleSetRepresentative = useCallback( + async (file: FileInfo) => { + try { + const { setRepresentativeFile } = await import("@/lib/api/file"); + await setRepresentativeFile(file.objid); + + const updatedFiles = uploadedFiles.map((f) => ({ + ...f, + isRepresentative: f.objid === file.objid, + })); + + setUploadedFiles(updatedFiles); + loadRepresentativeImage(file); + } catch (e) { + console.error("대표 파일 설정 실패:", e); + } + }, + [uploadedFiles, loadRepresentativeImage] + ); + + // uploadedFiles 변경 시 대표 이미지 로드 + useEffect(() => { + const representativeFile = uploadedFiles.find(f => f.isRepresentative) || uploadedFiles[0]; + if (representativeFile) { + loadRepresentativeImage(representativeFile); + } else { + setRepresentativeImageUrl(null); + } + + return () => { + if (representativeImageUrl) { + window.URL.revokeObjectURL(representativeImageUrl); + } + }; + }, [uploadedFiles]); + + // 드래그 앤 드롭 핸들러 + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!readonly && !disabled) { + setDragOver(true); + } + }, [readonly, disabled]); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + + if (!readonly && !disabled) { + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + handleFileUpload(files); + } + } + }, [readonly, disabled, handleFileUpload]); + + // 파일 선택 + const handleFileSelect = useCallback(() => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }, []); + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length > 0) { + handleFileUpload(files); + } + e.target.value = ''; + }, [handleFileUpload]); + + // 파일 설정 + const fileConfig: FileUploadConfig = { + accept: config.accept || "*/*", + multiple: config.multiple || false, + maxSize: config.maxSize || 10 * 1024 * 1024, + disabled: disabled, + readonly: readonly, }; const showLabel = label && style?.labelDisplay !== false; @@ -749,11 +755,9 @@ export const V2Media = forwardRef( ref={ref} id={id} className="flex w-full flex-col" - style={{ - width: componentWidth, - // 🔧 높이는 컨테이너가 아닌 컨텐츠 영역에만 적용 (라벨 높이는 별도) - }} + style={{ width: componentWidth }} > + {/* 라벨 */} {showLabel && ( )} + + {/* 메인 컨테이너 */}
- {renderMedia()} +
+ {/* 숨겨진 파일 입력 */} + + + {/* 파일이 있는 경우: 대표 이미지/파일 표시 */} + {uploadedFiles.length > 0 ? (() => { + const representativeFile = uploadedFiles.find(f => f.isRepresentative) || uploadedFiles[0]; + const isImage = representativeFile && ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes( + representativeFile.fileExt.toLowerCase().replace(".", "") + ); + + return ( + <> + {isImage && representativeImageUrl ? ( +
+ {representativeFile.realFileName} +
+ ) : isImage && !representativeImageUrl ? ( +
+
+

이미지 로딩 중...

+
+ ) : ( +
+ {getFileIcon(representativeFile.fileExt)} +

+ {representativeFile.realFileName} +

+ + 대표 파일 + +
+ )} + + {/* 우측 하단 자세히보기 버튼 */} +
+ +
+ + ); + })() : ( + // 파일이 없는 경우: 업로드 안내 +
!disabled && !readonly && handleFileSelect()} + > + +

파일을 드래그하거나 클릭하세요

+

+ 최대 {formatFileSize(config.maxSize || 10 * 1024 * 1024)} + {config.accept && config.accept !== "*/*" && ` (${config.accept})`} +

+ +
+ )} +
+ + {/* 파일 뷰어 모달 */} + + + {/* 파일 관리 모달 */} + setIsFileManagerOpen(false)} + uploadedFiles={uploadedFiles} + onFileUpload={handleFileUpload} + onFileDownload={handleFileDownload} + onFileDelete={handleFileDelete} + onFileView={handleFileView} + onSetRepresentative={handleSetRepresentative} + config={fileConfig} + isDesignMode={isDesignMode} + />
); } @@ -785,4 +906,3 @@ export const V2Media = forwardRef( V2Media.displayName = "V2Media"; export default V2Media; - diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 9571abef..0ea82ad8 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -427,9 +427,10 @@ export const DynamicComponentRenderer: React.FC = // 컴포넌트의 columnName에 해당하는 formData 값 추출 const fieldName = (component as any).columnName || (component as any).componentConfig?.columnName || component.id; - // 🔍 V2Media 디버깅 - if (componentType === "v2-media") { - console.log("[DynamicComponentRenderer] v2-media:", { + // 🔍 파일 업로드 컴포넌트 디버깅 + if (componentType === "v2-media" || componentType === "file-upload") { + console.log("[DynamicComponentRenderer] 파일 업로드:", { + componentType, componentId: component.id, columnName: (component as any).columnName, configColumnName: (component as any).componentConfig?.columnName, diff --git a/frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx b/frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx index 0cfd5393..af923ec3 100644 --- a/frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx +++ b/frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx @@ -3,90 +3,86 @@ import React from "react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { V2MediaDefinition } from "./index"; -import { V2Media } from "@/components/v2/V2Media"; +import FileUploadComponent from "../file-upload/FileUploadComponent"; /** * V2Media 렌더러 - * 파일, 이미지, 비디오, 오디오 등 다양한 미디어 타입을 지원 + * 레거시 FileUploadComponent를 사용하여 안정적인 파일 업로드 기능 제공 * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 */ export class V2MediaRenderer extends AutoRegisteringComponentRenderer { static componentDefinition = V2MediaDefinition; render(): React.ReactElement { - const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props; + const { + component, + formData, + onFormDataChange, + isDesignMode, + isSelected, + isInteractive, + onUpdate, + ...restProps + } = this.props; // 컴포넌트 설정 추출 const config = component.componentConfig || component.config || {}; const columnName = component.columnName; const tableName = component.tableName || this.props.tableName; - // formData에서 현재 값 가져오기 - const rawValue = formData?.[columnName] ?? component.value ?? ""; - - // objid를 미리보기 URL로 변환하는 함수 (number/string 모두 처리) - const convertToPreviewUrl = (val: any): string => { - if (val === null || val === undefined || val === "") return ""; - - // number면 string으로 변환 - const strVal = String(val); - - // 이미 URL 형태면 그대로 반환 - if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal; - - // 숫자로만 이루어진 문자열이면 objid로 간주하고 미리보기 URL 생성 - if (/^\d+$/.test(strVal)) { - return `/api/files/preview/${strVal}`; - } - - return strVal; - }; - - // 배열 또는 단일 값 처리 - const currentValue = Array.isArray(rawValue) - ? rawValue.map(convertToPreviewUrl) - : convertToPreviewUrl(rawValue); - - console.log("[V2Media] rawValue:", rawValue, "-> currentValue:", currentValue); - - // 값 변경 핸들러 - const handleChange = (value: any) => { - if (isInteractive && onFormDataChange && columnName) { - onFormDataChange(columnName, value); - } - }; - - // V1 file-upload, image-widget에서 넘어온 설정 매핑 + // V1 file-upload에서 사용하는 형태로 설정 매핑 const mediaType = config.mediaType || config.type || this.getMediaTypeFromWebType(component.webType); - // maxSize: MB → bytes 변환 (V1은 bytes, V2는 MB 단위 사용) + // maxSize: MB → bytes 변환 const maxSizeBytes = config.maxSize ? (config.maxSize > 1000 ? config.maxSize : config.maxSize * 1024 * 1024) : 10 * 1024 * 1024; // 기본 10MB + // 레거시 컴포넌트 설정 형태로 변환 + const legacyComponentConfig = { + maxFileCount: config.multiple ? 10 : 1, + maxFileSize: maxSizeBytes, + accept: config.accept || this.getDefaultAccept(mediaType), + docType: config.docType || "DOCUMENT", + docTypeName: config.docTypeName || "일반 문서", + showFileList: config.showFileList ?? true, + dragDrop: config.dragDrop ?? true, + }; + + // 레거시 컴포넌트 형태로 변환 + const legacyComponent = { + ...component, + id: component.id, + columnName: columnName, + tableName: tableName, + componentConfig: legacyComponentConfig, + }; + + // onFormDataChange 래퍼: 레거시 컴포넌트는 객체를 전달하므로 변환 필요 + const handleFormDataChange = (data: any) => { + if (onFormDataChange) { + // 레거시 컴포넌트는 { [columnName]: value } 형태로 전달 + // 부모는 (fieldName, value) 형태를 기대 + Object.entries(data).forEach(([key, value]) => { + // __attachmentsUpdate 같은 메타 데이터는 건너뛰기 + if (!key.startsWith("__")) { + onFormDataChange(key, value); + } + }); + } + }; + return ( - ); } diff --git a/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx index a3bde9a4..94d0c742 100644 --- a/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx @@ -475,9 +475,21 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table filterValue = filterValue.join("|"); } + // 🔧 filterType에 따라 operator 설정 + // - "select" 유형: 정확히 일치 (equals) + // - "text" 유형: 부분 일치 (contains) + // - "date", "number": 각각 적절한 처리 + let operator = "contains"; // 기본값 + if (filter.filterType === "select") { + operator = "equals"; // 선택 필터는 정확히 일치 + } else if (filter.filterType === "number") { + operator = "equals"; // 숫자도 정확히 일치 + } + return { ...filter, value: filterValue || "", + operator, // operator 추가 }; }) .filter((f) => { diff --git a/frontend/lib/utils/webTypeMapping.ts b/frontend/lib/utils/webTypeMapping.ts index ed4acba2..ebff5fb8 100644 --- a/frontend/lib/utils/webTypeMapping.ts +++ b/frontend/lib/utils/webTypeMapping.ts @@ -107,18 +107,18 @@ export const WEB_TYPE_V2_MAPPING: Record = { config: { mode: "dropdown", source: "category" }, }, - // 파일/이미지 → V2Media + // 파일/이미지 → 레거시 file-upload (안정적인 파일 업로드) file: { - componentType: "v2-media", - config: { type: "file", multiple: false }, + componentType: "file-upload", + config: { maxFileCount: 10, accept: "*/*" }, }, image: { - componentType: "v2-media", - config: { type: "image", showPreview: true }, + componentType: "file-upload", + config: { maxFileCount: 1, accept: "image/*" }, }, img: { - componentType: "v2-media", - config: { type: "image", showPreview: true }, + componentType: "file-upload", + config: { maxFileCount: 1, accept: "image/*" }, }, // 버튼은 V2 컴포넌트에서 제외 (기존 버튼 시스템 사용) @@ -157,9 +157,9 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record = { code: "v2-select", entity: "v2-select", category: "v2-select", - file: "v2-media", - image: "v2-media", - img: "v2-media", + file: "file-upload", + image: "file-upload", + img: "file-upload", button: "button-primary", label: "v2-input", }; diff --git a/frontend/types/v2-components.ts b/frontend/types/v2-components.ts index d985699d..c0d6ca53 100644 --- a/frontend/types/v2-components.ts +++ b/frontend/types/v2-components.ts @@ -232,13 +232,27 @@ export interface V2MediaConfig { maxSize?: number; preview?: boolean; uploadEndpoint?: string; + // 레거시 FileUpload 호환 설정 + docType?: string; + docTypeName?: string; + showFileList?: boolean; + dragDrop?: boolean; } export interface V2MediaProps extends V2BaseProps { - v2Type: "V2Media"; - config: V2MediaConfig; + v2Type?: "V2Media"; + config?: V2MediaConfig; value?: string | string[]; // 파일 URL 또는 배열 onChange?: (value: string | string[]) => void; + // 레거시 FileUpload 호환 props + formData?: Record; + columnName?: string; + tableName?: string; + // 부모 컴포넌트 시그니처: (fieldName, value) 형식 + onFormDataChange?: (fieldName: string, value: any) => void; + isDesignMode?: boolean; + isInteractive?: boolean; + onUpdate?: (updates: Partial) => void; } // ===== V2List ===== -- 2.43.0 From 32139beebc8be23ebdc7bcc08b15e50abf4082d6 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 4 Feb 2026 18:01:20 +0900 Subject: [PATCH 13/24] =?UTF-8?q?feat:=20Docker=20=EB=B0=8F=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Docker Compose 설정에서 Node.js 메모리 제한을 8192MB로 증가시키고, Next.js telemetry를 비활성화하여 성능을 개선하였습니다. - Next.js 구성에서 메모리 사용량 최적화를 위한 webpackMemoryOptimizations를 활성화하였습니다. - ScreenModal 컴포넌트에서 overflow 속성을 조정하여 라벨이 잘리지 않도록 개선하였습니다. - InteractiveScreenViewerDynamic 컴포넌트에서 라벨 표시 여부를 확인하는 로직을 추가하여 사용자 경험을 향상시켰습니다. - RealtimePreviewDynamic 컴포넌트에서 라벨 표시 및 디버깅 로그를 추가하여 렌더링 과정을 추적할 수 있도록 하였습니다. - ImprovedButtonControlConfigPanel에서 controlMode 설정을 추가하여 플로우 제어 기능을 개선하였습니다. - V2PropertiesPanel에서 라벨 텍스트 및 표시 상태 업데이트 로직을 개선하여 일관성을 높였습니다. - DynamicComponentRenderer에서 라벨 표시 로직을 개선하여 사용자 정의 스타일을 보다 효과적으로 적용할 수 있도록 하였습니다. - layoutV2Converter에서 webTypeConfig를 병합하여 버튼 제어 기능과 플로우 가시성을 보존하였습니다. --- docker/dev/docker-compose.frontend.mac.yml | 3 +- frontend/components/common/ScreenModal.tsx | 4 +- .../screen/InteractiveScreenViewerDynamic.tsx | 19 ++- .../components/screen/RealtimePreview.tsx | 16 +- .../screen/RealtimePreviewDynamic.tsx | 7 +- frontend/components/screen/ScreenDesigner.tsx | 146 +++++++----------- .../ImprovedButtonControlConfigPanel.tsx | 2 + .../screen/panels/V2PropertiesPanel.tsx | 21 ++- frontend/components/v2/V2Input.tsx | 6 +- .../lib/registry/DynamicComponentRenderer.tsx | 17 +- .../ButtonPrimaryComponent.tsx | 19 +-- .../components/v2-input/V2InputRenderer.tsx | 6 +- .../lib/utils/improvedButtonActionExecutor.ts | 118 ++++++++++++-- frontend/lib/utils/layoutV2Converter.ts | 38 ++++- frontend/next.config.mjs | 3 +- 15 files changed, 295 insertions(+), 130 deletions(-) diff --git a/docker/dev/docker-compose.frontend.mac.yml b/docker/dev/docker-compose.frontend.mac.yml index 23bcb654..6428d481 100644 --- a/docker/dev/docker-compose.frontend.mac.yml +++ b/docker/dev/docker-compose.frontend.mac.yml @@ -9,7 +9,8 @@ services: - "9771:3000" environment: - NEXT_PUBLIC_API_URL=http://localhost:8080/api - - NODE_OPTIONS=--max-old-space-size=4096 + - NODE_OPTIONS=--max-old-space-size=8192 + - NEXT_TELEMETRY_DISABLED=1 volumes: - ../../frontend:/app - /app/node_modules diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 62685175..38aebadc 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -603,7 +603,7 @@ export const ScreenModal: React.FC = ({ className }) => {
{loading ? (
@@ -620,6 +620,8 @@ export const ScreenModal: React.FC = ({ className }) => { style={{ width: `${screenDimensions?.width || 800}px`, height: `${screenDimensions?.height || 600}px`, + // 🆕 라벨이 위로 튀어나갈 수 있도록 overflow visible 설정 + overflow: "visible", }} > {(() => { diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 8dc5da89..d8e26377 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -1062,22 +1062,35 @@ export const InteractiveScreenViewerDynamic: React.FC 0 ? "visible" : undefined, }; return ( <>
- {/* 라벨 숨김 - 모달에서는 라벨을 표시하지 않음 */} - {/* 위젯 렌더링 */} + {/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */} {renderInteractiveWidget(component)}
diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index b58a6a1f..5a786616 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -119,6 +119,9 @@ const WidgetRenderer: React.FC<{ tableDisplayData?: any[]; [key: string]: any; }> = ({ component, isDesignMode = false, sortBy, sortOrder, tableDisplayData, ...restProps }) => { + // 🔧 무조건 로그 (렌더링 확인용) + console.log("📦 WidgetRenderer 렌더링:", component.id, "labelDisplay:", component.style?.labelDisplay); + // 위젯 컴포넌트가 아닌 경우 빈 div 반환 if (!isWidgetComponent(component)) { return
위젯이 아닙니다
; @@ -127,9 +130,6 @@ const WidgetRenderer: React.FC<{ const widget = component; const { widgetType, label, placeholder, required, readonly, columnName, style } = widget; - // 디버깅: 실제 widgetType 값 확인 - // console.log("RealtimePreviewDynamic - widgetType:", widgetType, "columnName:", columnName); - // 사용자가 테두리를 설정했는지 확인 const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border); @@ -246,8 +246,17 @@ export const RealtimePreviewDynamic: React.FC = ({ tableDisplayData, // 🆕 화면 표시 데이터 ...restProps }) => { + // 🔧 무조건 로그 - 파일 반영 테스트용 (2024-TEST) + console.log("🔷🔷🔷 RealtimePreview 2024:", component.id); + const { user } = useAuth(); const { type, id, position, size, style = {} } = component; + + // 🔧 v2 컴포넌트 렌더링 추적 + if (id?.includes("v2-")) { + console.log("🔷 RealtimePreview 렌더:", id, "type:", type, "labelDisplay:", style?.labelDisplay); + } + const [fileUpdateTrigger, setFileUpdateTrigger] = useState(0); const [actualHeight, setActualHeight] = useState(null); const contentRef = React.useRef(null); @@ -741,6 +750,7 @@ export const RealtimePreviewDynamic: React.FC = ({ {/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */} {type === "component" && (() => { + console.log("📦 DynamicComponentRenderer 렌더링:", component.id, "labelDisplay:", component.style?.labelDisplay); const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer"); return ( = ({ />
- {/* 선택된 컴포넌트 정보 표시 */} + {/* 선택된 컴포넌트 정보 표시 - 🔧 오른쪽으로 이동 (라벨과 겹치지 않도록) */} {isSelected && ( -
+
{type === "widget" && (
{getWidgetIcon((component as WidgetComponent).widgetType)} @@ -685,7 +685,8 @@ const RealtimePreviewDynamicComponent: React.FC = ({ ); }; -// React.memo로 래핑하여 불필요한 리렌더링 방지 +// 🔧 arePropsEqual 제거 - 기본 React.memo 사용 (디버깅용) +// component 객체가 새로 생성되면 자동으로 리렌더링됨 export const RealtimePreviewDynamic = React.memo(RealtimePreviewDynamicComponent); // displayName 설정 (디버깅용) diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 192bd16c..389e8366 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -472,14 +472,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU // 이미 배치된 컬럼 목록 계산 const placedColumns = useMemo(() => { const placed = new Set(); + // 🔧 화면의 메인 테이블명을 fallback으로 사용 + const screenTableName = selectedScreen?.tableName; const collectColumns = (components: ComponentData[]) => { components.forEach((comp) => { const anyComp = comp as any; - // widget 타입 또는 component 타입 (새로운 시스템)에서 tableName과 columnName 확인 - if ((comp.type === "widget" || comp.type === "component") && anyComp.tableName && anyComp.columnName) { - const key = `${anyComp.tableName}.${anyComp.columnName}`; + // 🔧 tableName과 columnName을 여러 위치에서 찾기 (최상위, componentConfig, 또는 화면 테이블명) + const tableName = anyComp.tableName || anyComp.componentConfig?.tableName || screenTableName; + const columnName = anyComp.columnName || anyComp.componentConfig?.columnName; + + // widget 타입 또는 component 타입에서 columnName 확인 (tableName은 화면 테이블명으로 fallback) + if ((comp.type === "widget" || comp.type === "component") && tableName && columnName) { + const key = `${tableName}.${columnName}`; placed.add(key); } @@ -492,7 +498,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU collectColumns(layout.components); return placed; - }, [layout.components]); + }, [layout.components, selectedScreen?.tableName]); // 히스토리에 저장 const saveToHistory = useCallback( @@ -770,6 +776,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU const finalKey = pathParts[pathParts.length - 1]; current[finalKey] = value; + // 🔧 style 관련 업데이트 디버그 로그 + if (path.includes("style") || path.includes("labelDisplay")) { + console.log("🎨 style 업데이트 제대로 렌더링된거다 내가바꿈:", { + componentId: comp.id, + path, + value, + updatedStyle: newComp.style, + pathIncludesLabelDisplay: path.includes("labelDisplay"), + }); + } + + // 🆕 labelDisplay 변경 시 강제 리렌더링 트리거 (조건문 밖으로 이동) + if (path === "style.labelDisplay") { + console.log("⏰⏰⏰ labelDisplay 변경 감지! forceRenderTrigger 실행 예정"); + } + // 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화) if (path === "size.width" || path === "size.height" || path === "size") { // 🔧 style 객체를 새로 복사하여 불변성 유지 @@ -1787,97 +1809,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU const buttonComponents = layoutWithResolution.components.filter( (c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary", ); - console.log("💾 저장 시작:", { - screenId: selectedScreen.screenId, - componentsCount: layoutWithResolution.components.length, - gridSettings: layoutWithResolution.gridSettings, - screenResolution: layoutWithResolution.screenResolution, - buttonComponents: buttonComponents.map((c: any) => ({ - id: c.id, - type: c.type, - componentType: c.componentType, - text: c.componentConfig?.text, - actionType: c.componentConfig?.action?.type, - fullAction: c.componentConfig?.action, - })), - }); - - // 🔍 디버그: 분할 패널 내부의 탭 및 컴포넌트 설정 확인 - const splitPanels = layoutWithResolution.components.filter( - (c: any) => c.componentType === "v2-split-panel-layout" || c.componentType === "split-panel-layout" - ); - splitPanels.forEach((sp: any) => { - console.log("🔍 [저장] 분할 패널 설정:", { - id: sp.id, - leftPanel: sp.componentConfig?.leftPanel, - rightPanel: sp.componentConfig?.rightPanel, - }); - // 🆕 분할 패널 내 모든 컴포넌트의 componentConfig 로그 - const rightComponents = sp.componentConfig?.rightPanel?.components || []; - console.log("🔍 [저장] 오른쪽 패널 컴포넌트들:", rightComponents.map((c: any) => ({ - id: c.id, - componentType: c.componentType, - hasComponentConfig: !!c.componentConfig, - componentConfig: JSON.parse(JSON.stringify(c.componentConfig || {})), - }))); - // 왼쪽 패널의 탭 컴포넌트 확인 - const leftTabs = sp.componentConfig?.leftPanel?.components?.filter( - (c: any) => c.componentType === "v2-tabs-widget" - ); - leftTabs?.forEach((tabWidget: any) => { - console.log("🔍 [저장] 왼쪽 패널 탭 위젯 전체 componentConfig:", { - tabWidgetId: tabWidget.id, - fullComponentConfig: JSON.parse(JSON.stringify(tabWidget.componentConfig || {})), - }); - console.log("🔍 [저장] 왼쪽 패널 탭 내부 컴포넌트:", { - tabId: tabWidget.id, - tabs: tabWidget.componentConfig?.tabs?.map((t: any) => ({ - id: t.id, - label: t.label, - componentsCount: t.components?.length || 0, - components: t.components, - })), - }); - }); - // 오른쪽 패널의 탭 컴포넌트 확인 - const rightTabs = sp.componentConfig?.rightPanel?.components?.filter( - (c: any) => c.componentType === "v2-tabs-widget" - ); - rightTabs?.forEach((tabWidget: any) => { - console.log("🔍 [저장] 오른쪽 패널 탭 위젯 전체 componentConfig:", { - tabWidgetId: tabWidget.id, - fullComponentConfig: JSON.parse(JSON.stringify(tabWidget.componentConfig || {})), - }); - console.log("🔍 [저장] 오른쪽 패널 탭 내부 컴포넌트:", { - tabId: tabWidget.id, - tabs: tabWidget.componentConfig?.tabs?.map((t: any) => ({ - id: t.id, - label: t.label, - componentsCount: t.components?.length || 0, - components: t.components, - })), - }); - }); - }); + // 💾 저장 로그 (디버그 완료 - 간소화) + // console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length }); + // 분할 패널 디버그 로그 (주석 처리) // V2 API 사용 여부에 따라 분기 if (USE_V2_API) { + // 🔧 V2 레이아웃 저장 (디버그 로그 주석 처리) const v2Layout = convertLegacyToV2(layoutWithResolution); - console.log("📦 V2 변환 결과 (분할 패널 overrides):", v2Layout.components - .filter((c: any) => c.url?.includes("split-panel")) - .map((c: any) => ({ - id: c.id, - url: c.url, - overrides: c.overrides, - })) - ); await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); - console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트"); + // console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트"); } else { await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); } - console.log("✅ 저장 성공! 메뉴 할당 모달 열기"); + // console.log("✅ 저장 성공!"); toast.success("화면이 저장되었습니다."); // 저장 성공 후 부모에게 화면 정보 업데이트 알림 (테이블명 즉시 반영) @@ -3084,7 +3030,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU }, webTypeConfig: getDefaultWebTypeConfig(component.webType), style: { - labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정 + labelDisplay: true, // 🆕 라벨 기본 표시 (사용자가 끄고 싶으면 체크 해제) labelFontSize: "14px", labelColor: "#212121", labelFontWeight: "500", @@ -3750,7 +3696,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU entityJoinColumn: column.entityJoinColumn, }), style: { - labelDisplay: false, // 라벨 숨김 + labelDisplay: true, // 🆕 라벨 기본 표시 labelFontSize: "12px", labelColor: "#212121", labelFontWeight: "500", @@ -3816,7 +3762,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU entityJoinColumn: column.entityJoinColumn, }), style: { - labelDisplay: false, // 라벨 숨김 + labelDisplay: true, // 🆕 라벨 기본 표시 labelFontSize: "14px", labelColor: "#000000", // 순수한 검정 labelFontWeight: "500", @@ -5452,6 +5398,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU ); } + // 🔧 ScreenDesigner 렌더링 확인 (디버그 완료 - 주석 처리) + // console.log("🏠 ScreenDesigner 렌더!", Date.now()); + return ( @@ -6163,6 +6112,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU // 그룹에 속하지 않은 일반 컴포넌트들 const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); + // 🔧 렌더링 확인 로그 (디버그 완료 - 주석 처리) + // console.log("🔄 ScreenDesigner 렌더링:", { componentsCount: regularComponents.length, forceRenderTrigger, timestamp: Date.now() }); + return ( <> {/* 일반 컴포넌트들 */} @@ -6228,11 +6180,23 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU const globalFiles = globalFileState[component.id] || []; const componentFiles = (component as any).uploadedFiles || []; const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`; + // 🆕 style 변경 시 리렌더링을 위한 key 추가 + const styleKey = component.style?.labelDisplay !== undefined ? `label-${component.style.labelDisplay}` : ""; + const fullKey = `${component.id}-${fileStateKey}-${styleKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`; + + // 🔧 v2-input 계열 컴포넌트 key 변경 로그 (디버그 완료 - 주석 처리) + // if (component.id.includes("v2-") || component.widgetType?.includes("v2-")) { console.log("🔑 RealtimePreview key:", { id: component.id, styleKey, labelDisplay: component.style?.labelDisplay, forceRenderTrigger, fullKey }); } + + // 🆕 labelDisplay 변경 시 새 객체로 강제 변경 감지 + const componentWithLabel = { + ...displayComponent, + _labelDisplayKey: component.style?.labelDisplay, + }; return ( = ({
{ handleUpdate("style.labelText", e.target.value); handleUpdate("label", e.target.value); // label도 함께 업데이트 @@ -861,8 +861,23 @@ export const V2PropertiesPanel: React.FC = ({
handleUpdate("style.labelDisplay", checked)} + checked={selectedComponent.style?.labelDisplay === true || selectedComponent.labelDisplay === true} + onCheckedChange={(checked) => { + const boolValue = checked === true; + // 🔧 "필수"처럼 직접 경로로 업데이트! (style 객체 전체 덮어쓰기 방지) + handleUpdate("style.labelDisplay", boolValue); + handleUpdate("labelDisplay", boolValue); + // labelText도 설정 (처음 켤 때 라벨 텍스트가 없을 수 있음) + if (boolValue && !selectedComponent.style?.labelText) { + const labelValue = + selectedComponent.label || + selectedComponent.componentConfig?.label || + ""; + if (labelValue) { + handleUpdate("style.labelText", labelValue); + } + } + }} className="h-4 w-4" /> diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index 85083174..ac4bc33c 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -802,7 +802,9 @@ export const V2Input = forwardRef((props, ref) => }; // 라벨이 표시될 때 입력 필드가 차지할 높이 계산 - const showLabel = label && style?.labelDisplay !== false; + // 🔧 label prop이 없어도 style.labelText에서 가져올 수 있도록 수정 + const actualLabel = label || style?.labelText; + const showLabel = actualLabel && style?.labelDisplay === true; // size에서 우선 가져오고, 없으면 style에서 가져옴 const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; @@ -836,7 +838,7 @@ export const V2Input = forwardRef((props, ref) => }} className="text-sm font-medium whitespace-nowrap" > - {label} + {actualLabel} {required && *} )} diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 9571abef..4fe888bf 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -513,6 +513,18 @@ export const DynamicComponentRenderer: React.FC = componentType === "modal-repeater-table" || componentType === "v2-input"; + // 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true일 때만 라벨 표시) + const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay; + const effectiveLabel = labelDisplay === true + ? (component.style?.labelText || (component as any).label || component.componentConfig?.label) + : undefined; + + // 🔧 순서 중요! finalStyle 먼저, component.style 나중에 (커스텀 속성이 CSS 속성을 덮어써야 함) + const mergedStyle = { + ...finalStyle, // CSS 속성 (width, height 등) - 먼저! + ...component.style, // 원본 style (labelDisplay, labelText 등) - 나중에! (덮어씀) + }; + const rendererProps = { component, isSelected, @@ -521,11 +533,14 @@ export const DynamicComponentRenderer: React.FC = onDragEnd, size: component.size || newComponent.defaultSize, position: component.position, - style: finalStyle, // size를 포함한 최종 style config: component.componentConfig, componentConfig: component.componentConfig, // componentConfig의 모든 속성을 props로 spread (tableName, displayField 등) ...(component.componentConfig || {}), + // 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선) + style: mergedStyle, + // 🆕 라벨 표시 (labelDisplay가 true일 때만) + label: effectiveLabel, // 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 inputType: (component as any).inputType || component.componentConfig?.inputType, columnName: (component as any).columnName || component.componentConfig?.columnName, diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index 83a7771d..d6ed349f 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -555,13 +555,10 @@ export const ButtonPrimaryComponent: React.FC = ({ } // 스타일 계산 - // height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감 - // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + // 🔧 사용자가 설정한 크기가 있으면 그대로 사용 const componentStyle: React.CSSProperties = { ...component.style, ...style, - width: "100%", - height: "100%", }; // 디자인 모드 스타일 (border 속성 분리하여 충돌 방지) @@ -1289,19 +1286,23 @@ export const ButtonPrimaryComponent: React.FC = ({ componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading; // 공통 버튼 스타일 - // 🔧 component.style에서 background/backgroundColor 충돌 방지 + // 🔧 component.style에서 background/backgroundColor 충돌 방지 (width/height는 허용) const userStyle = component.style ? Object.fromEntries( Object.entries(component.style).filter( - ([key]) => !["width", "height", "background", "backgroundColor"].includes(key), + ([key]) => !["background", "backgroundColor"].includes(key), ), ) : {}; + // 🔧 사용자가 설정한 크기 우선 사용, 없으면 100% + const buttonWidth = component.size?.width ? `${component.size.width}px` : (style?.width || "100%"); + const buttonHeight = component.size?.height ? `${component.size.height}px` : (style?.height || "100%"); + const buttonElementStyle: React.CSSProperties = { - width: "100%", - height: "100%", - minHeight: "40px", + width: buttonWidth, + height: buttonHeight, + minHeight: "32px", // 🔧 최소 높이를 32px로 줄임 border: "none", borderRadius: "0.5rem", backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, diff --git a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx index 52a230fa..e67a8399 100644 --- a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx +++ b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx @@ -31,9 +31,11 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer { }; // 라벨: style.labelText 우선, 없으면 component.label 사용 - // style.labelDisplay가 false면 라벨 숨김 + // 🔧 style.labelDisplay를 먼저 체크 (속성 패널에서 style 객체로 업데이트하므로) const style = component.style || {}; - const effectiveLabel = style.labelDisplay === false ? undefined : (style.labelText || component.label); + const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay; + // labelDisplay: true → 라벨 표시, false → 숨김, undefined → 기존 동작 유지(숨김) + const effectiveLabel = labelDisplay === true ? (style.labelText || component.label) : undefined; return ( ; }; + // 🆕 플로우 기반 제어 설정 + flowConfig?: { + flowId: number; + flowName: string; + executionTiming: "before" | "after" | "replace"; + contextData?: Record; + }; } interface ExecutionPlan { @@ -163,15 +170,22 @@ export class ImprovedButtonActionExecutor { return plan; } - // enableDataflowControl 체크를 제거하고 dataflowConfig만 있으면 실행 + // 🔧 controlMode가 없으면 flowConfig/relationshipConfig 존재 여부로 자동 판단 + const effectiveControlMode = dataflowConfig.controlMode + || (dataflowConfig.flowConfig ? "flow" : null) + || (dataflowConfig.relationshipConfig ? "relationship" : null) + || "none"; + console.log("📋 실행 계획 생성:", { controlMode: dataflowConfig.controlMode, + effectiveControlMode, + hasFlowConfig: !!dataflowConfig.flowConfig, hasRelationshipConfig: !!dataflowConfig.relationshipConfig, enableDataflowControl: buttonConfig.enableDataflowControl, }); - // 관계 기반 제어만 지원 - if (dataflowConfig.controlMode === "relationship" && dataflowConfig.relationshipConfig) { + // 관계 기반 제어 + if (effectiveControlMode === "relationship" && dataflowConfig.relationshipConfig) { const control: ControlConfig = { type: "relationship", relationshipConfig: dataflowConfig.relationshipConfig, @@ -191,11 +205,34 @@ export class ImprovedButtonActionExecutor { } } + // 🆕 플로우 기반 제어 + if (effectiveControlMode === "flow" && dataflowConfig.flowConfig) { + const control: ControlConfig = { + type: "flow", + flowConfig: dataflowConfig.flowConfig, + }; + + console.log("📋 플로우 제어 설정:", dataflowConfig.flowConfig); + + switch (dataflowConfig.flowConfig.executionTiming) { + case "before": + plan.beforeControls.push(control); + break; + case "after": + plan.afterControls.push(control); + break; + case "replace": + plan.afterControls.push(control); + plan.hasReplaceControl = true; + break; + } + } + return plan; } /** - * 🔥 제어 실행 (관계 또는 외부호출) + * 🔥 제어 실행 (관계 또는 플로우) */ private static async executeControls( controls: ControlConfig[], @@ -206,8 +243,16 @@ export class ImprovedButtonActionExecutor { for (const control of controls) { try { - // 관계 실행만 지원 - const result = await this.executeRelationship(control.relationshipConfig, formData, context); + let result: ExecutionResult; + + // 🆕 제어 타입에 따라 분기 처리 + if (control.type === "flow" && control.flowConfig) { + result = await this.executeFlow(control.flowConfig, formData, context); + } else if (control.type === "relationship" && control.relationshipConfig) { + result = await this.executeRelationship(control.relationshipConfig, formData, context); + } else { + throw new Error(`지원하지 않는 제어 타입: ${control.type}`); + } results.push(result); @@ -215,7 +260,7 @@ export class ImprovedButtonActionExecutor { if (!result.success) { throw new Error(result.message); } - } catch (error) { + } catch (error: any) { console.error(`제어 실행 실패 (${control.type}):`, error); results.push({ success: false, @@ -230,6 +275,61 @@ export class ImprovedButtonActionExecutor { return results; } + /** + * 🆕 플로우 실행 + */ + private static async executeFlow( + config: { + flowId: number; + flowName: string; + executionTiming: "before" | "after" | "replace"; + contextData?: Record; + }, + formData: Record, + context: ButtonExecutionContext, + ): Promise { + const startTime = Date.now(); + + try { + console.log(`🔄 플로우 실행 시작: ${config.flowName} (ID: ${config.flowId})`); + + // 플로우 실행 API 호출 + const response = await apiClient.post(`/api/dataflow/node-flows/${config.flowId}/execute`, { + formData, + contextData: config.contextData || {}, + selectedRows: context.selectedRows || [], + flowSelectedData: context.flowSelectedData || [], + screenId: context.screenId, + companyCode: context.companyCode, + userId: context.userId, + }); + + const executionTime = Date.now() - startTime; + + if (response.data?.success) { + console.log(`✅ 플로우 실행 성공: ${config.flowName}`, response.data); + return { + success: true, + message: `플로우 "${config.flowName}" 실행 완료`, + executionTime, + data: response.data, + }; + } else { + throw new Error(response.data?.message || "플로우 실행 실패"); + } + } catch (error: any) { + const executionTime = Date.now() - startTime; + console.error(`❌ 플로우 실행 실패: ${config.flowName}`, error); + + return { + success: false, + message: `플로우 "${config.flowName}" 실행 실패: ${error.message}`, + executionTime, + error: error.message, + }; + } + } + /** * 🔥 관계 실행 */ diff --git a/frontend/lib/utils/layoutV2Converter.ts b/frontend/lib/utils/layoutV2Converter.ts index 32360a73..b8485487 100644 --- a/frontend/lib/utils/layoutV2Converter.ts +++ b/frontend/lib/utils/layoutV2Converter.ts @@ -191,6 +191,8 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData | autoFill: overrides.autoFill, // 🆕 style 설정 복원 (라벨 텍스트, 라벨 스타일 등) style: overrides.style || {}, + // 🔧 webTypeConfig 복원 (버튼 제어기능, 플로우 가시성 등) + webTypeConfig: overrides.webTypeConfig || {}, // 기존 구조 호환을 위한 추가 필드 parentId: null, gridColumns: 12, @@ -245,13 +247,47 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 { if (comp.autoFill) topLevelProps.autoFill = comp.autoFill; // 🆕 style 설정 저장 (라벨 텍스트, 라벨 스타일 등) if (comp.style && Object.keys(comp.style).length > 0) topLevelProps.style = comp.style; + // 🔧 webTypeConfig 저장 (버튼 제어기능, 플로우 가시성 등) + if (comp.webTypeConfig && Object.keys(comp.webTypeConfig).length > 0) { + topLevelProps.webTypeConfig = comp.webTypeConfig; + // 🔍 디버그: webTypeConfig 저장 확인 + if (comp.webTypeConfig.dataflowConfig || comp.webTypeConfig.enableDataflowControl) { + console.log("💾 webTypeConfig 저장:", { + componentId: comp.id, + enableDataflowControl: comp.webTypeConfig.enableDataflowControl, + dataflowConfig: comp.webTypeConfig.dataflowConfig, + }); + } + } // 현재 설정에서 차이값만 추출 const fullConfig = comp.componentConfig || {}; const configOverrides = extractCustomConfig(fullConfig, defaults); + // 🔧 디버그: style 저장 확인 (주석 처리) + // if (comp.style?.labelDisplay !== undefined || configOverrides.style?.labelDisplay !== undefined) { console.log("💾 저장 시 style 변환:", { componentId: comp.id, "comp.style": comp.style, "configOverrides.style": configOverrides.style, "topLevelProps.style": topLevelProps.style }); } + // 상위 레벨 속성과 componentConfig 병합 - const overrides = { ...topLevelProps, ...configOverrides }; + // 🔧 style은 양쪽을 병합하되 comp.style(topLevelProps.style)을 우선시 + const mergedStyle = { + ...(configOverrides.style || {}), + ...(topLevelProps.style || {}), + }; + + // 🔧 webTypeConfig도 병합 (topLevelProps가 우선, dataflowConfig 등 보존) + const mergedWebTypeConfig = { + ...(configOverrides.webTypeConfig || {}), + ...(topLevelProps.webTypeConfig || {}), + }; + + const overrides = { + ...topLevelProps, + ...configOverrides, + // 🆕 병합된 style 사용 (comp.style 값이 최종 우선) + ...(Object.keys(mergedStyle).length > 0 ? { style: mergedStyle } : {}), + // 🆕 병합된 webTypeConfig 사용 (comp.webTypeConfig가 최종 우선) + ...(Object.keys(mergedWebTypeConfig).length > 0 ? { webTypeConfig: mergedWebTypeConfig } : {}), + }; return { id: comp.id, diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index ca804adc..2e23bc81 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -15,7 +15,8 @@ const nextConfig = { // 실험적 기능 활성화 experimental: { - outputFileTracingRoot: undefined, + // 메모리 사용량 최적화 (Next.js 15+) + webpackMemoryOptimizations: true, }, // API 프록시 설정 - 백엔드로 요청 전달 -- 2.43.0 From f3a0c925640e37bff0dc119f7b584d594ae88378 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 5 Feb 2026 10:08:26 +0900 Subject: [PATCH 14/24] =?UTF-8?q?feat:=20EditModal=20=EB=B0=8F=20ButtonAct?= =?UTF-8?q?ionExecutor=EC=97=90=EC=84=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=20=EC=A0=9C=EC=96=B4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EditModal 컴포넌트에서 executionTiming 체크 로직을 추가하여 데이터 흐름 제어를 보다 유연하게 처리하도록 개선하였습니다. - ButtonActionExecutor에서 저장된 데이터 구조를 명확히 하여, API 응답에서 실제 폼 데이터를 올바르게 추출하도록 수정하였습니다. - 디버깅 로그를 추가하여 데이터 흐름 및 상태를 추적할 수 있도록 하여 개발 편의성을 높였습니다. --- .../src/services/nodeFlowExecutionService.ts | 3 + .../SAVED_DATA_NESTED_STRUCTURE_BUG_FIX.md | 149 ++++++++++++++++++ frontend/components/screen/EditModal.tsx | 21 ++- frontend/lib/utils/buttonActions.ts | 59 +++++-- 4 files changed, 214 insertions(+), 18 deletions(-) create mode 100644 docs/kjs/SAVED_DATA_NESTED_STRUCTURE_BUG_FIX.md diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index eadddf9f..cadfdefc 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -845,6 +845,9 @@ export class NodeFlowExecutionService { logger.info( `📊 컨텍스트 데이터 사용: ${context.dataSourceType}, ${context.sourceData.length}건` ); + // 🔍 디버깅: sourceData 내용 출력 + logger.info(`📊 [테이블소스] sourceData 필드: ${JSON.stringify(Object.keys(context.sourceData[0]))}`); + logger.info(`📊 [테이블소스] sourceData.sabun: ${context.sourceData[0]?.sabun}`); return context.sourceData; } diff --git a/docs/kjs/SAVED_DATA_NESTED_STRUCTURE_BUG_FIX.md b/docs/kjs/SAVED_DATA_NESTED_STRUCTURE_BUG_FIX.md new file mode 100644 index 00000000..1cdf3af1 --- /dev/null +++ b/docs/kjs/SAVED_DATA_NESTED_STRUCTURE_BUG_FIX.md @@ -0,0 +1,149 @@ +# 저장 후 플로우 실행 시 폼 데이터 전달 오류 수정 + +## 오류 현상 + +사용자가 폼에서 데이터를 저장한 후, 연결된 노드 플로우(예: 비밀번호 자동 설정)가 실행될 때 `sabun` 값이 `undefined`로 전달되어 UPDATE 쿼리의 WHERE 조건이 작동하지 않는 문제. + +### 증상 +- 저장 버튼 클릭 시 INSERT는 정상 작동 +- 저장 후 실행되는 노드 플로우에서 `user_password` UPDATE가 실패 (0건 업데이트) +- 콘솔 로그에서 `savedData.sabun: undefined` 출력 + +``` +📦 [executeAfterSaveControl] savedData 필드: ['id', 'screenId', 'tableName', 'data', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'] +📦 [executeAfterSaveControl] savedData.sabun: undefined +``` + +--- + +## 원인 분석 + +### API 응답 구조의 3단계 중첩 + +저장 API(`DynamicFormApi.saveFormData`)의 응답이 3단계로 중첩되어 있었음: + +```typescript +// 1단계: Axios 응답 +saveResult = { + data: { ... } // API 응답 +} + +// 2단계: API 응답 래핑 (ApiResponse 인터페이스) +saveResult.data = { + success: true, + data: { ... }, // 저장된 레코드 + message: "저장 완료" +} + +// 3단계: 저장된 레코드 (dynamic_form_data 테이블 구조) +saveResult.data.data = { + id: 123, + screenId: 106, + tableName: "user_info", + data: { sabun: "20260205-087", user_name: "TEST", ... }, // ← 실제 폼 데이터 + createdAt: "2026-02-05T...", + updatedAt: "2026-02-05T...", + createdBy: "admin", + updatedBy: "admin" +} + +// 4단계: 실제 폼 데이터 (우리가 필요한 데이터) +saveResult.data.data.data = { + sabun: "20260205-087", + user_name: "TEST", + user_id: "Kim1542", + ... +} +``` + +### 기존 코드의 문제점 + +```typescript +// 기존 코드 (buttonActions.ts:1619-1621) +const savedData = saveResult?.data?.data || saveResult?.data || {}; +const formData = savedData; // ← 2단계까지만 추출 + +// savedData = { id, screenId, tableName, data: {...}, createdAt, ... } +// savedData.sabun = undefined ← 문제 발생! +``` + +기존 코드는 2단계(`saveResult.data.data`)까지만 추출했기 때문에, `savedData`가 저장된 레코드 메타데이터를 가리키고 있었음. 실제 폼 데이터는 `savedData.data` 안에 있었음. + +--- + +## 해결 방법 + +### 수정된 코드 + +```typescript +// 수정된 코드 (buttonActions.ts:1619-1628) +// 🔧 수정: saveResult.data가 3단계로 중첩된 경우 실제 폼 데이터 추출 +// saveResult.data = API 응답 { success, data, message } +// saveResult.data.data = 저장된 레코드 { id, screenId, tableName, data, createdAt... } +// saveResult.data.data.data = 실제 폼 데이터 { sabun, user_name... } +const savedRecord = saveResult?.data?.data || saveResult?.data || {}; +const actualFormData = savedRecord?.data || savedRecord; // ← 3단계까지 추출 +const formData = (Object.keys(actualFormData).length > 0 ? actualFormData : context.formData || {}); +``` + +### 수정 핵심 +1. `savedRecord`: 저장된 레코드 메타데이터 (`{ id, screenId, tableName, data, ... }`) +2. `actualFormData`: `savedRecord.data`가 있으면 그것을 사용, 없으면 `savedRecord` 자체 사용 +3. 폴백: `actualFormData`가 비어있으면 `context.formData` 사용 + +--- + +## 수정된 파일 + +| 파일 | 수정 내용 | +|------|-----------| +| `frontend/lib/utils/buttonActions.ts` | 3단계 중첩 데이터 구조에서 실제 폼 데이터 추출 로직 수정 (라인 1619-1628) | + +--- + +## 검증 결과 + +### 수정 전 +``` +📦 [executeAfterSaveControl] savedData 필드: ['id', 'screenId', 'tableName', 'data', ...] +📦 [executeAfterSaveControl] savedData.sabun: undefined +``` + +### 수정 후 +``` +📦 [executeAfterSaveControl] savedRecord 구조: ['id', 'screenId', 'tableName', 'data', ...] +📦 [executeAfterSaveControl] actualFormData 추출: ['sabun', 'user_id', 'user_password', ...] +📦 [executeAfterSaveControl] formData.sabun: 20260205-087 +``` + +### DB 확인 +```sql +SELECT sabun, user_name, user_password FROM user_info WHERE sabun = '20260205-087'; +-- 결과: sabun: "20260205-087", user_name: "TEST", user_password: "1e538e2abdd9663437343212a4853591" +``` + +--- + +## 교훈 + +1. **API 응답 구조 확인**: API 응답이 여러 단계로 래핑될 수 있음. 프론트엔드에서 `apiClient`가 한 번, `ApiResponse` 인터페이스가 한 번, 그리고 실제 데이터 구조가 또 다른 레벨을 가질 수 있음. + +2. **로그 추가의 중요성**: 중간 단계마다 로그를 찍어 데이터 구조를 확인하는 것이 디버깅에 필수적. + +3. **폴백 처리**: 데이터 추출 시 여러 단계의 폴백을 두어 다양한 응답 구조에 대응. + +--- + +## 관련 이슈 + +- 비밀번호 자동 설정 노드 플로우가 저장 후 실행되지 않는 문제 +- 저장 후 연결된 UPDATE 플로우에서 WHERE 조건이 작동하지 않는 문제 + +--- + +## 작성 정보 + +- **작성일**: 2026-02-05 +- **작성자**: AI Assistant +- **관련 화면**: 부서관리 > 사용자 등록 모달 +- **관련 플로우**: flowId: 120 (부서관리 비밀번호 자동세팅) diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index db722991..03d43b82 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -704,7 +704,12 @@ export const EditModal: React.FC = ({ className }) => { controlConfig, }); - if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") { + // 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인 + const flowTiming = controlConfig?.dataflowTiming + || controlConfig?.dataflowConfig?.flowConfig?.executionTiming + || (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null); + + if (controlConfig?.enableDataflowControl && flowTiming === "after") { console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig); // buttonActions의 executeAfterSaveControl 동적 import @@ -863,7 +868,12 @@ export const EditModal: React.FC = ({ className }) => { console.log("[EditModal] INSERT 완료 후 제어로직 실행 시도", { controlConfig }); - if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") { + // 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인 + const flowTimingInsert = controlConfig?.dataflowTiming + || controlConfig?.dataflowConfig?.flowConfig?.executionTiming + || (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null); + + if (controlConfig?.enableDataflowControl && flowTimingInsert === "after") { console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig); const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions"); @@ -936,7 +946,12 @@ export const EditModal: React.FC = ({ className }) => { console.log("[EditModal] UPDATE 완료 후 제어로직 실행 시도", { controlConfig }); - if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") { + // 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인 + const flowTimingUpdate = controlConfig?.dataflowTiming + || controlConfig?.dataflowConfig?.flowConfig?.executionTiming + || (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null); + + if (controlConfig?.enableDataflowControl && flowTimingUpdate === "after") { console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig); const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions"); diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index b1d66eea..3521c668 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1616,7 +1616,16 @@ export class ButtonActionExecutor { if (config.enableDataflowControl && config.dataflowConfig) { // 테이블 섹션 데이터 파싱 (comp_로 시작하는 필드에 JSON 배열이 있는 경우) // 입고 화면 등에서 품목 목록이 comp_xxx 필드에 JSON 문자열로 저장됨 - const formData: Record = (saveResult.data || context.formData || {}) as Record; + // 🔧 수정: saveResult.data가 3단계로 중첩된 경우 실제 폼 데이터 추출 + // saveResult.data = API 응답 { success, data, message } + // saveResult.data.data = 저장된 레코드 { id, screenId, tableName, data, createdAt... } + // saveResult.data.data.data = 실제 폼 데이터 { sabun, user_name... } + const savedRecord = saveResult?.data?.data || saveResult?.data || {}; + const actualFormData = savedRecord?.data || savedRecord; + const formData: Record = (Object.keys(actualFormData).length > 0 ? actualFormData : context.formData || {}) as Record; + console.log("📦 [executeAfterSaveControl] savedRecord 구조:", Object.keys(savedRecord)); + console.log("📦 [executeAfterSaveControl] actualFormData 추출:", Object.keys(formData)); + console.log("📦 [executeAfterSaveControl] formData.sabun:", formData.sabun); let parsedSectionData: any[] = []; // comp_로 시작하는 필드에서 테이블 섹션 데이터 찾기 @@ -4016,16 +4025,27 @@ export class ButtonActionExecutor { const { executeNodeFlow } = await import("@/lib/api/nodeFlows"); // 데이터 소스 준비: context-data 모드는 배열을 기대함 - // 우선순위: selectedRowsData > savedData > formData - // - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함) - // - savedData: 저장 API 응답 데이터 - // - formData: 폼에 입력된 데이터 + // 🔧 저장 후 제어: savedData > formData > selectedRowsData + // - 저장 후 제어에서는 방금 저장된 데이터(savedData)가 가장 중요! + // - selectedRowsData는 왼쪽 패널 선택 데이터일 수 있으므로 마지막 순위 let sourceData: any[]; - if (context.selectedRowsData && context.selectedRowsData.length > 0) { + if (context.savedData) { + // 저장된 데이터가 있으면 우선 사용 (저장 API 응답) + sourceData = Array.isArray(context.savedData) ? context.savedData : [context.savedData]; + console.log("📦 [executeAfterSaveControl] savedData 사용:", sourceData); + console.log("📦 [executeAfterSaveControl] savedData 필드:", Object.keys(context.savedData)); + console.log("📦 [executeAfterSaveControl] savedData.sabun:", context.savedData.sabun); + } else if (context.formData && Object.keys(context.formData).length > 0) { + // 폼 데이터 사용 + sourceData = [context.formData]; + console.log("📦 [executeAfterSaveControl] formData 사용:", sourceData); + } else if (context.selectedRowsData && context.selectedRowsData.length > 0) { + // 테이블 섹션 데이터 (마지막 순위) sourceData = context.selectedRowsData; + console.log("📦 [executeAfterSaveControl] selectedRowsData 사용:", sourceData); } else { - const savedData = context.savedData || context.formData || {}; - sourceData = Array.isArray(savedData) ? savedData : [savedData]; + sourceData = []; + console.warn("⚠️ [executeAfterSaveControl] 데이터 소스 없음!"); } let allSuccess = true; @@ -4125,16 +4145,25 @@ export class ButtonActionExecutor { const { executeNodeFlow } = await import("@/lib/api/nodeFlows"); // 데이터 소스 준비: context-data 모드는 배열을 기대함 - // 우선순위: selectedRowsData > savedData > formData - // - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함) - // - savedData: 저장 API 응답 데이터 - // - formData: 폼에 입력된 데이터 + // 🔧 저장 후 제어: savedData > formData > selectedRowsData + // - 저장 후 제어에서는 방금 저장된 데이터(savedData)가 가장 중요! + // - selectedRowsData는 왼쪽 패널 선택 데이터일 수 있으므로 마지막 순위 let sourceData: any[]; - if (context.selectedRowsData && context.selectedRowsData.length > 0) { + if (context.savedData) { + // 저장된 데이터가 있으면 우선 사용 (저장 API 응답) + sourceData = Array.isArray(context.savedData) ? context.savedData : [context.savedData]; + console.log("📦 [executeSingleFlowControl] savedData 사용:", sourceData); + } else if (context.formData && Object.keys(context.formData).length > 0) { + // 폼 데이터 사용 + sourceData = [context.formData]; + console.log("📦 [executeSingleFlowControl] formData 사용:", sourceData); + } else if (context.selectedRowsData && context.selectedRowsData.length > 0) { + // 테이블 섹션 데이터 (마지막 순위) sourceData = context.selectedRowsData; + console.log("📦 [executeSingleFlowControl] selectedRowsData 사용:", sourceData); } else { - const savedData = context.savedData || context.formData || {}; - sourceData = Array.isArray(savedData) ? savedData : [savedData]; + sourceData = []; + console.warn("⚠️ [executeSingleFlowControl] 데이터 소스 없음!"); } // repeat-screen-modal 데이터가 있으면 병합 -- 2.43.0 From 2fb6dd0c321cf0160a9677b78a63c73ecffeaeb0 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 5 Feb 2026 10:50:34 +0900 Subject: [PATCH 15/24] =?UTF-8?q?feat:=20=EA=B8=B0=EB=B3=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=A1=9C=EA=B9=85=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V2PropertiesPanel에서 defaultSort를 currentConfig에 추가하여 기본 정렬 정보를 관리하도록 개선하였습니다. - TableListComponent에서 localStorage에서 기본 정렬 설정을 적용하는 로직을 추가하여 사용자 경험을 향상시켰습니다. - TableListConfigPanel에 기본 정렬 설정 UI를 추가하여 사용자가 테이블 로드 시 기본 정렬 순서를 지정할 수 있도록 하였습니다. - 각 컴포넌트에서 상태 변경 시 로깅을 추가하여 디버깅을 용이하게 하였습니다. --- .../screen/panels/V2PropertiesPanel.tsx | 8 +++ .../v2-table-list/TableListComponent.tsx | 18 +++-- .../v2-table-list/TableListConfigPanel.tsx | 65 ++++++++++++++++++- .../components/v2-table-list/types.ts | 6 ++ 4 files changed, 92 insertions(+), 5 deletions(-) diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index a843d710..8b42d753 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -263,6 +263,7 @@ export const V2PropertiesPanel: React.FC = ({ definitionName: definition.name, hasConfigPanel: !!definition.configPanel, currentConfig, + defaultSort: currentConfig?.defaultSort, // 🔍 defaultSort 확인 }); // 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지) @@ -1059,8 +1060,15 @@ export const V2PropertiesPanel: React.FC = ({ allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용 currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보 onChange={(newConfig) => { + console.log("🔧 [V2PropertiesPanel] DynamicConfigPanel onChange:", { + componentId: selectedComponent.id, + newConfigKeys: Object.keys(newConfig), + defaultSort: newConfig.defaultSort, + newConfig, + }); // 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지 Object.entries(newConfig).forEach(([key, value]) => { + console.log(` -> handleUpdate: componentConfig.${key} =`, value); handleUpdate(`componentConfig.${key}`, value); }); }} diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 02ef8643..543724b5 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -1010,7 +1010,7 @@ export const TableListComponent: React.FC = ({ // unregisterTable 함수는 의존성이 없어 안정적임 ]); - // 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 + // 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 (없으면 defaultSort 적용) useEffect(() => { if (!tableConfig.selectedTable || !userId || hasInitializedSort.current) return; @@ -1024,12 +1024,21 @@ export const TableListComponent: React.FC = ({ setSortColumn(column); setSortDirection(direction); hasInitializedSort.current = true; + return; } } catch (error) { // 정렬 상태 복원 실패 } } - }, [tableConfig.selectedTable, userId]); + + // localStorage에 저장된 정렬이 없으면 defaultSort 설정 적용 + if (tableConfig.defaultSort?.columnName) { + console.log("📊 기본 정렬 설정 적용:", tableConfig.defaultSort); + setSortColumn(tableConfig.defaultSort.columnName); + setSortDirection(tableConfig.defaultSort.direction || "asc"); + hasInitializedSort.current = true; + } + }, [tableConfig.selectedTable, tableConfig.defaultSort, userId]); // 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기 useEffect(() => { @@ -1470,8 +1479,9 @@ export const TableListComponent: React.FC = ({ try { const page = tableConfig.pagination?.currentPage || currentPage; const pageSize = localPageSize; - const sortBy = sortColumn || undefined; - const sortOrder = sortDirection; + // 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용 + const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined; + const sortOrder = sortColumn ? sortDirection : (tableConfig.defaultSort?.direction || sortDirection); const search = searchTerm || undefined; // 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때) diff --git a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx index ff76960e..977830ca 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx @@ -319,7 +319,9 @@ export const TableListConfigPanel: React.FC = ({ const handleChange = (key: keyof TableListConfig, value: any) => { // 기존 config와 병합하여 전달 (다른 속성 손실 방지) - onChange({ ...config, [key]: value }); + const newConfig = { ...config, [key]: value }; + console.log("📊 TableListConfigPanel handleChange:", { key, value, newConfig }); + onChange(newConfig); }; const handleNestedChange = (parentKey: keyof TableListConfig, childKey: string, value: any) => { @@ -884,6 +886,67 @@ export const TableListConfigPanel: React.FC = ({
+ {/* 기본 정렬 설정 */} +
+
+

기본 정렬 설정

+

테이블 로드 시 기본 정렬 순서를 지정합니다

+
+
+
+
+ + +
+ + {config.defaultSort?.columnName && ( +
+ + +
+ )} +
+
+ {/* 가로 스크롤 및 컬럼 고정 */}
diff --git a/frontend/lib/registry/components/v2-table-list/types.ts b/frontend/lib/registry/components/v2-table-list/types.ts index a43ccdfa..1cc04375 100644 --- a/frontend/lib/registry/components/v2-table-list/types.ts +++ b/frontend/lib/registry/components/v2-table-list/types.ts @@ -278,6 +278,12 @@ export interface TableListConfig extends ComponentConfig { autoLoad: boolean; refreshInterval?: number; // 초 단위 + // 🆕 기본 정렬 설정 + defaultSort?: { + columnName: string; // 정렬할 컬럼명 + direction: "asc" | "desc"; // 정렬 방향 + }; + // 🆕 툴바 버튼 표시 설정 toolbar?: ToolbarConfig; -- 2.43.0 From 21a663a99b87de863ab8e015c04d330d86f267f6 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 5 Feb 2026 10:58:27 +0900 Subject: [PATCH 16/24] =?UTF-8?q?feat:=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=20=EA=B4=80=EA=B3=84=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EA=B9=85=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getReferencedByTables 함수에서 현재 사용자의 company_code를 우선적으로 사용하도록 로직을 개선하였습니다. - SQL 쿼리에서 ROW_NUMBER를 활용하여 같은 테이블/컬럼 조합에서 회사코드 우선순위로 하나만 선택하도록 하였습니다. - 로깅 메시지에 회사코드를 포함시켜, 조회 시 어떤 회사의 데이터가 사용되었는지 명확히 기록하도록 하였습니다. --- .../controllers/tableManagementController.ts | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 4fa08eed..a494ae3d 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -2344,6 +2344,8 @@ export async function getTableEntityRelations( * * table_type_columns에서 reference_table이 현재 테이블인 레코드를 찾아서 * 해당 테이블과 FK 컬럼 정보를 반환합니다. + * + * 우선순위: 현재 사용자의 company_code > 공통('*') */ export async function getReferencedByTables( req: AuthenticatedRequest, @@ -2351,9 +2353,11 @@ export async function getReferencedByTables( ): Promise { try { const { tableName } = req.params; + // 현재 사용자의 회사 코드 (없으면 '*' 사용) + const userCompanyCode = req.user?.companyCode || "*"; logger.info( - `=== 테이블 참조 관계 조회 시작: ${tableName} 을 참조하는 테이블 ===` + `=== 테이블 참조 관계 조회 시작: ${tableName} 을 참조하는 테이블 (회사코드: ${userCompanyCode}) ===` ); if (!tableName) { @@ -2371,23 +2375,41 @@ export async function getReferencedByTables( // table_type_columns에서 reference_table이 현재 테이블인 레코드 조회 // input_type이 'entity'인 것만 조회 (실제 FK 관계) + // 우선순위: 현재 사용자의 company_code > 공통('*') + // ROW_NUMBER를 사용해서 같은 테이블/컬럼 조합에서 회사코드 우선순위로 하나만 선택 const sqlQuery = ` + WITH ranked AS ( + SELECT + ttc.table_name, + ttc.column_name, + ttc.column_label, + ttc.reference_table, + ttc.reference_column, + ttc.display_column, + ttc.company_code, + ROW_NUMBER() OVER ( + PARTITION BY ttc.table_name, ttc.column_name + ORDER BY CASE WHEN ttc.company_code = $2 THEN 1 ELSE 2 END + ) as rn + FROM table_type_columns ttc + WHERE ttc.reference_table = $1 + AND ttc.input_type = 'entity' + AND ttc.company_code IN ($2, '*') + ) SELECT DISTINCT - ttc.table_name, - ttc.column_name, - ttc.column_label, - ttc.reference_table, - ttc.reference_column, - ttc.display_column, - ttc.table_name as table_label - FROM table_type_columns ttc - WHERE ttc.reference_table = $1 - AND ttc.input_type = 'entity' - AND ttc.company_code = '*' - ORDER BY ttc.table_name, ttc.column_name + table_name, + column_name, + column_label, + reference_table, + reference_column, + display_column, + table_name as table_label + FROM ranked + WHERE rn = 1 + ORDER BY table_name, column_name `; - const result = await query(sqlQuery, [tableName]); + const result = await query(sqlQuery, [tableName, userCompanyCode]); const referencedByTables = result.map((row: any) => ({ tableName: row.table_name, @@ -2400,7 +2422,7 @@ export async function getReferencedByTables( })); logger.info( - `테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견` + `테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견 (회사코드: ${userCompanyCode})` ); const response: ApiResponse = { -- 2.43.0 From ad7c5923a65a2109aa6e7ce14012229a8dc2ea11 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 5 Feb 2026 13:45:23 +0900 Subject: [PATCH 17/24] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 파일 정보 조회를 위한 getFileInfo 함수를 추가하여, 파일의 메타데이터를 공개 접근으로 조회할 수 있도록 하였습니다. - 파일 업로드 컴포넌트에서 파일 아이콘 매핑 및 파일 미리보기 기능을 개선하여 사용자 경험을 향상시켰습니다. - V2 파일 업로드 컴포넌트의 설정 패널을 추가하여, 파일 업로드 관련 설정을 보다 쉽게 관리할 수 있도록 하였습니다. - 파일 뷰어 모달을 추가하여 다양한 파일 형식의 미리보기를 지원합니다. --- .../src/controllers/fileController.ts | 51 + backend-node/src/routes/fileRoutes.ts | 8 + .../screen/InteractiveDataTable.tsx | 33 +- frontend/components/v2/V2Media.tsx | 22 +- frontend/lib/api/file.ts | 28 + .../registry/components/file-upload/index.ts | 9 +- .../registry/components/image-widget/index.ts | 7 +- frontend/lib/registry/components/index.ts | 1 + .../v2-file-upload/FileManagerModal.tsx | 421 ++++++ .../v2-file-upload/FileUploadComponent.tsx | 1345 +++++++++++++++++ .../v2-file-upload/FileUploadConfigPanel.tsx | 287 ++++ .../v2-file-upload/FileViewerModal.tsx | 543 +++++++ .../v2-file-upload/V2FileUploadRenderer.tsx | 56 + .../components/v2-file-upload/config.ts | 62 + .../components/v2-file-upload/index.ts | 46 + .../components/v2-file-upload/types.ts | 114 ++ .../components/v2-media/V2MediaRenderer.tsx | 26 +- .../v2-table-list/TableListComponent.tsx | 67 +- frontend/lib/utils/webTypeMapping.ts | 20 +- 19 files changed, 3103 insertions(+), 43 deletions(-) create mode 100644 frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx create mode 100644 frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx create mode 100644 frontend/lib/registry/components/v2-file-upload/FileUploadConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-file-upload/FileViewerModal.tsx create mode 100644 frontend/lib/registry/components/v2-file-upload/V2FileUploadRenderer.tsx create mode 100644 frontend/lib/registry/components/v2-file-upload/config.ts create mode 100644 frontend/lib/registry/components/v2-file-upload/index.ts create mode 100644 frontend/lib/registry/components/v2-file-upload/types.ts diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index a648a4f9..28a46232 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -1261,5 +1261,56 @@ export const setRepresentativeFile = async ( } }; +/** + * 파일 정보 조회 (메타데이터만, 파일 내용 없음) + * 공개 접근 허용 + */ +export const getFileInfo = async (req: Request, res: Response) => { + try { + const { objid } = req.params; + + if (!objid) { + return res.status(400).json({ + success: false, + message: "파일 ID가 필요합니다.", + }); + } + + // 파일 정보 조회 + const fileRecord = await queryOne( + `SELECT objid, real_file_name, file_size, file_ext, file_path, regdate, is_representative + FROM attach_file_info + WHERE objid = $1 AND status = 'ACTIVE'`, + [parseInt(objid)] + ); + + if (!fileRecord) { + return res.status(404).json({ + success: false, + message: "파일을 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + data: { + objid: fileRecord.objid.toString(), + realFileName: fileRecord.real_file_name, + fileSize: fileRecord.file_size, + fileExt: fileRecord.file_ext, + filePath: fileRecord.file_path, + regdate: fileRecord.regdate, + isRepresentative: fileRecord.is_representative, + }, + }); + } catch (error) { + console.error("파일 정보 조회 오류:", error); + res.status(500).json({ + success: false, + message: "파일 정보 조회 중 오류가 발생했습니다.", + }); + } +}; + // Multer 미들웨어 export export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일 diff --git a/backend-node/src/routes/fileRoutes.ts b/backend-node/src/routes/fileRoutes.ts index 4514e37f..562a0b7f 100644 --- a/backend-node/src/routes/fileRoutes.ts +++ b/backend-node/src/routes/fileRoutes.ts @@ -11,6 +11,7 @@ import { generateTempToken, getFileByToken, setRepresentativeFile, + getFileInfo, } from "../controllers/fileController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -31,6 +32,13 @@ router.get("/public/:token", getFileByToken); */ router.get("/preview/:objid", previewFile); +/** + * @route GET /api/files/info/:objid + * @desc 파일 정보 조회 (메타데이터만, 파일 내용 없음) - 공개 접근 허용 + * @access Public + */ +router.get("/info/:objid", getFileInfo); + // 모든 파일 API는 인증 필요 router.use(authenticateToken); diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 2c400df5..582aa413 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -43,7 +43,7 @@ import { } from "lucide-react"; import { tableTypeApi } from "@/lib/api/screen"; import { commonCodeApi } from "@/lib/api/commonCode"; -import { apiClient, getCurrentUser, UserInfo } from "@/lib/api/client"; +import { apiClient, getCurrentUser, UserInfo, getFullImageUrl } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup"; import { cn } from "@/lib/utils"; @@ -2224,6 +2224,37 @@ export const InteractiveDataTable: React.FC = ({ // 파일 타입 컬럼 처리 (가상 파일 컬럼 포함) const isFileColumn = actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn; + // 🖼️ 이미지 타입 컬럼: 썸네일로 표시 + const isImageColumn = actualWebType === "image" || actualWebType === "img"; + if (isImageColumn && value) { + // value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용 + // 🔑 download 대신 preview 사용 (공개 접근 허용) + const isObjid = /^\d+$/.test(String(value)); + const imageUrl = isObjid + ? `/api/files/preview/${value}` + : getFullImageUrl(String(value)); + + return ( +
+ 이미지 { + e.stopPropagation(); + // 이미지 클릭 시 크게 보기 (새 탭에서 열기) + window.open(imageUrl, "_blank"); + }} + onError={(e) => { + // 이미지 로드 실패 시 기본 아이콘 표시 + (e.target as HTMLImageElement).style.display = "none"; + }} + /> +
+ ); + } + // 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리) if (isFileColumn && rowData) { // 현재 행의 기본키 값 가져오기 diff --git a/frontend/components/v2/V2Media.tsx b/frontend/components/v2/V2Media.tsx index 7321808f..733d6657 100644 --- a/frontend/components/v2/V2Media.tsx +++ b/frontend/components/v2/V2Media.tsx @@ -124,7 +124,8 @@ export const V2Media = forwardRef( const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_')); const recordTableName = formData?.tableName || tableName; const recordId = formData?.id; - const effectiveColumnName = isRecordMode ? 'attachments' : (columnName || id || 'attachments'); + // 🔑 columnName 우선 사용 (실제 DB 컬럼명), 없으면 id, 최후에 attachments + const effectiveColumnName = columnName || id || 'attachments'; // 레코드용 targetObjid 생성 const getRecordTargetObjid = useCallback(() => { @@ -471,13 +472,21 @@ export const V2Media = forwardRef( // 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식 if (onFormDataChange && targetColumn) { + // 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값) + // 복수 파일: 콤마 구분 문자열로 전달 + const formValue = config.multiple + ? fileIds.join(',') + : (fileIds[0] || ''); + console.log("📝 [V2Media] formData 업데이트:", { columnName: targetColumn, fileIds, + formValue, + isMultiple: config.multiple, isRecordMode: effectiveIsRecordMode, }); // (fieldName: string, value: any) 형식으로 호출 - onFormDataChange(targetColumn, fileIds); + onFormDataChange(targetColumn, formValue); } // 그리드 파일 상태 새로고침 이벤트 발생 @@ -601,12 +610,19 @@ export const V2Media = forwardRef( // 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식 if (onFormDataChange && targetColumn) { + // 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값) + // 복수 파일: 콤마 구분 문자열로 전달 + const formValue = config.multiple + ? fileIds.join(',') + : (fileIds[0] || ''); + console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", { columnName: targetColumn, fileIds, + formValue, }); // (fieldName: string, value: any) 형식으로 호출 - onFormDataChange(targetColumn, fileIds); + onFormDataChange(targetColumn, formValue); } toast.success(`${fileName} 삭제 완료`); diff --git a/frontend/lib/api/file.ts b/frontend/lib/api/file.ts index e6cab8ae..f848c7e6 100644 --- a/frontend/lib/api/file.ts +++ b/frontend/lib/api/file.ts @@ -298,3 +298,31 @@ export const setRepresentativeFile = async (objid: string): Promise<{ throw new Error("대표 파일 설정에 실패했습니다."); } }; + +/** + * 파일 정보 조회 (메타데이터만, objid로 조회) + */ +export const getFileInfoByObjid = async (objid: string): Promise<{ + success: boolean; + data?: { + objid: string; + realFileName: string; + fileSize: number; + fileExt: string; + filePath: string; + regdate: string; + isRepresentative: boolean; + }; + message?: string; +}> => { + try { + const response = await apiClient.get(`/files/info/${objid}`); + return response.data; + } catch (error) { + console.error("파일 정보 조회 오류:", error); + return { + success: false, + message: "파일 정보 조회에 실패했습니다.", + }; + } +}; diff --git a/frontend/lib/registry/components/file-upload/index.ts b/frontend/lib/registry/components/file-upload/index.ts index fcca65cc..3f059ae1 100644 --- a/frontend/lib/registry/components/file-upload/index.ts +++ b/frontend/lib/registry/components/file-upload/index.ts @@ -14,22 +14,23 @@ import { FileUploadConfig } from "./types"; */ export const FileUploadDefinition = createComponentDefinition({ id: "file-upload", - name: "파일 업로드", - nameEng: "FileUpload Component", - description: "파일 업로드를 위한 파일 선택 컴포넌트", + name: "파일 업로드 (레거시)", + nameEng: "FileUpload Component (Legacy)", + description: "파일 업로드를 위한 파일 선택 컴포넌트 (레거시)", category: ComponentCategory.INPUT, webType: "file", component: FileUploadComponent, defaultConfig: { placeholder: "입력하세요", }, - defaultSize: { width: 350, height: 240 }, // 40 * 6 (파일 선택 + 목록 표시) + defaultSize: { width: 350, height: 240 }, configPanel: FileUploadConfigPanel, icon: "Edit", tags: [], version: "1.0.0", author: "개발팀", documentation: "https://docs.example.com/components/file-upload", + hidden: true, // v2-file-upload 사용으로 패널에서 숨김 }); // 타입 내보내기 diff --git a/frontend/lib/registry/components/image-widget/index.ts b/frontend/lib/registry/components/image-widget/index.ts index 67abbc80..aee663e8 100644 --- a/frontend/lib/registry/components/image-widget/index.ts +++ b/frontend/lib/registry/components/image-widget/index.ts @@ -13,9 +13,9 @@ import { ImageWidgetConfigPanel } from "./ImageWidgetConfigPanel"; */ export const ImageWidgetDefinition = createComponentDefinition({ id: "image-widget", - name: "이미지 위젯", - nameEng: "Image Widget", - description: "이미지 표시 및 업로드", + name: "이미지 위젯 (레거시)", + nameEng: "Image Widget (Legacy)", + description: "이미지 표시 및 업로드 (레거시)", category: ComponentCategory.INPUT, webType: "image", component: ImageWidget, @@ -32,6 +32,7 @@ export const ImageWidgetDefinition = createComponentDefinition({ version: "1.0.0", author: "개발팀", documentation: "https://docs.example.com/components/image-widget", + hidden: true, // v2-file-upload 사용으로 패널에서 숨김 }); // 컴포넌트 내보내기 diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 6519230d..172f0067 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -111,6 +111,7 @@ import "./v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스 import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트 import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트 import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트 +import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트 /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx b/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx new file mode 100644 index 00000000..de838fbf --- /dev/null +++ b/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx @@ -0,0 +1,421 @@ +"use client"; + +import React, { useState, useRef } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { FileInfo, FileUploadConfig } from "./types"; +import { + Upload, + Download, + Trash2, + Eye, + File, + FileText, + Image as ImageIcon, + Video, + Music, + Archive, + Presentation, + X, + Star +} from "lucide-react"; +import { formatFileSize } from "@/lib/utils"; +import { FileViewerModal } from "./FileViewerModal"; + +interface FileManagerModalProps { + isOpen: boolean; + onClose: () => void; + uploadedFiles: FileInfo[]; + onFileUpload: (files: File[]) => Promise; + onFileDownload: (file: FileInfo) => void; + onFileDelete: (file: FileInfo) => void; + onFileView: (file: FileInfo) => void; + onSetRepresentative?: (file: FileInfo) => void; // 대표 이미지 설정 콜백 + config: FileUploadConfig; + isDesignMode?: boolean; +} + +export const FileManagerModal: React.FC = ({ + isOpen, + onClose, + uploadedFiles, + onFileUpload, + onFileDownload, + onFileDelete, + onFileView, + onSetRepresentative, + config, + isDesignMode = false, +}) => { + const [dragOver, setDragOver] = useState(false); + const [uploading, setUploading] = useState(false); + const [viewerFile, setViewerFile] = useState(null); + const [isViewerOpen, setIsViewerOpen] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); // 선택된 파일 (좌측 미리보기용) + const [previewImageUrl, setPreviewImageUrl] = useState(null); // 이미지 미리보기 URL + const fileInputRef = useRef(null); + + // 파일 아이콘 가져오기 + const getFileIcon = (fileExt: string) => { + const ext = fileExt.toLowerCase(); + + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) { + return ; + } else if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) { + return ; + } else if (['xls', 'xlsx', 'csv'].includes(ext)) { + return ; + } else if (['ppt', 'pptx'].includes(ext)) { + return ; + } else if (['mp4', 'avi', 'mov', 'webm'].includes(ext)) { + return
)} - {/* 좌우 분할 레이아웃 */} + {/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
- {/* 좌측: 이미지 미리보기 */} -
- {selectedFile && previewImageUrl ? ( - {selectedFile.realFileName} - ) : selectedFile ? ( -
- {getFileIcon(selectedFile.fileExt)} -

미리보기 불가능

+ {/* 좌측: 이미지 미리보기 (확대/축소 가능) */} +
+ {/* 확대/축소 컨트롤 */} + {selectedFile && previewImageUrl && ( +
+ + + {Math.round(zoomLevel * 100)}% + + +
- ) : ( -
- -

파일을 선택하면 미리보기가 표시됩니다

+ )} + + {/* 이미지 미리보기 영역 - 마우스 휠로 확대/축소, 드래그로 이동 */} +
1 ? (isDragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in' + }`} + onWheel={(e) => { + if (selectedFile && previewImageUrl) { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.1 : 0.1; + setZoomLevel(prev => Math.min(4, Math.max(0.25, prev + delta))); + } + }} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + onMouseLeave={handleMouseUp} + > + {selectedFile && previewImageUrl ? ( + {selectedFile.realFileName} + ) : selectedFile ? ( +
+ {getFileIcon(selectedFile.fileExt)} +

미리보기 불가능

+
+ ) : ( +
+ +

파일을 선택하면 미리보기가 표시됩니다

+
+ )} +
+ + {/* 파일 정보 바 */} + {selectedFile && ( +
+ {selectedFile.realFileName}
)}
- {/* 우측: 파일 목록 */} -
+ {/* 우측: 파일 목록 (고정 너비) */} +

-- 2.43.0 From dd867efd0ae6d957af6cea54782129f4473c2d4a Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 5 Feb 2026 14:07:18 +0900 Subject: [PATCH 19/24] =?UTF-8?q?feat:=20ScreenModal=20=EB=B0=8F=20V2Selec?= =?UTF-8?q?t=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScreenModal에서 모달 크기 계산 로직을 개선하여, 콘텐츠가 화면 높이를 초과할 때만 스크롤이 필요하도록 수정하였습니다. - V2Select 및 관련 컴포넌트에서 height 및 style props를 추가하여, 사용자 정의 스타일을 보다 효과적으로 적용할 수 있도록 하였습니다. - DropdownSelect에서 height 스타일을 직접 전달하여, 다양한 높이 설정을 지원하도록 개선하였습니다. - CategorySelectComponent에서 라벨 표시 및 높이 계산 로직을 추가하여, 사용자 경험을 향상시켰습니다. --- frontend/components/common/ScreenModal.tsx | 20 ++- frontend/components/ui/select.tsx | 9 +- frontend/components/v2/V2Date.tsx | 16 +- frontend/components/v2/V2Input.tsx | 2 +- frontend/components/v2/V2List.tsx | 4 +- frontend/components/v2/V2Select.tsx | 46 ++++-- .../lib/registry/DynamicComponentRenderer.tsx | 26 +++- .../CategorySelectComponent.tsx | 143 ++++++++++++++---- .../components/v2-select/V2SelectRenderer.tsx | 25 ++- .../registry/components/v2-select/index.ts | 5 +- 10 files changed, 230 insertions(+), 66 deletions(-) diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 2f6ae80f..746c85f1 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -531,26 +531,34 @@ export const ScreenModal: React.FC = ({ className }) => { return { className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0", style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용 + needsScroll: false, }; } // 화면관리에서 설정한 크기 = 컨텐츠 영역 크기 - // 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 - // 🔧 여백 최소화: 디자이너와 일치하도록 조정 + // 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + 패딩 + // 🔧 DialogContent의 gap-4 (16px × 2) + 컨텐츠 pt-6 (24px) 포함 const headerHeight = 48; // DialogHeader (타이틀 + border-b + py-3) const footerHeight = 44; // 연속 등록 모드 체크박스 영역 + const dialogGap = 32; // gap-4 × 2 (header-content, content-footer 사이) + const contentTopPadding = 24; // pt-6 (컨텐츠 영역 상단 패딩) const horizontalPadding = 16; // 좌우 패딩 최소화 - const totalHeight = screenDimensions.height + headerHeight + footerHeight; + const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + contentTopPadding; + const maxAvailableHeight = window.innerHeight * 0.95; + + // 콘텐츠가 화면 높이를 초과할 때만 스크롤 필요 + const needsScroll = totalHeight > maxAvailableHeight; return { className: "overflow-hidden p-0", style: { width: `${Math.min(screenDimensions.width + horizontalPadding, window.innerWidth * 0.98)}px`, - height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`, + // 🔧 height 대신 max-height만 설정 - 콘텐츠가 작으면 자동으로 줄어듦 + maxHeight: `${maxAvailableHeight}px`, maxWidth: "98vw", - maxHeight: "95vh", }, + needsScroll, }; }; @@ -634,7 +642,7 @@ export const ScreenModal: React.FC = ({ className }) => {
{loading ? (
diff --git a/frontend/components/ui/select.tsx b/frontend/components/ui/select.tsx index 81e90fd3..64fef9c4 100644 --- a/frontend/components/ui/select.tsx +++ b/frontend/components/ui/select.tsx @@ -22,18 +22,25 @@ function SelectTrigger({ className, size = "xs", children, + style, ...props }: React.ComponentProps & { size?: "xs" | "sm" | "default"; }) { + // className에 h-full/h-[ 또는 style.height가 있으면 data-size 높이를 무시 + const hasCustomHeight = className?.includes("h-full") || className?.includes("h-[") || !!style?.height; + return ( {children} diff --git a/frontend/components/v2/V2Date.tsx b/frontend/components/v2/V2Date.tsx index 7e930840..91a4cf5d 100644 --- a/frontend/components/v2/V2Date.tsx +++ b/frontend/components/v2/V2Date.tsx @@ -222,14 +222,14 @@ const RangeDatePicker = forwardRef< ); return ( -
+
{/* 시작 날짜 */} @@ -164,9 +162,9 @@ export const ImageWidget: React.FC {/* 필수 필드 경고 */} - {required && !imageUrl && ( -
* 이미지를 업로드해야 합니다
- )} + {required && !imageUrl &&
* 이미지를 업로드해야 합니다
}
); }; diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index 8e27e38f..a284f26e 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -551,10 +551,6 @@ export const V2Input = forwardRef((props, ref) => // 🆕 채번 규칙 ID를 formData에 저장 (저장 시 allocateCode 호출을 위해) if (parsed.numberingRuleId && onFormDataChange && columnName) { onFormDataChange(`${columnName}_numberingRuleId`, parsed.numberingRuleId); - console.log("🔧 채번 규칙 ID를 formData에 저장:", { - key: `${columnName}_numberingRuleId`, - value: parsed.numberingRuleId, - }); } } catch { // JSON 파싱 실패 @@ -571,11 +567,6 @@ export const V2Input = forwardRef((props, ref) => // 채번 코드 생성 (formDataRef.current 사용하여 최신 formData 전달) const currentFormData = formDataRef.current; - console.log("🔍 [V2Input] 채번 미리보기 호출:", { - numberingRuleId, - formDataKeys: Object.keys(currentFormData), - materialValue: currentFormData.material // 재질 값 로깅 - }); const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData); if (previewResponse.success && previewResponse.data?.generatedCode) { @@ -655,11 +646,6 @@ export const V2Input = forwardRef((props, ref) => // formData에 직접 주입 if (event.detail?.formData && columnName) { event.detail.formData[columnName] = currentValue; - console.log("🔧 [V2Input] beforeFormSave에서 채번 값 주입:", { - columnName, - manualInputValue, - currentValue, - }); } }; diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 84dd0d3c..a1bf35f9 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -758,16 +758,6 @@ export const V2Select = forwardRef( const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; - // 🔍 디버깅: 높이값 확인 (warn으로 변경하여 캡처되도록) - console.warn("🔍 [V2Select] 높이 디버깅:", { - id, - "size?.height": size?.height, - "style?.height": style?.height, - componentHeight, - size, - style, - }); - // 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정) const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14; const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; diff --git a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx index 056facac..62aa9246 100644 --- a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx @@ -27,11 +27,7 @@ interface V2SelectConfigPanelProps { inputType?: string; } -export const V2SelectConfigPanel: React.FC = ({ - config, - onChange, - inputType, -}) => { +export const V2SelectConfigPanel: React.FC = ({ config, onChange, inputType }) => { // 엔티티 타입인지 확인 const isEntityType = inputType === "entity"; // 엔티티 테이블의 컬럼 목록 @@ -55,18 +51,18 @@ export const V2SelectConfigPanel: React.FC = ({ const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=500`); const data = response.data.data || response.data; const columns = data.columns || data || []; - + const columnOptions: ColumnOption[] = columns.map((col: any) => { const name = col.columnName || col.column_name || col.name; // displayName 우선 사용 const label = col.displayName || col.display_name || col.columnLabel || col.column_label || name; - + return { columnName: name, columnLabel: label, }; }); - + setEntityColumns(columnOptions); } catch (error) { console.error("컬럼 목록 조회 실패:", error); @@ -85,7 +81,7 @@ export const V2SelectConfigPanel: React.FC = ({ // 정적 옵션 관리 const options = config.options || []; - + const addOption = () => { const newOptions = [...options, { value: "", label: "" }]; updateConfig("options", newOptions); @@ -107,10 +103,7 @@ export const V2SelectConfigPanel: React.FC = ({ {/* 선택 모드 */}
- updateConfig("mode", value)}> @@ -130,10 +123,7 @@ export const V2SelectConfigPanel: React.FC = ({ {/* 데이터 소스 */}
- updateConfig("source", value)}> @@ -151,59 +141,51 @@ export const V2SelectConfigPanel: React.FC = ({
-
-
+
{options.map((option: any, index: number) => (
updateOption(index, "value", e.target.value)} placeholder="값" - className="h-7 text-xs flex-1" + className="h-7 flex-1 text-xs" /> updateOption(index, "label", e.target.value)} placeholder="표시 텍스트" - className="h-7 text-xs flex-1" + className="h-7 flex-1 text-xs" />
))} {options.length === 0 && ( -

- 옵션을 추가해주세요 -

+

옵션을 추가해주세요

)}
- + {/* 기본값 설정 */} {options.length > 0 && ( -
+
-

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

+

화면 로드 시 자동 선택될 값

)}
@@ -228,16 +208,13 @@ export const V2SelectConfigPanel: React.FC = ({
{config.codeGroup ? ( -

{config.codeGroup}

+

{config.codeGroup}

) : ( -

- 테이블 타입 관리에서 코드 그룹을 설정해주세요 -

+

테이블 타입 관리에서 코드 그룹을 설정해주세요

)}
)} - {/* 엔티티(참조 테이블) 설정 */} {config.source === "entity" && (
@@ -248,16 +225,16 @@ export const V2SelectConfigPanel: React.FC = ({ readOnly disabled placeholder="테이블 타입 관리에서 설정" - className="h-8 text-xs bg-muted" + className="bg-muted h-8 text-xs" /> -

+

조인할 테이블명 (테이블 타입 관리에서 설정된 경우 자동 입력됨)

{/* 컬럼 로딩 중 표시 */} {loadingColumns && ( -
+
컬럼 목록 로딩 중...
@@ -291,7 +268,7 @@ export const V2SelectConfigPanel: React.FC = ({ className="h-8 text-xs" /> )} -

저장될 값

+

저장될 값

@@ -319,7 +296,7 @@ export const V2SelectConfigPanel: React.FC = ({ className="h-8 text-xs" /> )} -

화면에 표시될 값

+

화면에 표시될 값

@@ -337,14 +314,16 @@ export const V2SelectConfigPanel: React.FC = ({ {/* 추가 옵션 */}
- +
updateConfig("multiple", checked)} /> - +
@@ -353,7 +332,9 @@ export const V2SelectConfigPanel: React.FC = ({ checked={config.searchable || false} onCheckedChange={(checked) => updateConfig("searchable", checked)} /> - +
@@ -362,7 +343,9 @@ export const V2SelectConfigPanel: React.FC = ({ checked={config.allowClear !== false} onCheckedChange={(checked) => updateConfig("allowClear", checked)} /> - +
diff --git a/frontend/lib/registry/ComponentRegistry.ts b/frontend/lib/registry/ComponentRegistry.ts index 00866c68..f6065ff5 100644 --- a/frontend/lib/registry/ComponentRegistry.ts +++ b/frontend/lib/registry/ComponentRegistry.ts @@ -31,10 +31,7 @@ export class ComponentRegistry { throw new Error(`컴포넌트 등록 실패 (${definition.id}): ${validation.errors.join(", ")}`); } - // 중복 등록 체크 - if (this.components.has(definition.id)) { - console.warn(`⚠️ 컴포넌트 중복 등록: ${definition.id} - 기존 정의를 덮어씁니다.`); - } + // 중복 등록 체크 (기존 정의를 덮어씀) // 타임스탬프 추가 const enhancedDefinition = { @@ -64,7 +61,6 @@ export class ComponentRegistry { static unregisterComponent(id: string): void { const definition = this.components.get(id); if (!definition) { - console.warn(`⚠️ 등록되지 않은 컴포넌트 해제 시도: ${id}`); return; } @@ -76,8 +72,6 @@ export class ComponentRegistry { data: definition, timestamp: new Date(), }); - - console.log(`🗑️ 컴포넌트 해제: ${id}`); } /** @@ -355,7 +349,6 @@ export class ComponentRegistry { }, force: async () => { // hotReload 기능 비활성화 (불필요) - console.log("⚠️ 강제 Hot Reload는 더 이상 필요하지 않습니다"); }, }, diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 7c6470fa..570a82a7 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -220,8 +220,8 @@ export function RepeaterTable({ columns .filter((col) => !col.hidden) .forEach((col) => { - widths[col.field] = col.width ? parseInt(col.width) : 120; - }); + widths[col.field] = col.width ? parseInt(col.width) : 120; + }); return widths; }); @@ -404,10 +404,10 @@ export function RepeaterTable({ // 데이터가 있으면 데이터 기반 자동 맞춤, 없으면 균등 분배 const timer = setTimeout(() => { if (data.length > 0) { - applyAutoFitWidths(); - } else { - applyEqualizeWidths(); - } + applyAutoFitWidths(); + } else { + applyEqualizeWidths(); + } }, 50); return () => clearTimeout(timer); @@ -654,11 +654,17 @@ export function RepeaterTable({

+ 순서 + @@ -810,7 +816,7 @@ export function RepeaterTable({ diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx index 78e58bfe..e7917dd9 100644 --- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx @@ -90,7 +90,7 @@ export function SimpleRepeaterTableComponent({ const newRowDefaults = componentConfig?.newRowDefaults || {}; const summaryConfig = componentConfig?.summaryConfig; const maxHeight = componentConfig?.maxHeight || propMaxHeight || "240px"; - + // 🆕 컴포넌트 레벨의 저장 테이블 설정 const componentTargetTable = componentConfig?.targetTable || componentConfig?.saveTable; const componentFkColumn = componentConfig?.fkColumn; @@ -149,14 +149,11 @@ export function SimpleRepeaterTableComponent({ } // API 호출 - const response = await apiClient.post( - `/table-management/tables/${initialConfig.sourceTable}/data`, - { - search: filters, - page: 1, - size: 1000, // 대량 조회 - } - ); + const response = await apiClient.post(`/table-management/tables/${initialConfig.sourceTable}/data`, { + search: filters, + page: 1, + size: 1000, // 대량 조회 + }); if (response.data.success && response.data.data?.data) { const loadedData = response.data.data.data; @@ -182,7 +179,7 @@ export function SimpleRepeaterTableComponent({ // 2. 조인 데이터 처리 const joinColumns = columns.filter( - (col) => col.sourceConfig?.type === "join" && col.sourceConfig.joinTable && col.sourceConfig.joinKey + (col) => col.sourceConfig?.type === "join" && col.sourceConfig.joinTable && col.sourceConfig.joinKey, ); if (joinColumns.length > 0) { @@ -208,25 +205,20 @@ export function SimpleRepeaterTableComponent({ const [tableName] = groupKey.split(":"); // 조인 키 값 수집 (중복 제거) - const keyValues = Array.from(new Set( - baseMappedData - .map((row: any) => row[key]) - .filter((v: any) => v !== undefined && v !== null) - )); + const keyValues = Array.from( + new Set(baseMappedData.map((row: any) => row[key]).filter((v: any) => v !== undefined && v !== null)), + ); if (keyValues.length === 0) return; try { // 조인 테이블 조회 // refKey(타겟 테이블 컬럼)로 검색 - const response = await apiClient.post( - `/table-management/tables/${tableName}/data`, - { - search: { [refKey]: keyValues }, // { id: [1, 2, 3] } - page: 1, - size: 1000, - } - ); + const response = await apiClient.post(`/table-management/tables/${tableName}/data`, { + search: { [refKey]: keyValues }, // { id: [1, 2, 3] } + page: 1, + size: 1000, + }); if (response.data.success && response.data.data?.data) { const joinedRows = response.data.data.data; @@ -251,7 +243,7 @@ export function SimpleRepeaterTableComponent({ console.error(`조인 실패 (${tableName}):`, error); // 실패 시 무시하고 진행 (값은 undefined) } - }) + }), ); } @@ -296,7 +288,7 @@ export function SimpleRepeaterTableComponent({ // 🆕 컴포넌트 레벨의 targetTable이 설정되어 있으면 우선 사용 if (componentTargetTable) { console.log("✅ [SimpleRepeaterTable] 컴포넌트 레벨 저장 테이블 사용:", componentTargetTable); - + // 모든 행을 해당 테이블에 저장 const dataToSave = value.map((row: any) => { // 메타데이터 필드 제외 (_, _rowIndex 등) @@ -399,9 +391,12 @@ export function SimpleRepeaterTableComponent({ // 기존 onFormDataChange도 호출 (호환성) if (onFormDataChange && columnName) { // 테이블별 데이터를 통합하여 전달 - onFormDataChange(columnName, Object.entries(dataByTable).flatMap(([table, rows]) => - rows.map((row: any) => ({ ...row, _targetTable: table })) - )); + onFormDataChange( + columnName, + Object.entries(dataByTable).flatMap(([table, rows]) => + rows.map((row: any) => ({ ...row, _targetTable: table })), + ), + ); } }; @@ -543,24 +538,14 @@ export function SimpleRepeaterTableComponent({ if (!allowAdd || readOnly || value.length >= maxRows) return null; return ( - ); }; - const renderCell = ( - row: any, - column: SimpleRepeaterColumnConfig, - rowIndex: number - ) => { + const renderCell = (row: any, column: SimpleRepeaterColumnConfig, rowIndex: number) => { const cellValue = row[column.field]; // 계산 필드는 편집 불가 @@ -583,9 +568,7 @@ export function SimpleRepeaterTableComponent({ - handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0) - } + onChange={(e) => handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)} className="h-7 text-xs" /> ); @@ -604,19 +587,19 @@ export function SimpleRepeaterTableComponent({ return ( ); @@ -636,11 +619,11 @@ export function SimpleRepeaterTableComponent({ // 로딩 중일 때 if (isLoading) { return ( -
+
- -

데이터를 불러오는 중...

+ +

데이터를 불러오는 중...

@@ -650,14 +633,14 @@ export function SimpleRepeaterTableComponent({ // 에러 발생 시 if (loadError) { return ( -
+
-
- +
+
-

데이터 로드 실패

-

{loadError}

+

데이터 로드 실패

+

{loadError}

@@ -668,30 +651,27 @@ export function SimpleRepeaterTableComponent({ const totalColumns = columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0); return ( -
+
{/* 상단 행 추가 버튼 */} {allowAdd && addButtonPosition !== "bottom" && ( -
+
)} -
+
{showRowNumber && ( - )} {columns.map((col) => ( ))} {!readOnly && allowDelete && ( - )} @@ -708,11 +688,7 @@ export function SimpleRepeaterTableComponent({ {value.length === 0 ? ( - ) : ( value.map((row, rowIndex) => ( - + {showRowNumber && ( - )} @@ -743,7 +719,7 @@ export function SimpleRepeaterTableComponent({ size="sm" onClick={() => handleRowDelete(rowIndex)} disabled={value.length <= minRows} - className="h-7 w-7 p-0 text-destructive hover:text-destructive disabled:opacity-50" + className="text-destructive hover:text-destructive h-7 w-7 p-0 disabled:opacity-50" > @@ -758,35 +734,29 @@ export function SimpleRepeaterTableComponent({ {/* 합계 표시 */} {summaryConfig?.enabled && summaryValues && ( -
-
+
+
{summaryConfig.title && ( -
- {summaryConfig.title} -
+
{summaryConfig.title}
)} -
+
{summaryConfig.fields.map((field) => (
- {field.label} - + {field.label} + {formatSummaryValue(field, summaryValues[field.field] || 0)}
@@ -798,10 +768,10 @@ export function SimpleRepeaterTableComponent({ {/* 하단 행 추가 버튼 */} {allowAdd && addButtonPosition !== "top" && value.length > 0 && ( -
+
{maxRows !== Infinity && ( - + {value.length} / {maxRows} )} @@ -810,4 +780,3 @@ export function SimpleRepeaterTableComponent({
); } - diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index fcf2e97f..f8b154d6 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -1098,28 +1098,10 @@ export const ButtonPrimaryComponent: React.FC = ({ const screenContextFormData = screenContext?.formData || {}; const propsFormData = formData || {}; - // 🔧 디버그: formData 소스 확인 - console.log("🔍 [v2-button-primary] formData 소스 확인:", { - propsFormDataKeys: Object.keys(propsFormData), - screenContextFormDataKeys: Object.keys(screenContextFormData), - propsHasCompanyImage: "company_image" in propsFormData, - propsHasCompanyLogo: "company_logo" in propsFormData, - screenHasCompanyImage: "company_image" in screenContextFormData, - screenHasCompanyLogo: "company_logo" in screenContextFormData, - }); - // 병합: splitPanelParentData를 기본으로, props.formData, screenContext.formData 순으로 오버라이드 // (일반 폼 필드는 props.formData, RepeaterFieldGroup은 screenContext.formData에 있음) let effectiveFormData = { ...propsFormData, ...screenContextFormData }; - console.log("🔍 [v2-button-primary] effectiveFormData 병합 결과:", { - keys: Object.keys(effectiveFormData), - hasCompanyImage: "company_image" in effectiveFormData, - hasCompanyLogo: "company_logo" in effectiveFormData, - companyImageValue: effectiveFormData.company_image, - companyLogoValue: effectiveFormData.company_logo, - }); - // 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용 if (splitPanelPosition === "right" && Object.keys(effectiveFormData).length === 0 && splitPanelParentData) { effectiveFormData = { ...splitPanelParentData }; @@ -1289,20 +1271,18 @@ export const ButtonPrimaryComponent: React.FC = ({ // 🔧 component.style에서 background/backgroundColor 충돌 방지 (width/height는 허용) const userStyle = component.style ? Object.fromEntries( - Object.entries(component.style).filter( - ([key]) => !["background", "backgroundColor"].includes(key), - ), + Object.entries(component.style).filter(([key]) => !["background", "backgroundColor"].includes(key)), ) : {}; // 🔧 사용자가 설정한 크기 우선 사용, 없으면 100% - const buttonWidth = component.size?.width ? `${component.size.width}px` : (style?.width || "100%"); - const buttonHeight = component.size?.height ? `${component.size.height}px` : (style?.height || "100%"); + const buttonWidth = component.size?.width ? `${component.size.width}px` : style?.width || "100%"; + const buttonHeight = component.size?.height ? `${component.size.height}px` : style?.height || "100%"; const buttonElementStyle: React.CSSProperties = { width: buttonWidth, height: buttonHeight, - minHeight: "32px", // 🔧 최소 높이를 32px로 줄임 + minHeight: "32px", // 🔧 최소 높이를 32px로 줄임 border: "none", borderRadius: "0.5rem", backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, @@ -1328,26 +1308,26 @@ export const ButtonPrimaryComponent: React.FC = ({ // 버튼 텍스트 결정 (다양한 소스에서 가져옴) // "기본 버튼"은 컴포넌트 생성 시 기본값이므로 무시 const labelValue = component.label === "기본 버튼" ? undefined : component.label; - + // 액션 타입에 따른 기본 텍스트 (modal 액션과 동일하게) const actionType = processedConfig.action?.type || component.componentConfig?.action?.type; const actionDefaultText: Record = { save: "저장", - delete: "삭제", + delete: "삭제", modal: "등록", edit: "수정", copy: "복사", close: "닫기", cancel: "취소", }; - - const buttonContent = - processedConfig.text || - component.webTypeConfig?.text || - component.componentConfig?.text || - component.config?.text || + + const buttonContent = + processedConfig.text || + component.webTypeConfig?.text || + component.componentConfig?.text || + component.config?.text || component.style?.labelText || - labelValue || + labelValue || actionDefaultText[actionType as string] || "버튼"; diff --git a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx index cc2a8ea3..1f8232d8 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx @@ -123,34 +123,16 @@ const FileUploadComponent: React.FC = ({ }, [isRecordMode, recordTableName, recordId, columnName]); // 🔑 레코드별 고유 키 생성 (localStorage, 전역 상태용) + // 🆕 columnName을 포함하여 같은 화면의 여러 파일 업로드 컴포넌트 구분 const getUniqueKey = useCallback(() => { if (isRecordMode && recordTableName && recordId) { - // 레코드 모드: 테이블명:레코드ID:컴포넌트ID 형태로 고유 키 생성 - return `fileUpload_${recordTableName}_${recordId}_${component.id}`; + // 레코드 모드: 테이블명:레코드ID:컴포넌트ID:컬럼명 형태로 고유 키 생성 + return `fileUpload_${recordTableName}_${recordId}_${component.id}_${columnName}`; } - // 기본 모드: 컴포넌트 ID만 사용 - return `fileUpload_${component.id}`; - }, [isRecordMode, recordTableName, recordId, component.id]); + // 기본 모드: 컴포넌트 ID + 컬럼명 사용 + return `fileUpload_${component.id}_${columnName}`; + }, [isRecordMode, recordTableName, recordId, component.id, columnName]); - // 🔍 디버깅: 레코드 모드 상태 로깅 - useEffect(() => { - console.log("📎 [FileUploadComponent] 모드 확인:", { - isRecordMode, - recordTableName, - recordId, - columnName, - targetObjid: getRecordTargetObjid(), - uniqueKey: getUniqueKey(), - formDataKeys: formData ? Object.keys(formData) : [], - // 🔍 추가 디버깅: formData.id 확인 (수정 모드 판단에 사용됨) - "formData.id": formData?.id, - "formData.tableName": formData?.tableName, - "formData.image": formData?.image, - "component.tableName": component.tableName, - "component.columnName": component.columnName, - "component.id": component.id, - }); - }, [isRecordMode, recordTableName, recordId, columnName, getRecordTargetObjid, getUniqueKey, formData, component.tableName, component.columnName, component.id]); // 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드 const prevRecordIdRef = useRef(null); @@ -160,19 +142,12 @@ const FileUploadComponent: React.FC = ({ const modeChanged = prevIsRecordModeRef.current !== null && prevIsRecordModeRef.current !== isRecordMode; if (recordIdChanged || modeChanged) { - console.log("📎 [FileUploadComponent] 레코드 상태 변경 감지:", { - prevRecordId: prevRecordIdRef.current, - currentRecordId: recordId, - prevIsRecordMode: prevIsRecordModeRef.current, - currentIsRecordMode: isRecordMode, - }); prevRecordIdRef.current = recordId; prevIsRecordModeRef.current = isRecordMode; // 레코드 ID가 변경되거나 등록 모드(isRecordMode=false)로 전환되면 파일 목록 초기화 // 등록 모드에서는 항상 빈 상태로 시작해야 함 if (isRecordMode || !recordId) { - console.log("📎 [FileUploadComponent] 파일 목록 초기화 (새 레코드 또는 레코드 변경)"); setUploadedFiles([]); setRepresentativeImageUrl(null); } @@ -189,7 +164,6 @@ const FileUploadComponent: React.FC = ({ // 등록 모드(새 레코드)인 경우 파일 복원 스킵 - 빈 상태 유지 if (!isRecordMode || !recordId) { - console.log("📎 [FileUploadComponent] 등록 모드: 파일 복원 스킵 (빈 상태 유지)"); return; } @@ -200,13 +174,6 @@ const FileUploadComponent: React.FC = ({ if (backupFiles) { const parsedFiles = JSON.parse(backupFiles); if (parsedFiles.length > 0) { - console.log("🚀 컴포넌트 마운트 시 파일 즉시 복원:", { - uniqueKey: backupKey, - componentId: component.id, - recordId: recordId, - restoredFiles: parsedFiles.length, - files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })), - }); setUploadedFiles(parsedFiles); // 전역 상태에도 복원 (레코드별 고유 키 사용) @@ -224,26 +191,20 @@ const FileUploadComponent: React.FC = ({ }, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행 // 🔑 수정 모드: formData[columnName]에 저장된 objid로 이미지 로드 - // 이 로직은 isRecordMode와 상관없이 formData에 이미지 objid가 있으면 표시 + // 🆕 formData 전체가 아닌 특정 컬럼 값만 의존하도록 수정 (다른 컴포넌트 영향 방지) + const imageObjidFromFormData = formData?.[columnName]; + useEffect(() => { - const imageObjid = formData?.[columnName]; - // 이미지 objid가 있고, 숫자 문자열인 경우에만 처리 - if (imageObjid && /^\d+$/.test(String(imageObjid))) { - console.log("🖼️ [FileUploadComponent] formData에서 이미지 objid 발견:", { - columnName, - imageObjid, - currentFilesCount: uploadedFiles.length, - }); + if (imageObjidFromFormData && /^\d+$/.test(String(imageObjidFromFormData))) { + const objidStr = String(imageObjidFromFormData); // 이미 같은 objid의 파일이 로드되어 있으면 스킵 - const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === String(imageObjid)); + const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === objidStr); if (alreadyLoaded) { - console.log("🖼️ [FileUploadComponent] 이미 로드된 이미지, 스킵"); return; } - const objidStr = String(imageObjid); const previewUrl = `/api/files/preview/${objidStr}`; // 🔑 실제 파일 정보 조회 @@ -254,12 +215,6 @@ const FileUploadComponent: React.FC = ({ if (fileInfoResponse.success && fileInfoResponse.data) { const { realFileName, fileSize, fileExt, regdate, isRepresentative } = fileInfoResponse.data; - console.log("🖼️ [FileUploadComponent] 파일 정보 조회 성공:", { - objid: objidStr, - realFileName, - fileExt, - }); - const fileInfo = { objid: objidStr, realFileName: realFileName, @@ -296,46 +251,39 @@ const FileUploadComponent: React.FC = ({ } })(); } - }, [formData, columnName, uploadedFiles]); + }, [imageObjidFromFormData, columnName, component.id]); // 🆕 formData 대신 특정 컬럼 값만 의존 // 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너 + // 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분 useEffect(() => { const handleDesignModeFileChange = (event: CustomEvent) => { - console.log("🎯🎯🎯 FileUploadComponent 화면설계 모드 파일 변경 이벤트 수신:", { - eventComponentId: event.detail.componentId, - currentComponentId: component.id, - isMatch: event.detail.componentId === component.id, - filesCount: event.detail.files?.length || 0, - action: event.detail.action, - source: event.detail.source, - eventDetail: event.detail, - }); + const eventColumnName = event.detail.eventColumnName || event.detail.columnName; + + // 🆕 고유 키 또는 (컴포넌트ID + 컬럼명) 조합으로 체크 + const isForThisComponent = + (event.detail.uniqueKey && event.detail.uniqueKey === currentUniqueKey) || + (event.detail.componentId === component.id && eventColumnName === columnName) || + (event.detail.componentId === component.id && !eventColumnName); // 이전 호환성 - // 현재 컴포넌트와 일치하고 화면설계 모드에서 온 이벤트인 경우 - if (event.detail.componentId === component.id && event.detail.source === "designMode") { + // 🆕 현재 컴포넌트와 일치하고 화면설계 모드에서 온 이벤트인 경우 + if (isForThisComponent && event.detail.source === "designMode") { // 파일 상태 업데이트 const newFiles = event.detail.files || []; setUploadedFiles(newFiles); // localStorage 백업 업데이트 (레코드별 고유 키 사용) try { - const backupKey = getUniqueKey(); + const backupKey = currentUniqueKey; localStorage.setItem(backupKey, JSON.stringify(newFiles)); - console.log("💾 화면설계 모드 동기화 후 localStorage 백업 업데이트:", { - uniqueKey: backupKey, - componentId: component.id, - recordId: recordId, - fileCount: newFiles.length, - }); } catch (e) { console.warn("localStorage 백업 업데이트 실패:", e); } - // 전역 상태 업데이트 + // 전역 상태 업데이트 (🆕 고유 키 사용) if (typeof window !== "undefined") { (window as any).globalFileState = { ...(window as any).globalFileState, - [component.id]: newFiles, + [currentUniqueKey]: newFiles, }; } @@ -346,11 +294,6 @@ const FileUploadComponent: React.FC = ({ lastFileUpdate: event.detail.timestamp, }); } - - console.log("🎉🎉🎉 화면설계 모드 → 실제 화면 동기화 완료:", { - componentId: component.id, - finalFileCount: newFiles.length, - }); } }; @@ -369,25 +312,10 @@ const FileUploadComponent: React.FC = ({ // 🔑 등록 모드(새 레코드)인 경우 파일 조회 스킵 - 빈 상태 유지 if (!isRecordMode || !recordId) { - console.log("📂 [FileUploadComponent] 등록 모드: 파일 조회 스킵 (빈 상태 유지)", { - isRecordMode, - recordId, - componentId: component.id, - }); return false; } try { - // 🔑 레코드 모드: 해당 행의 파일만 조회 - if (isRecordMode && recordTableName && recordId) { - console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", { - tableName: recordTableName, - recordId: recordId, - columnName: columnName, - targetObjid: getRecordTargetObjid(), - }); - } - // 1. formData에서 screenId 가져오기 let screenId = formData?.screenId; @@ -424,8 +352,6 @@ const FileUploadComponent: React.FC = ({ columnName: columnName, // 🔑 레코드 모드에서 사용하는 columnName }; - console.log("📂 [FileUploadComponent] 파일 조회 파라미터:", params); - const response = await getComponentFiles(params); if (response.success) { @@ -457,12 +383,6 @@ const FileUploadComponent: React.FC = ({ const additionalFiles = parsedBackupFiles.filter((f: any) => !serverObjIds.has(f.objid)); finalFiles = [...formattedFiles, ...additionalFiles]; - console.log("📂 [FileUploadComponent] 파일 병합 완료:", { - uniqueKey, - serverFiles: formattedFiles.length, - localFiles: parsedBackupFiles.length, - finalFiles: finalFiles.length, - }); } } catch (e) { console.warn("파일 병합 중 오류:", e); @@ -505,16 +425,6 @@ const FileUploadComponent: React.FC = ({ const componentFiles = (component as any)?.uploadedFiles || []; const lastUpdate = (component as any)?.lastFileUpdate; - console.log("🔄 FileUploadComponent 파일 동기화 시작:", { - componentId: component.id, - componentFiles: componentFiles.length, - formData: formData, - screenId: formData?.screenId, - tableName: formData?.tableName, // 🔍 테이블명 확인 - recordId: formData?.id, // 🔍 레코드 ID 확인 - currentUploadedFiles: uploadedFiles.length, - }); - // 🔒 항상 DB에서 최신 파일 목록을 조회 (멀티테넌시 격리) loadComponentFiles().then((dbLoadSuccess) => { if (dbLoadSuccess) { @@ -523,9 +433,10 @@ const FileUploadComponent: React.FC = ({ // DB 로드 실패 시에만 기존 로직 사용 (하위 호환성) - // 전역 상태에서 최신 파일 정보 가져오기 + // 전역 상태에서 최신 파일 정보 가져오기 (🆕 고유 키 사용) const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; - const globalFiles = globalFileState[component.id] || []; + const uniqueKeyForFallback = getUniqueKey(); + const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || []; // 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성) const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles; @@ -540,36 +451,27 @@ const FileUploadComponent: React.FC = ({ }, [loadComponentFiles, component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate]); // 전역 상태 변경 감지 (모든 파일 컴포넌트 동기화 + 화면 복원) + // 🆕 columnName을 포함한 고유 키로 구분하여 다른 파일 업로드 컴포넌트에 영향 방지 + const currentUniqueKey = getUniqueKey(); + useEffect(() => { const handleGlobalFileStateChange = (event: CustomEvent) => { - const { componentId, files, fileCount, timestamp, isRestore } = event.detail; + const { componentId, files, fileCount, timestamp, isRestore, uniqueKey: eventUniqueKey, eventColumnName } = event.detail; - console.log("🔄 FileUploadComponent 전역 상태 변경 감지:", { - currentComponentId: component.id, - eventComponentId: componentId, - isForThisComponent: componentId === component.id, - newFileCount: fileCount, - currentFileCount: uploadedFiles.length, - timestamp, - isRestore: !!isRestore, - }); + // 🆕 고유 키 또는 (컴포넌트ID + 컬럼명) 조합으로 체크 + const isForThisComponent = + (eventUniqueKey && eventUniqueKey === currentUniqueKey) || + (componentId === component.id && eventColumnName === columnName); - // 같은 컴포넌트 ID인 경우에만 업데이트 - if (componentId === component.id) { - const logMessage = isRestore ? "🔄 화면 복원으로 파일 상태 동기화" : "✅ 파일 상태 동기화 적용"; - console.log(logMessage, { - componentId: component.id, - 이전파일수: uploadedFiles?.length || 0, - 새파일수: files?.length || 0, - files: files?.map((f: any) => ({ objid: f.objid, name: f.realFileName })) || [], - }); + // 🆕 같은 고유 키인 경우에만 업데이트 (componentId + columnName 조합) + if (isForThisComponent) { setUploadedFiles(files); setForceUpdate((prev) => prev + 1); // localStorage 백업도 업데이트 (레코드별 고유 키 사용) try { - const backupKey = getUniqueKey(); + const backupKey = currentUniqueKey; localStorage.setItem(backupKey, JSON.stringify(files)); } catch (e) { console.warn("localStorage 백업 실패:", e); @@ -584,7 +486,7 @@ const FileUploadComponent: React.FC = ({ window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); }; } - }, [component.id, uploadedFiles.length]); + }, [component.id, columnName, currentUniqueKey, uploadedFiles.length]); // 파일 업로드 설정 - componentConfig가 undefined일 수 있으므로 안전하게 처리 const safeComponentConfig = componentConfig || {}; @@ -598,18 +500,8 @@ const FileUploadComponent: React.FC = ({ // 파일 선택 핸들러 const handleFileSelect = useCallback(() => { - console.log("🎯 handleFileSelect 호출됨:", { - hasFileInputRef: !!fileInputRef.current, - fileInputRef: fileInputRef.current, - fileInputType: fileInputRef.current?.type, - fileInputHidden: fileInputRef.current?.className, - }); - if (fileInputRef.current) { - console.log("✅ fileInputRef.current.click() 호출"); fileInputRef.current.click(); - } else { - console.log("❌ fileInputRef.current가 null입니다"); } }, []); @@ -680,34 +572,17 @@ const FileUploadComponent: React.FC = ({ if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) { // 🎯 레코드 모드: 특정 행에 파일 연결 targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`; - console.log("📁 [레코드 모드] 파일 업로드:", { - targetObjid, - tableName: effectiveTableName, - recordId: effectiveRecordId, - columnName: effectiveColumnName, - }); } else if (screenId) { // 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게) targetObjid = `screen_files:${screenId}:${component.id}:${effectiveColumnName}`; - console.log("📝 [템플릿 모드] 파일 업로드:", targetObjid); } else { // 기본값 (화면관리에서 사용) targetObjid = `temp_${component.id}`; - console.log("📝 [기본 모드] 파일 업로드:", targetObjid); } // 🔒 현재 사용자의 회사 코드 가져오기 (멀티테넌시 격리) const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode; - console.log("📤 [FileUploadComponent] 파일 업로드 준비:", { - userCompanyCode, - isRecordMode: effectiveIsRecordMode, - tableName: effectiveTableName, - recordId: effectiveRecordId, - columnName: effectiveColumnName, - targetObjid, - }); - // 🔑 레코드 모드일 때는 effectiveTableName을 우선 사용 // formData.linkedTable이 'screen_files' 같은 기본값일 수 있으므로 레코드 모드에서는 무시 const finalLinkedTable = effectiveIsRecordMode @@ -732,27 +607,11 @@ const FileUploadComponent: React.FC = ({ isRecordMode: effectiveIsRecordMode, }; - console.log("📤 [FileUploadComponent] uploadData 최종:", { - isRecordMode: effectiveIsRecordMode, - linkedTable: finalLinkedTable, - recordId: effectiveRecordId, - columnName: effectiveColumnName, - targetObjid, - }); - - - console.log("🚀 [FileUploadComponent] uploadFiles API 호출 직전:", { - filesCount: filesToUpload.length, - uploadData, - }); - const response = await uploadFiles({ files: filesToUpload, ...uploadData, }); - console.log("📥 [FileUploadComponent] uploadFiles API 응답:", response); - if (response.success) { // FileUploadResponse 타입에 맞게 files 배열 사용 const fileData = response.files || (response as any).data || []; @@ -811,9 +670,11 @@ const FileUploadComponent: React.FC = ({ }); // 모든 파일 컴포넌트에 동기화 이벤트 발생 + // 🆕 columnName 추가하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분 const syncEvent = new CustomEvent("globalFileStateChanged", { detail: { componentId: component.id, + eventColumnName: columnName, // 🆕 컬럼명 추가 uniqueKey: uniqueKey, // 🆕 고유 키 추가 recordId: recordId, // 🆕 레코드 ID 추가 files: updatedFiles, @@ -822,25 +683,11 @@ const FileUploadComponent: React.FC = ({ }, }); window.dispatchEvent(syncEvent); - - console.log("🌐 전역 파일 상태 업데이트 및 동기화 이벤트 발생:", { - componentId: component.id, - fileCount: updatedFiles.length, - globalState: Object.keys(globalFileState).map((id) => ({ - id, - fileCount: globalFileState[id]?.length || 0, - })), - }); } // 컴포넌트 업데이트 if (onUpdate) { const timestamp = Date.now(); - console.log("🔄 onUpdate 호출:", { - componentId: component.id, - uploadedFiles: updatedFiles.length, - timestamp: timestamp, - }); onUpdate({ uploadedFiles: updatedFiles, lastFileUpdate: timestamp, @@ -858,15 +705,6 @@ const FileUploadComponent: React.FC = ({ ? fileObjids.join(',') // 복수 파일: 콤마 구분 : (fileObjids[0] || ''); // 단일 파일: 첫 번째 파일 ID - console.log("📎 [파일 업로드] 컬럼 데이터 동기화:", { - tableName: effectiveTableName, - recordId: effectiveRecordId, - columnName: effectiveColumnName, - columnValue, - fileCount: updatedFiles.length, - isMultiple: fileConfig.multiple, - }); - // onFormDataChange를 (fieldName, value) 형태로 호출 (SaveModal 호환) onFormDataChange(effectiveColumnName, columnValue); } @@ -883,13 +721,6 @@ const FileUploadComponent: React.FC = ({ }, }); window.dispatchEvent(refreshEvent); - console.log("🔄 그리드 파일 상태 새로고침 이벤트 발생:", { - tableName: effectiveTableName, - recordId: effectiveRecordId, - columnName: effectiveColumnName, - targetObjid, - fileCount: updatedFiles.length, - }); } // 컴포넌트 설정 콜백 @@ -972,9 +803,11 @@ const FileUploadComponent: React.FC = ({ (window as any).globalFileState = globalFileState; // 모든 파일 컴포넌트에 동기화 이벤트 발생 + // 🆕 columnName 추가하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분 const syncEvent = new CustomEvent("globalFileStateChanged", { detail: { componentId: component.id, + eventColumnName: columnName, // 🆕 컬럼명 추가 uniqueKey: uniqueKey, // 🆕 고유 키 추가 recordId: recordId, // 🆕 레코드 ID 추가 files: updatedFiles, @@ -985,12 +818,6 @@ const FileUploadComponent: React.FC = ({ }, }); window.dispatchEvent(syncEvent); - - console.log("🗑️ 파일 삭제 후 전역 상태 동기화:", { - componentId: component.id, - deletedFile: fileName, - remainingFiles: updatedFiles.length, - }); } // 컴포넌트 업데이트 @@ -1010,14 +837,6 @@ const FileUploadComponent: React.FC = ({ ? fileObjids.join(',') : (fileObjids[0] || ''); - console.log("📎 [파일 삭제] 컬럼 데이터 동기화:", { - tableName: recordTableName, - recordId: recordId, - columnName: columnName, - columnValue, - remainingFiles: updatedFiles.length, - }); - // onFormDataChange를 (fieldName, value) 형태로 호출 (SaveModal 호환) onFormDataChange(columnName, columnValue); } @@ -1053,16 +872,10 @@ const FileUploadComponent: React.FC = ({ // 🔑 이미 previewUrl이 설정된 경우 바로 사용 (API 호출 스킵) if (file.previewUrl) { - console.log("🖼️ 대표 이미지: previewUrl 사용:", file.previewUrl); setRepresentativeImageUrl(file.previewUrl); return; } - console.log("🖼️ 대표 이미지 로드 시작:", { - objid: file.objid, - fileName: file.realFileName, - }); - // API 클라이언트를 통해 Blob으로 다운로드 (인증 토큰 포함) // 🔑 download 대신 preview 사용 (공개 접근) const response = await apiClient.get(`/files/preview/${file.objid}`, { @@ -1082,7 +895,6 @@ const FileUploadComponent: React.FC = ({ } setRepresentativeImageUrl(url); - console.log("✅ 대표 이미지 로드 성공:", url); } catch (error: any) { console.error("❌ 대표 이미지 로드 실패:", { file: file.realFileName, @@ -1113,12 +925,6 @@ const FileUploadComponent: React.FC = ({ // 대표 이미지 로드 loadRepresentativeImage(file); - - console.log("✅ 대표 파일 설정 완료:", { - componentId: component.id, - representativeFile: file.realFileName, - objid: file.objid, - }); } catch (e) { console.error("❌ 대표 파일 설정 실패:", e); } @@ -1146,22 +952,13 @@ const FileUploadComponent: React.FC = ({ // 드래그 앤 드롭 핸들러 const handleDragOver = useCallback( (e: React.DragEvent) => { - console.log("🎯 드래그 오버 이벤트 감지:", { - readonly: safeComponentConfig.readonly, - disabled: safeComponentConfig.disabled, - dragOver: dragOver, - }); - e.preventDefault(); e.stopPropagation(); if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) { setDragOver(true); - console.log("✅ 드래그 오버 활성화"); - } else { - console.log("❌ 드래그 차단됨: readonly 또는 disabled"); } }, - [safeComponentConfig.readonly, safeComponentConfig.disabled, dragOver], + [safeComponentConfig.readonly, safeComponentConfig.disabled], ); const handleDragLeave = useCallback((e: React.DragEvent) => { @@ -1189,19 +986,10 @@ const FileUploadComponent: React.FC = ({ // 클릭 핸들러 const handleClick = useCallback( (e: React.MouseEvent) => { - console.log("🖱️ 파일 업로드 영역 클릭:", { - readonly: safeComponentConfig.readonly, - disabled: safeComponentConfig.disabled, - hasHandleFileSelect: !!handleFileSelect, - }); - e.preventDefault(); e.stopPropagation(); if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) { - console.log("✅ 파일 선택 함수 호출"); handleFileSelect(); - } else { - console.log("❌ 클릭 차단됨: readonly 또는 disabled"); } onClick?.(); }, diff --git a/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx b/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx index 4f5da129..c9b738a1 100644 --- a/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx +++ b/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx @@ -23,9 +23,15 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer { // formData에서 현재 값 가져오기 (기본값 지원) const defaultValue = config.defaultValue || ""; let currentValue = formData?.[columnName] ?? component.value ?? ""; - + // 🆕 formData에 값이 없고 기본값이 설정된 경우, 기본값 적용 - if ((currentValue === "" || currentValue === undefined || currentValue === null) && defaultValue && isInteractive && onFormDataChange && columnName) { + if ( + (currentValue === "" || currentValue === undefined || currentValue === null) && + defaultValue && + isInteractive && + onFormDataChange && + columnName + ) { // 초기 렌더링 시 기본값을 formData에 설정 setTimeout(() => { if (!formData?.[columnName]) { diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 7f2f1fa3..b820d370 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -1033,7 +1033,6 @@ export const TableListComponent: React.FC = ({ // localStorage에 저장된 정렬이 없으면 defaultSort 설정 적용 if (tableConfig.defaultSort?.columnName) { - console.log("📊 기본 정렬 설정 적용:", tableConfig.defaultSort); setSortColumn(tableConfig.defaultSort.columnName); setSortDirection(tableConfig.defaultSort.direction || "asc"); hasInitializedSort.current = true; @@ -1139,16 +1138,6 @@ export const TableListComponent: React.FC = ({ }); } - // 🔍 디버깅: 캐시 사용 시 로그 - console.log("📊 [TableListComponent] 캐시에서 inputTypes 로드:", { - tableName: tableConfig.selectedTable, - cacheKey: cacheKey, - hasInputTypes: !!cached.inputTypes, - inputTypesLength: cached.inputTypes?.length || 0, - imageInputType: inputTypeMap["image"], - cacheAge: Date.now() - cached.timestamp, - }); - cached.columns.forEach((col: any) => { labels[col.columnName] = col.displayName || col.comment || col.columnName; meta[col.columnName] = { @@ -1172,14 +1161,6 @@ export const TableListComponent: React.FC = ({ inputTypeMap[col.columnName] = col.inputType; }); - // 🔍 디버깅: inputTypes 확인 - console.log("📊 [TableListComponent] inputTypes 조회 결과:", { - tableName: tableConfig.selectedTable, - inputTypes: inputTypes, - inputTypeMap: inputTypeMap, - imageColumn: inputTypes.find((col: any) => col.columnName === "image"), - }); - tableColumnCache.set(cacheKey, { columns, inputTypes, @@ -4079,17 +4060,6 @@ export const TableListComponent: React.FC = ({ // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) const inputType = meta?.inputType || column.inputType; - // 🔍 디버깅: image 컬럼인 경우 로그 출력 - if (column.columnName === "image") { - console.log("🖼️ [formatCellValue] image 컬럼 처리:", { - columnName: column.columnName, - value: value, - meta: meta, - inputType: inputType, - columnInputType: column.inputType, - }); - } - // 🖼️ 이미지 타입: 작은 썸네일 표시 if (inputType === "image" && value) { // value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용 diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerConfigPanel.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerConfigPanel.tsx index d297f860..a715e408 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerConfigPanel.tsx @@ -5,32 +5,10 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { tableTypeApi } from "@/lib/api/screen"; @@ -52,10 +30,7 @@ interface ColumnInfo { displayName: string; } -export function TimelineSchedulerConfigPanel({ - config, - onChange, -}: TimelineSchedulerConfigPanelProps) { +export function TimelineSchedulerConfigPanel({ config, onChange }: TimelineSchedulerConfigPanelProps) { const [tables, setTables] = useState([]); const [sourceColumns, setSourceColumns] = useState([]); const [resourceColumns, setResourceColumns] = useState([]); @@ -74,7 +49,7 @@ export function TimelineSchedulerConfigPanel({ tableList.map((t: any) => ({ tableName: t.table_name || t.tableName, displayName: t.display_name || t.displayName || t.table_name || t.tableName, - })) + })), ); } } catch (err) { @@ -100,7 +75,7 @@ export function TimelineSchedulerConfigPanel({ columns.map((col: any) => ({ columnName: col.column_name || col.columnName, displayName: col.display_name || col.displayName || col.column_name || col.columnName, - })) + })), ); } } catch (err) { @@ -125,7 +100,7 @@ export function TimelineSchedulerConfigPanel({ columns.map((col: any) => ({ columnName: col.column_name || col.columnName, displayName: col.display_name || col.displayName || col.column_name || col.columnName, - })) + })), ); } } catch (err) { @@ -168,11 +143,9 @@ export function TimelineSchedulerConfigPanel({ {/* 소스 데이터 설정 (스케줄 생성 기준) */} - - 스케줄 생성 설정 - + 스케줄 생성 설정 -

+

스케줄 자동 생성 시 참조할 원본 데이터 설정 (저장: schedule_mng)

@@ -208,20 +181,14 @@ export function TimelineSchedulerConfigPanel({ className="h-8 w-full justify-between text-xs" disabled={loading} > - {config.sourceConfig?.tableName ? ( - tables.find((t) => t.tableName === config.sourceConfig?.tableName) - ?.displayName || config.sourceConfig.tableName - ) : ( - "소스 테이블 선택..." - )} + {config.sourceConfig?.tableName + ? tables.find((t) => t.tableName === config.sourceConfig?.tableName)?.displayName || + config.sourceConfig.tableName + : "소스 테이블 선택..."} - + { const lowerSearch = search.toLowerCase(); @@ -233,9 +200,7 @@ export function TimelineSchedulerConfigPanel({ > - - 테이블을 찾을 수 없습니다. - + 테이블을 찾을 수 없습니다. {tables.map((table) => (
{table.displayName} - - {table.tableName} - + {table.tableName}
))} @@ -272,11 +233,11 @@ export function TimelineSchedulerConfigPanel({ {/* 소스 필드 매핑 */} {config.sourceConfig?.tableName && ( -
+
{/* 기준일 필드 */} -
+
-

- 스케줄 종료일로 사용됩니다 -

+

스케줄 종료일로 사용됩니다

{/* 수량 필드 */} @@ -339,7 +298,7 @@ export function TimelineSchedulerConfigPanel({
{/* 그룹명 필드 */} -
+
- updateConfig({ defaultZoomLevel: v as any }) - } + onValueChange={(v) => updateConfig({ defaultZoomLevel: v as any })} > @@ -534,9 +469,7 @@ export function TimelineSchedulerConfigPanel({ - updateConfig({ height: parseInt(e.target.value) || 500 }) - } + onChange={(e) => updateConfig({ height: parseInt(e.target.value) || 500 })} className="h-8 text-xs" />
@@ -547,9 +480,7 @@ export function TimelineSchedulerConfigPanel({ - updateConfig({ rowHeight: parseInt(e.target.value) || 50 }) - } + onChange={(e) => updateConfig({ rowHeight: parseInt(e.target.value) || 50 })} className="h-8 text-xs" />
@@ -558,26 +489,17 @@ export function TimelineSchedulerConfigPanel({
- updateConfig({ editable: v })} - /> + updateConfig({ editable: v })} />
- updateConfig({ draggable: v })} - /> + updateConfig({ draggable: v })} />
- updateConfig({ resizable: v })} - /> + updateConfig({ resizable: v })} />
diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts b/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts index 7ce7a9d6..94c001d4 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts @@ -3,13 +3,7 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { apiClient } from "@/lib/api/client"; import { v2EventBus, V2_EVENTS } from "@/lib/v2-core"; -import { - TimelineSchedulerConfig, - ScheduleItem, - Resource, - ZoomLevel, - UseTimelineDataResult, -} from "../types"; +import { TimelineSchedulerConfig, ScheduleItem, Resource, ZoomLevel, UseTimelineDataResult } from "../types"; import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config"; // schedule_mng 테이블 고정 (공통 스케줄 테이블) @@ -37,16 +31,14 @@ const addDays = (date: Date, days: number): Date => { export function useTimelineData( config: TimelineSchedulerConfig, externalSchedules?: ScheduleItem[], - externalResources?: Resource[] + externalResources?: Resource[], ): UseTimelineDataResult { // 상태 const [schedules, setSchedules] = useState([]); const [resources, setResources] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [zoomLevel, setZoomLevel] = useState( - config.defaultZoomLevel || "day" - ); + const [zoomLevel, setZoomLevel] = useState(config.defaultZoomLevel || "day"); const [viewStartDate, setViewStartDate] = useState(() => { if (config.initialDate) { return new Date(config.initialDate); @@ -69,9 +61,7 @@ export function useTimelineData( }, [viewStartDate, zoomLevel]); // 테이블명: 기본적으로 schedule_mng 사용, 커스텀 테이블 설정 시 해당 테이블 사용 - const tableName = config.useCustomTable && config.customTableName - ? config.customTableName - : SCHEDULE_TABLE; + const tableName = config.useCustomTable && config.customTableName ? config.customTableName : SCHEDULE_TABLE; const resourceTableName = config.resourceTable; @@ -88,7 +78,7 @@ export function useTimelineData( const fieldMapping = useMemo(() => { const mapping = config.fieldMapping; if (!mapping) return defaultTimelineSchedulerConfig.fieldMapping!; - + return { id: mapping.id || mapping.idField || "id", resourceId: mapping.resourceId || mapping.resourceIdField || "resource_id", @@ -134,17 +124,13 @@ export function useTimelineData( sourceKeys: currentSourceKeys, }); - const response = await apiClient.post( - `/table-management/tables/${tableName}/data`, - { - page: 1, - size: 10000, - autoFilter: true, - } - ); + const response = await apiClient.post(`/table-management/tables/${tableName}/data`, { + page: 1, + size: 10000, + autoFilter: true, + }); - const responseData = - response.data?.data?.data || response.data?.data || []; + const responseData = response.data?.data?.data || response.data?.data || []; let rawData = Array.isArray(responseData) ? responseData : []; // 클라이언트 측 필터링 적용 (schedule_mng 테이블인 경우) @@ -156,9 +142,7 @@ export function useTimelineData( // 선택된 품목 필터 (source_group_key 기준) if (currentSourceKeys.length > 0) { - rawData = rawData.filter((row: any) => - currentSourceKeys.includes(row.source_group_key) - ); + rawData = rawData.filter((row: any) => currentSourceKeys.includes(row.source_group_key)); } console.log("[useTimelineData] 필터링 후 스케줄:", rawData.length, "건"); @@ -194,9 +178,7 @@ export function useTimelineData( title: String(row[effectiveMapping.title] || ""), startDate: row[effectiveMapping.startDate] || "", endDate: row[effectiveMapping.endDate] || "", - status: effectiveMapping.status - ? row[effectiveMapping.status] || "planned" - : "planned", + status: effectiveMapping.status ? row[effectiveMapping.status] || "planned" : "planned", progress, color: fieldMapping.color ? row[fieldMapping.color] : undefined, data: row, @@ -228,26 +210,20 @@ export function useTimelineData( } try { - const response = await apiClient.post( - `/table-management/tables/${resourceTableName}/data`, - { - page: 1, - size: 1000, - autoFilter: true, - } - ); + const response = await apiClient.post(`/table-management/tables/${resourceTableName}/data`, { + page: 1, + size: 1000, + autoFilter: true, + }); - const responseData = - response.data?.data?.data || response.data?.data || []; + const responseData = response.data?.data?.data || response.data?.data || []; const rawData = Array.isArray(responseData) ? responseData : []; // 데이터를 Resource 형태로 변환 const mappedResources: Resource[] = rawData.map((row: any) => ({ id: String(row[resourceFieldMapping.id] || ""), name: String(row[resourceFieldMapping.name] || ""), - group: resourceFieldMapping.group - ? row[resourceFieldMapping.group] - : undefined, + group: resourceFieldMapping.group ? row[resourceFieldMapping.group] : undefined, })); setResources(mappedResources); @@ -270,44 +246,41 @@ export function useTimelineData( // 이벤트 버스 리스너 - 테이블 선택 변경 (품목 선택 시 해당 스케줄만 표시) useEffect(() => { - const unsubscribeSelection = v2EventBus.subscribe( - V2_EVENTS.TABLE_SELECTION_CHANGE, - (payload) => { - console.log("[useTimelineData] TABLE_SELECTION_CHANGE 수신:", { - tableName: payload.tableName, - selectedCount: payload.selectedCount, - }); + const unsubscribeSelection = v2EventBus.subscribe(V2_EVENTS.TABLE_SELECTION_CHANGE, (payload) => { + console.log("[useTimelineData] TABLE_SELECTION_CHANGE 수신:", { + tableName: payload.tableName, + selectedCount: payload.selectedCount, + }); - // 설정된 그룹 필드명 사용 (없으면 기본값들 fallback) - const groupByField = config.sourceConfig?.groupByField; + // 설정된 그룹 필드명 사용 (없으면 기본값들 fallback) + const groupByField = config.sourceConfig?.groupByField; - // 선택된 데이터에서 source_group_key 추출 - const sourceKeys: string[] = []; - for (const row of payload.selectedRows || []) { - // 설정된 필드명 우선, 없으면 일반적인 필드명 fallback - let key: string | undefined; - if (groupByField && row[groupByField]) { - key = row[groupByField]; - } else { - // fallback: 일반적으로 사용되는 필드명들 - key = row.part_code || row.source_group_key || row.item_code; - } - - if (key && !sourceKeys.includes(key)) { - sourceKeys.push(key); - } + // 선택된 데이터에서 source_group_key 추출 + const sourceKeys: string[] = []; + for (const row of payload.selectedRows || []) { + // 설정된 필드명 우선, 없으면 일반적인 필드명 fallback + let key: string | undefined; + if (groupByField && row[groupByField]) { + key = row[groupByField]; + } else { + // fallback: 일반적으로 사용되는 필드명들 + key = row.part_code || row.source_group_key || row.item_code; } - console.log("[useTimelineData] 선택된 그룹 키:", { - groupByField, - keys: sourceKeys, - }); - - // 상태 업데이트 및 ref 동기화 - selectedSourceKeysRef.current = sourceKeys; - setSelectedSourceKeys(sourceKeys); + if (key && !sourceKeys.includes(key)) { + sourceKeys.push(key); + } } - ); + + console.log("[useTimelineData] 선택된 그룹 키:", { + groupByField, + keys: sourceKeys, + }); + + // 상태 업데이트 및 ref 동기화 + selectedSourceKeysRef.current = sourceKeys; + setSelectedSourceKeys(sourceKeys); + }); return () => { unsubscribeSelection(); @@ -325,27 +298,21 @@ export function useTimelineData( // 이벤트 버스 리스너 - 스케줄 생성 완료 및 테이블 새로고침 useEffect(() => { // TABLE_REFRESH 이벤트 수신 - 스케줄 새로고침 - const unsubscribeRefresh = v2EventBus.subscribe( - V2_EVENTS.TABLE_REFRESH, - (payload) => { - // schedule_mng 또는 해당 테이블에 대한 새로고침 - if (payload.tableName === tableName || payload.tableName === SCHEDULE_TABLE) { - console.log("[useTimelineData] TABLE_REFRESH 수신, 스케줄 새로고침:", payload); - fetchSchedules(); - } + const unsubscribeRefresh = v2EventBus.subscribe(V2_EVENTS.TABLE_REFRESH, (payload) => { + // schedule_mng 또는 해당 테이블에 대한 새로고침 + if (payload.tableName === tableName || payload.tableName === SCHEDULE_TABLE) { + console.log("[useTimelineData] TABLE_REFRESH 수신, 스케줄 새로고침:", payload); + fetchSchedules(); } - ); + }); // SCHEDULE_GENERATE_COMPLETE 이벤트 수신 - 스케줄 자동 생성 완료 시 새로고침 - const unsubscribeComplete = v2EventBus.subscribe( - V2_EVENTS.SCHEDULE_GENERATE_COMPLETE, - (payload) => { - if (payload.success) { - console.log("[useTimelineData] SCHEDULE_GENERATE_COMPLETE 수신, 스케줄 새로고침:", payload); - fetchSchedules(); - } + const unsubscribeComplete = v2EventBus.subscribe(V2_EVENTS.SCHEDULE_GENERATE_COMPLETE, (payload) => { + if (payload.success) { + console.log("[useTimelineData] SCHEDULE_GENERATE_COMPLETE 수신, 스케줄 새로고침:", payload); + fetchSchedules(); } - ); + }); return () => { unsubscribeRefresh(); @@ -390,23 +357,20 @@ export function useTimelineData( if (updates.endDate) updateData[fieldMapping.endDate] = updates.endDate; if (updates.resourceId) updateData[fieldMapping.resourceId] = updates.resourceId; if (updates.title) updateData[fieldMapping.title] = updates.title; - if (updates.status && fieldMapping.status) - updateData[fieldMapping.status] = updates.status; + if (updates.status && fieldMapping.status) updateData[fieldMapping.status] = updates.status; if (updates.progress !== undefined && fieldMapping.progress) updateData[fieldMapping.progress] = updates.progress; await apiClient.put(`/table-management/tables/${tableName}/data/${id}`, updateData); // 로컬 상태 업데이트 - setSchedules((prev) => - prev.map((s) => (s.id === id ? { ...s, ...updates } : s)) - ); + setSchedules((prev) => prev.map((s) => (s.id === id ? { ...s, ...updates } : s))); } catch (err: any) { console.error("스케줄 업데이트 오류:", err); throw err; } }, - [tableName, fieldMapping, config.editable] + [tableName, fieldMapping, config.editable], ); // 스케줄 추가 @@ -427,10 +391,7 @@ export function useTimelineData( if (fieldMapping.progress && schedule.progress !== undefined) insertData[fieldMapping.progress] = schedule.progress; - const response = await apiClient.post( - `/table-management/tables/${tableName}/data`, - insertData - ); + const response = await apiClient.post(`/table-management/tables/${tableName}/data`, insertData); const newId = response.data?.data?.id || Date.now().toString(); @@ -441,7 +402,7 @@ export function useTimelineData( throw err; } }, - [tableName, fieldMapping, config.editable] + [tableName, fieldMapping, config.editable], ); // 스케줄 삭제 @@ -459,7 +420,7 @@ export function useTimelineData( throw err; } }, - [tableName, config.editable] + [tableName, config.editable], ); // 새로고침 diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/types.ts b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts index b7a836a6..baf59741 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/types.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts @@ -10,12 +10,7 @@ export type ZoomLevel = "day" | "week" | "month"; /** * 스케줄 상태 */ -export type ScheduleStatus = - | "planned" - | "in_progress" - | "completed" - | "delayed" - | "cancelled"; +export type ScheduleStatus = "planned" | "in_progress" | "completed" | "delayed" | "cancelled"; /** * 스케줄 항목 (간트 바) @@ -107,10 +102,10 @@ export interface ResourceFieldMapping { * 스케줄 타입 (schedule_mng.schedule_type) */ export type ScheduleType = - | "PRODUCTION" // 생산계획 - | "MAINTENANCE" // 정비계획 - | "SHIPPING" // 배차계획 - | "WORK_ASSIGN"; // 작업배정 + | "PRODUCTION" // 생산계획 + | "MAINTENANCE" // 정비계획 + | "SHIPPING" // 배차계획 + | "WORK_ASSIGN"; // 작업배정 /** * 소스 데이터 설정 (스케줄 생성 기준이 되는 원본 데이터) diff --git a/frontend/lib/utils/layoutV2Converter.ts b/frontend/lib/utils/layoutV2Converter.ts index b8485487..fff56bf9 100644 --- a/frontend/lib/utils/layoutV2Converter.ts +++ b/frontend/lib/utils/layoutV2Converter.ts @@ -38,19 +38,19 @@ interface LegacyLayoutData { // ============================================ function applyDefaultsToNestedComponents(components: any[]): any[] { if (!Array.isArray(components)) return components; - + return components.map((nestedComp: any) => { if (!nestedComp) return nestedComp; - + // 중첩 컴포넌트의 타입 확인 (componentType 또는 url에서 추출) let nestedComponentType = nestedComp.componentType; if (!nestedComponentType && nestedComp.url) { nestedComponentType = getComponentTypeFromUrl(nestedComp.url); } - + // 결과 객체 초기화 (원본 복사) - let result = { ...nestedComp }; - + const result = { ...nestedComp }; + // 🆕 탭 위젯인 경우 재귀적으로 탭 내부 컴포넌트도 처리 if (nestedComponentType === "v2-tabs-widget") { const config = result.componentConfig || {}; @@ -69,31 +69,35 @@ function applyDefaultsToNestedComponents(components: any[]): any[] { }; } } - + // 🆕 분할 패널인 경우 재귀적으로 내부 컴포넌트도 처리 if (nestedComponentType === "v2-split-panel-layout") { const config = result.componentConfig || {}; result.componentConfig = { ...config, - leftPanel: config.leftPanel ? { - ...config.leftPanel, - components: applyDefaultsToNestedComponents(config.leftPanel.components || []), - } : config.leftPanel, - rightPanel: config.rightPanel ? { - ...config.rightPanel, - components: applyDefaultsToNestedComponents(config.rightPanel.components || []), - } : config.rightPanel, + leftPanel: config.leftPanel + ? { + ...config.leftPanel, + components: applyDefaultsToNestedComponents(config.leftPanel.components || []), + } + : config.leftPanel, + rightPanel: config.rightPanel + ? { + ...config.rightPanel, + components: applyDefaultsToNestedComponents(config.rightPanel.components || []), + } + : config.rightPanel, }; } - + // 컴포넌트 타입이 없으면 그대로 반환 if (!nestedComponentType) { return result; } - + // 중첩 컴포넌트의 기본값 가져오기 const nestedDefaults = getDefaultsByUrl(`registry://${nestedComponentType}`); - + // componentConfig가 있으면 기본값과 병합 if (result.componentConfig && Object.keys(nestedDefaults).length > 0) { const mergedNestedConfig = mergeComponentConfig(nestedDefaults, result.componentConfig); @@ -102,7 +106,7 @@ function applyDefaultsToNestedComponents(components: any[]): any[] { componentConfig: mergedNestedConfig, }; } - + return result; }); } @@ -112,7 +116,7 @@ function applyDefaultsToNestedComponents(components: any[]): any[] { // ============================================ function applyDefaultsToSplitPanelComponents(mergedConfig: Record): Record { const result = { ...mergedConfig }; - + // leftPanel.components 처리 if (result.leftPanel?.components) { result.leftPanel = { @@ -120,7 +124,7 @@ function applyDefaultsToSplitPanelComponents(mergedConfig: Record): components: applyDefaultsToNestedComponents(result.leftPanel.components), }; } - + // rightPanel.components 처리 if (result.rightPanel?.components) { result.rightPanel = { @@ -128,7 +132,7 @@ function applyDefaultsToSplitPanelComponents(mergedConfig: Record): components: applyDefaultsToNestedComponents(result.rightPanel.components), }; } - + return result; } @@ -149,7 +153,7 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData | if (componentType === "v2-split-panel-layout") { mergedConfig = applyDefaultsToSplitPanelComponents(mergedConfig); } - + // 🆕 탭 위젯인 경우 탭 내부 컴포넌트에도 기본값 적용 if (componentType === "v2-tabs-widget" && mergedConfig.tabs) { mergedConfig = { @@ -273,15 +277,15 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 { ...(configOverrides.style || {}), ...(topLevelProps.style || {}), }; - + // 🔧 webTypeConfig도 병합 (topLevelProps가 우선, dataflowConfig 등 보존) const mergedWebTypeConfig = { ...(configOverrides.webTypeConfig || {}), ...(topLevelProps.webTypeConfig || {}), }; - - const overrides = { - ...topLevelProps, + + const overrides = { + ...topLevelProps, ...configOverrides, // 🆕 병합된 style 사용 (comp.style 값이 최종 우선) ...(Object.keys(mergedStyle).length > 0 ? { style: mergedStyle } : {}), diff --git a/frontend/lib/v2-core/services/ScheduleGeneratorService.ts b/frontend/lib/v2-core/services/ScheduleGeneratorService.ts index d73dd3a3..5d693005 100644 --- a/frontend/lib/v2-core/services/ScheduleGeneratorService.ts +++ b/frontend/lib/v2-core/services/ScheduleGeneratorService.ts @@ -10,11 +10,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { v2EventBus } from "../events/EventBus"; import { V2_EVENTS } from "../events/types"; -import type { - ScheduleType, - V2ScheduleGenerateRequestEvent, - V2ScheduleGenerateApplyEvent, -} from "../events/types"; +import type { ScheduleType, V2ScheduleGenerateRequestEvent, V2ScheduleGenerateApplyEvent } from "../events/types"; import { apiClient } from "@/lib/api/client"; import { toast } from "sonner"; @@ -122,13 +118,10 @@ function getDefaultPeriod(): { start: string; end: string } { * const { showConfirmDialog, previewResult, handleConfirm } = useScheduleGenerator(config); * ``` */ -export function useScheduleGenerator( - scheduleConfig?: ScheduleGenerationConfig | null -): UseScheduleGeneratorReturn { +export function useScheduleGenerator(scheduleConfig?: ScheduleGenerationConfig | null): UseScheduleGeneratorReturn { // 상태 const [selectedData, setSelectedData] = useState([]); - const [previewResult, setPreviewResult] = - useState(null); + const [previewResult, setPreviewResult] = useState(null); const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [isLoading, setIsLoading] = useState(false); const currentRequestIdRef = useRef(""); @@ -136,57 +129,53 @@ export function useScheduleGenerator( // 1. 테이블 선택 데이터 추적 (TABLE_SELECTION_CHANGE 이벤트 수신) useEffect(() => { - const unsubscribe = v2EventBus.subscribe( - V2_EVENTS.TABLE_SELECTION_CHANGE, - (payload) => { - // scheduleConfig가 있으면 해당 테이블만, 없으면 모든 테이블의 선택 데이터 저장 - if (scheduleConfig?.source?.tableName) { - if (payload.tableName === scheduleConfig.source.tableName) { - setSelectedData(payload.selectedRows); - console.log("[useScheduleGenerator] 선택 데이터 업데이트 (특정 테이블):", payload.selectedCount, "건"); - } - } else { - // scheduleConfig가 없으면 모든 테이블의 선택 데이터를 저장 + const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_SELECTION_CHANGE, (payload) => { + // scheduleConfig가 있으면 해당 테이블만, 없으면 모든 테이블의 선택 데이터 저장 + if (scheduleConfig?.source?.tableName) { + if (payload.tableName === scheduleConfig.source.tableName) { setSelectedData(payload.selectedRows); - console.log("[useScheduleGenerator] 선택 데이터 업데이트 (모든 테이블):", payload.selectedCount, "건"); + console.log("[useScheduleGenerator] 선택 데이터 업데이트 (특정 테이블):", payload.selectedCount, "건"); } + } else { + // scheduleConfig가 없으면 모든 테이블의 선택 데이터를 저장 + setSelectedData(payload.selectedRows); + console.log("[useScheduleGenerator] 선택 데이터 업데이트 (모든 테이블):", payload.selectedCount, "건"); } - ); + }); return unsubscribe; }, [scheduleConfig?.source?.tableName]); // 2. 스케줄 생성 요청 처리 (SCHEDULE_GENERATE_REQUEST 수신) useEffect(() => { - console.log("[useScheduleGenerator] 이벤트 구독 시작"); - const unsubscribe = v2EventBus.subscribe( V2_EVENTS.SCHEDULE_GENERATE_REQUEST, async (payload: V2ScheduleGenerateRequestEvent) => { console.log("[useScheduleGenerator] SCHEDULE_GENERATE_REQUEST 수신:", payload); // 이벤트에서 config가 오면 사용, 없으면 기존 scheduleConfig 또는 기본 config 사용 - const configToUse = (payload as any).config || scheduleConfig || { - // 기본 설정 (생산계획 화면용) - scheduleType: payload.scheduleType || "PRODUCTION", - source: { - tableName: "sales_order_mng", - groupByField: "part_code", - quantityField: "balance_qty", - dueDateField: "delivery_date", // 기준일 필드 (납기일) - }, - resource: { - type: "ITEM", - idField: "part_code", - nameField: "part_name", - }, - rules: { - leadTimeDays: 3, - dailyCapacity: 100, - }, - target: { - tableName: "schedule_mng", - }, - }; + const configToUse = (payload as any).config || + scheduleConfig || { + // 기본 설정 (생산계획 화면용) + scheduleType: payload.scheduleType || "PRODUCTION", + source: { + tableName: "sales_order_mng", + groupByField: "part_code", + quantityField: "balance_qty", + dueDateField: "delivery_date", // 기준일 필드 (납기일) + }, + resource: { + type: "ITEM", + idField: "part_code", + nameField: "part_name", + }, + rules: { + leadTimeDays: 3, + dailyCapacity: 100, + }, + target: { + tableName: "schedule_mng", + }, + }; console.log("[useScheduleGenerator] 사용할 config:", configToUse); @@ -250,7 +239,7 @@ export function useScheduleGenerator( } finally { setIsLoading(false); } - } + }, ); return unsubscribe; }, [selectedData, scheduleConfig]); @@ -299,10 +288,9 @@ export function useScheduleGenerator( tableName: configToUse?.target?.tableName || "schedule_mng", }); - toast.success( - `${response.data.applied?.created || 0}건의 스케줄이 생성되었습니다.`, - { id: "schedule-apply" } - ); + toast.success(`${response.data.applied?.created || 0}건의 스케줄이 생성되었습니다.`, { + id: "schedule-apply", + }); setShowConfirmDialog(false); setPreviewResult(null); } catch (error: any) { @@ -311,7 +299,7 @@ export function useScheduleGenerator( } finally { setIsLoading(false); } - } + }, ); return unsubscribe; }, [previewResult, scheduleConfig]); -- 2.43.0 From e31bb970a280fa64e67d564df03ef2e3debcc7cb Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 5 Feb 2026 17:38:06 +0900 Subject: [PATCH 24/24] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EA=B0=80=EB=8F=85=EC=84=B1=20?= =?UTF-8?q?=ED=96=A5=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - numberingRuleController.ts에서 API 엔드포인트의 코드 스타일을 일관되게 정리하여 가독성을 높였습니다. - 불필요한 줄바꿈을 제거하고, 코드 블록을 명확하게 정리하여 유지보수성을 개선했습니다. - tableManagementService.ts와 ButtonConfigPanel.tsx에서 코드 정리를 통해 일관성을 유지하고, 가독성을 향상시켰습니다. - 전반적으로 코드의 깔끔함을 유지하고, 향후 개발 시 이해하기 쉽게 개선했습니다. --- .../controllers/numberingRuleController.ts | 755 ++++++++------ .../src/services/tableManagementService.ts | 31 +- .../config-panels/ButtonConfigPanel.tsx | 441 ++++---- .../components/screen/widgets/TabsWidget.tsx | 86 +- .../components/unified/UnifiedRepeater.tsx | 181 ++-- frontend/components/v2/V2Repeater.tsx | 332 +++--- frontend/lib/api/numberingRule.ts | 54 +- .../RepeatScreenModalComponent.tsx | 971 +++++++++--------- .../UniversalFormModalComponent.tsx | 50 +- .../components/v2-input/V2InputRenderer.tsx | 2 +- frontend/lib/utils/buttonActions.ts | 15 +- 11 files changed, 1570 insertions(+), 1348 deletions(-) diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index a8f99b36..d307b41a 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -3,392 +3,545 @@ */ import { Router, Response } from "express"; -import { authenticateToken, AuthenticatedRequest } from "../middleware/authMiddleware"; +import { + authenticateToken, + AuthenticatedRequest, +} from "../middleware/authMiddleware"; import { numberingRuleService } from "../services/numberingRuleService"; import { logger } from "../utils/logger"; const router = Router(); // 규칙 목록 조회 (전체) -router.get("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; +router.get( + "/", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; - try { - const rules = await numberingRuleService.getRuleList(companyCode); - return res.json({ success: true, data: rules }); - } catch (error: any) { - logger.error("규칙 목록 조회 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + const rules = await numberingRuleService.getRuleList(companyCode); + return res.json({ success: true, data: rules }); + } catch (error: any) { + logger.error("규칙 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // 메뉴별 사용 가능한 규칙 조회 -router.get("/available/:menuObjid?", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined; +router.get( + "/available/:menuObjid?", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const menuObjid = req.params.menuObjid + ? parseInt(req.params.menuObjid) + : undefined; - logger.info("메뉴별 채번 규칙 조회 요청", { menuObjid, companyCode }); + logger.info("메뉴별 채번 규칙 조회 요청", { menuObjid, companyCode }); - try { - const rules = await numberingRuleService.getAvailableRulesForMenu(companyCode, menuObjid); - - logger.info("✅ 메뉴별 채번 규칙 조회 성공 (컨트롤러)", { - companyCode, - menuObjid, - rulesCount: rules.length - }); - - return res.json({ success: true, data: rules }); - } catch (error: any) { - logger.error("❌ 메뉴별 사용 가능한 규칙 조회 실패 (컨트롤러)", { - error: error.message, - errorCode: error.code, - errorStack: error.stack, - companyCode, - menuObjid, - }); - return res.status(500).json({ success: false, error: error.message }); + try { + const rules = await numberingRuleService.getAvailableRulesForMenu( + companyCode, + menuObjid + ); + + logger.info("✅ 메뉴별 채번 규칙 조회 성공 (컨트롤러)", { + companyCode, + menuObjid, + rulesCount: rules.length, + }); + + return res.json({ success: true, data: rules }); + } catch (error: any) { + logger.error("❌ 메뉴별 사용 가능한 규칙 조회 실패 (컨트롤러)", { + error: error.message, + errorCode: error.code, + errorStack: error.stack, + companyCode, + menuObjid, + }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // 화면용 채번 규칙 조회 (테이블 기반 필터링 - 간소화) -router.get("/available-for-screen", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { tableName } = req.query; +router.get( + "/available-for-screen", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { tableName } = req.query; - try { - // tableName 필수 검증 - if (!tableName || typeof tableName !== "string") { - return res.status(400).json({ - success: false, - error: "tableName is required", - }); - } - - const rules = await numberingRuleService.getAvailableRulesForScreen( - companyCode, - tableName - ); - - logger.info("화면용 채번 규칙 조회 성공", { - companyCode, - tableName, - count: rules.length, - }); - - return res.json({ success: true, data: rules }); - } catch (error: any) { - logger.error("화면용 채번 규칙 조회 실패", { - error: error.message, - tableName, - }); - return res.status(500).json({ - success: false, - error: error.message, - }); - } -}); - -// 특정 규칙 조회 -router.get("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; - - try { - const rule = await numberingRuleService.getRuleById(ruleId, companyCode); - if (!rule) { - return res.status(404).json({ success: false, error: "규칙을 찾을 수 없습니다" }); - } - return res.json({ success: true, data: rule }); - } catch (error: any) { - logger.error("규칙 조회 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); - } -}); - -// 규칙 생성 -router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const ruleConfig = req.body; - - logger.info("🔍 [POST /numbering-rules] 채번 규칙 생성 요청:", { - companyCode, - userId, - ruleId: ruleConfig.ruleId, - ruleName: ruleConfig.ruleName, - scopeType: ruleConfig.scopeType, - menuObjid: ruleConfig.menuObjid, - tableName: ruleConfig.tableName, - partsCount: ruleConfig.parts?.length, - }); - - try { - if (!ruleConfig.ruleId || !ruleConfig.ruleName) { - return res.status(400).json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" }); - } - - if (!Array.isArray(ruleConfig.parts) || ruleConfig.parts.length === 0) { - return res.status(400).json({ success: false, error: "최소 1개 이상의 규칙 파트가 필요합니다" }); - } - - // 🆕 scopeType이 'table'인 경우 tableName 필수 체크 - if (ruleConfig.scopeType === "table") { - if (!ruleConfig.tableName || ruleConfig.tableName.trim() === "") { + try { + // tableName 필수 검증 + if (!tableName || typeof tableName !== "string") { return res.status(400).json({ success: false, - error: "테이블 범위 규칙은 테이블명(tableName)이 필수입니다", + error: "tableName is required", }); } - } - const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId); - - logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", { - ruleId: newRule.ruleId, - menuObjid: newRule.menuObjid, - }); + const rules = await numberingRuleService.getAvailableRulesForScreen( + companyCode, + tableName + ); - return res.status(201).json({ success: true, data: newRule }); - } catch (error: any) { - if (error.code === "23505") { - return res.status(409).json({ success: false, error: "이미 존재하는 규칙 ID입니다" }); + logger.info("화면용 채번 규칙 조회 성공", { + companyCode, + tableName, + count: rules.length, + }); + + return res.json({ success: true, data: rules }); + } catch (error: any) { + logger.error("화면용 채번 규칙 조회 실패", { + error: error.message, + tableName, + }); + return res.status(500).json({ + success: false, + error: error.message, + }); } - logger.error("❌ [POST /numbering-rules] 규칙 생성 실패:", { - error: error.message, - stack: error.stack, - code: error.code, - }); - return res.status(500).json({ success: false, error: error.message }); } -}); +); + +// 특정 규칙 조회 +router.get( + "/:ruleId", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + + try { + const rule = await numberingRuleService.getRuleById(ruleId, companyCode); + if (!rule) { + return res + .status(404) + .json({ success: false, error: "규칙을 찾을 수 없습니다" }); + } + return res.json({ success: true, data: rule }); + } catch (error: any) { + logger.error("규칙 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } + } +); + +// 규칙 생성 +router.post( + "/", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const ruleConfig = req.body; + + logger.info("🔍 [POST /numbering-rules] 채번 규칙 생성 요청:", { + companyCode, + userId, + ruleId: ruleConfig.ruleId, + ruleName: ruleConfig.ruleName, + scopeType: ruleConfig.scopeType, + menuObjid: ruleConfig.menuObjid, + tableName: ruleConfig.tableName, + partsCount: ruleConfig.parts?.length, + }); + + try { + if (!ruleConfig.ruleId || !ruleConfig.ruleName) { + return res + .status(400) + .json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" }); + } + + if (!Array.isArray(ruleConfig.parts) || ruleConfig.parts.length === 0) { + return res + .status(400) + .json({ + success: false, + error: "최소 1개 이상의 규칙 파트가 필요합니다", + }); + } + + // 🆕 scopeType이 'table'인 경우 tableName 필수 체크 + if (ruleConfig.scopeType === "table") { + if (!ruleConfig.tableName || ruleConfig.tableName.trim() === "") { + return res.status(400).json({ + success: false, + error: "테이블 범위 규칙은 테이블명(tableName)이 필수입니다", + }); + } + } + + const newRule = await numberingRuleService.createRule( + ruleConfig, + companyCode, + userId + ); + + logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", { + ruleId: newRule.ruleId, + menuObjid: newRule.menuObjid, + }); + + return res.status(201).json({ success: true, data: newRule }); + } catch (error: any) { + if (error.code === "23505") { + return res + .status(409) + .json({ success: false, error: "이미 존재하는 규칙 ID입니다" }); + } + logger.error("❌ [POST /numbering-rules] 규칙 생성 실패:", { + error: error.message, + stack: error.stack, + code: error.code, + }); + return res.status(500).json({ success: false, error: error.message }); + } + } +); // 규칙 수정 -router.put("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; - const updates = req.body; +router.put( + "/:ruleId", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + const updates = req.body; - logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates }); + logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates }); - try { - const updatedRule = await numberingRuleService.updateRule(ruleId, updates, companyCode); - logger.info("채번 규칙 수정 성공", { ruleId, companyCode }); - return res.json({ success: true, data: updatedRule }); - } catch (error: any) { - logger.error("채번 규칙 수정 실패", { - ruleId, - companyCode, - error: error.message, - stack: error.stack - }); - if (error.message.includes("찾을 수 없거나")) { - return res.status(404).json({ success: false, error: error.message }); + try { + const updatedRule = await numberingRuleService.updateRule( + ruleId, + updates, + companyCode + ); + logger.info("채번 규칙 수정 성공", { ruleId, companyCode }); + return res.json({ success: true, data: updatedRule }); + } catch (error: any) { + logger.error("채번 규칙 수정 실패", { + ruleId, + companyCode, + error: error.message, + stack: error.stack, + }); + if (error.message.includes("찾을 수 없거나")) { + return res.status(404).json({ success: false, error: error.message }); + } + return res.status(500).json({ success: false, error: error.message }); } - return res.status(500).json({ success: false, error: error.message }); } -}); +); // 규칙 삭제 -router.delete("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; +router.delete( + "/:ruleId", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; - try { - await numberingRuleService.deleteRule(ruleId, companyCode); - return res.json({ success: true, message: "규칙이 삭제되었습니다" }); - } catch (error: any) { - if (error.message.includes("찾을 수 없거나")) { - return res.status(404).json({ success: false, error: error.message }); + try { + await numberingRuleService.deleteRule(ruleId, companyCode); + return res.json({ success: true, message: "규칙이 삭제되었습니다" }); + } catch (error: any) { + if (error.message.includes("찾을 수 없거나")) { + return res.status(404).json({ success: false, error: error.message }); + } + logger.error("규칙 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); } - logger.error("규칙 삭제 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); } -}); +); // 코드 미리보기 (순번 증가 없음) -router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; - const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용) +router.post( + "/:ruleId/preview", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용) - try { - const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData); - return res.json({ success: true, data: { generatedCode: previewCode } }); - } catch (error: any) { - logger.error("코드 미리보기 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + const previewCode = await numberingRuleService.previewCode( + ruleId, + companyCode, + formData + ); + return res.json({ success: true, data: { generatedCode: previewCode } }); + } catch (error: any) { + logger.error("코드 미리보기 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // 코드 할당 (저장 시점에 실제 순번 증가) -router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; - const { formData, userInputCode } = req.body; // 폼 데이터 + 사용자가 편집한 코드 +router.post( + "/:ruleId/allocate", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + const { formData, userInputCode } = req.body; // 폼 데이터 + 사용자가 편집한 코드 - logger.info("코드 할당 요청", { ruleId, companyCode, hasFormData: !!formData, userInputCode }); + logger.info("코드 할당 요청", { + ruleId, + companyCode, + hasFormData: !!formData, + userInputCode, + }); - try { - const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData, userInputCode); - logger.info("코드 할당 성공", { ruleId, allocatedCode }); - return res.json({ success: true, data: { generatedCode: allocatedCode } }); - } catch (error: any) { - logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + const allocatedCode = await numberingRuleService.allocateCode( + ruleId, + companyCode, + formData, + userInputCode + ); + logger.info("코드 할당 성공", { ruleId, allocatedCode }); + return res.json({ + success: true, + data: { generatedCode: allocatedCode }, + }); + } catch (error: any) { + logger.error("코드 할당 실패", { + ruleId, + companyCode, + error: error.message, + }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // 코드 생성 (기존 호환성 유지, deprecated) -router.post("/:ruleId/generate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; +router.post( + "/:ruleId/generate", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; - try { - const generatedCode = await numberingRuleService.generateCode(ruleId, companyCode); - return res.json({ success: true, data: { generatedCode } }); - } catch (error: any) { - logger.error("코드 생성 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + const generatedCode = await numberingRuleService.generateCode( + ruleId, + companyCode + ); + return res.json({ success: true, data: { generatedCode } }); + } catch (error: any) { + logger.error("코드 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // 시퀀스 초기화 -router.post("/:ruleId/reset", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; +router.post( + "/:ruleId/reset", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; - try { - await numberingRuleService.resetSequence(ruleId, companyCode); - return res.json({ success: true, message: "시퀀스가 초기화되었습니다" }); - } catch (error: any) { - logger.error("시퀀스 초기화 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + await numberingRuleService.resetSequence(ruleId, companyCode); + return res.json({ success: true, message: "시퀀스가 초기화되었습니다" }); + } catch (error: any) { + logger.error("시퀀스 초기화 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // ==================== 테스트 테이블용 API ==================== // [테스트] 테스트 테이블에서 채번 규칙 목록 조회 -router.get("/test/list/:menuObjid?", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined; +router.get( + "/test/list/:menuObjid?", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const menuObjid = req.params.menuObjid + ? parseInt(req.params.menuObjid) + : undefined; - logger.info("[테스트] 채번 규칙 목록 조회 요청", { companyCode, menuObjid }); + logger.info("[테스트] 채번 규칙 목록 조회 요청", { + companyCode, + menuObjid, + }); - try { - const rules = await numberingRuleService.getRulesFromTest(companyCode, menuObjid); - logger.info("[테스트] 채번 규칙 목록 조회 성공", { companyCode, menuObjid, count: rules.length }); - return res.json({ success: true, data: rules }); - } catch (error: any) { - logger.error("[테스트] 채번 규칙 목록 조회 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + const rules = await numberingRuleService.getRulesFromTest( + companyCode, + menuObjid + ); + logger.info("[테스트] 채번 규칙 목록 조회 성공", { + companyCode, + menuObjid, + count: rules.length, + }); + return res.json({ success: true, data: rules }); + } catch (error: any) { + logger.error("[테스트] 채번 규칙 목록 조회 실패", { + error: error.message, + }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // [테스트] 테이블+컬럼 기반 채번 규칙 조회 -router.get("/test/by-column/:tableName/:columnName", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { tableName, columnName } = req.params; +router.get( + "/test/by-column/:tableName/:columnName", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { tableName, columnName } = req.params; - try { - const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, columnName); - return res.json({ success: true, data: rule }); - } catch (error: any) { - logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + const rule = await numberingRuleService.getNumberingRuleByColumn( + companyCode, + tableName, + columnName + ); + return res.json({ success: true, data: rule }); + } catch (error: any) { + logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", { + error: error.message, + }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // [테스트] 테스트 테이블에 채번 규칙 저장 // 채번 규칙은 독립적으로 생성 가능 (나중에 테이블 타입 관리에서 컬럼에 연결) -router.post("/test/save", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const ruleConfig = req.body; +router.post( + "/test/save", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const ruleConfig = req.body; - logger.info("[테스트] 채번 규칙 저장 요청", { - ruleId: ruleConfig.ruleId, - ruleName: ruleConfig.ruleName, - tableName: ruleConfig.tableName || "(미지정)", - columnName: ruleConfig.columnName || "(미지정)", - }); + logger.info("[테스트] 채번 규칙 저장 요청", { + ruleId: ruleConfig.ruleId, + ruleName: ruleConfig.ruleName, + tableName: ruleConfig.tableName || "(미지정)", + columnName: ruleConfig.columnName || "(미지정)", + }); - try { - // ruleName만 필수, tableName/columnName은 선택 (나중에 테이블 타입 관리에서 연결) - if (!ruleConfig.ruleName) { - return res.status(400).json({ - success: false, - error: "ruleName is required" - }); + try { + // ruleName만 필수, tableName/columnName은 선택 (나중에 테이블 타입 관리에서 연결) + if (!ruleConfig.ruleName) { + return res.status(400).json({ + success: false, + error: "ruleName is required", + }); + } + + const savedRule = await numberingRuleService.saveRuleToTest( + ruleConfig, + companyCode, + userId + ); + return res.json({ success: true, data: savedRule }); + } catch (error: any) { + logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); } - - const savedRule = await numberingRuleService.saveRuleToTest(ruleConfig, companyCode, userId); - return res.json({ success: true, data: savedRule }); - } catch (error: any) { - logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); } -}); +); // [테스트] 테스트 테이블에서 채번 규칙 삭제 -router.delete("/test/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; +router.delete( + "/test/:ruleId", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; - try { - await numberingRuleService.deleteRuleFromTest(ruleId, companyCode); - return res.json({ success: true, message: "테스트 채번 규칙이 삭제되었습니다" }); - } catch (error: any) { - logger.error("[테스트] 채번 규칙 삭제 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + await numberingRuleService.deleteRuleFromTest(ruleId, companyCode); + return res.json({ + success: true, + message: "테스트 채번 규칙이 삭제되었습니다", + }); + } catch (error: any) { + logger.error("[테스트] 채번 규칙 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // [테스트] 코드 미리보기 (테스트 테이블 사용) -router.post("/test/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; - const { formData } = req.body; +router.post( + "/test/:ruleId/preview", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + const { formData } = req.body; - try { - const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData); - return res.json({ success: true, data: { generatedCode: previewCode } }); - } catch (error: any) { - logger.error("[테스트] 코드 미리보기 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + const previewCode = await numberingRuleService.previewCode( + ruleId, + companyCode, + formData + ); + return res.json({ success: true, data: { generatedCode: previewCode } }); + } catch (error: any) { + logger.error("[테스트] 코드 미리보기 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // ==================== 회사별 채번규칙 복제 API ==================== // 회사별 채번규칙 복제 -router.post("/copy-for-company", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const userCompanyCode = req.user!.companyCode; - const { sourceCompanyCode, targetCompanyCode } = req.body; +router.post( + "/copy-for-company", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const userCompanyCode = req.user!.companyCode; + const { sourceCompanyCode, targetCompanyCode } = req.body; - // 최고 관리자만 사용 가능 - if (userCompanyCode !== "*") { - return res.status(403).json({ - success: false, - error: "최고 관리자만 사용할 수 있습니다" - }); - } + // 최고 관리자만 사용 가능 + if (userCompanyCode !== "*") { + return res.status(403).json({ + success: false, + error: "최고 관리자만 사용할 수 있습니다", + }); + } - if (!sourceCompanyCode || !targetCompanyCode) { - return res.status(400).json({ - success: false, - error: "sourceCompanyCode와 targetCompanyCode가 필요합니다" - }); - } + if (!sourceCompanyCode || !targetCompanyCode) { + return res.status(400).json({ + success: false, + error: "sourceCompanyCode와 targetCompanyCode가 필요합니다", + }); + } - try { - const result = await numberingRuleService.copyRulesForCompany(sourceCompanyCode, targetCompanyCode); - return res.json({ success: true, data: result }); - } catch (error: any) { - logger.error("회사별 채번규칙 복제 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + const result = await numberingRuleService.copyRulesForCompany( + sourceCompanyCode, + targetCompanyCode + ); + return res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("회사별 채번규칙 복제 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); export default router; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 6e8d0b7b..2d4aa581 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -322,7 +322,9 @@ export class TableManagementService { }); } else { // menu_objid 컬럼이 없는 경우 - 매핑 없이 진행 - logger.info("⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵"); + logger.info( + "⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵" + ); } } catch (mappingError: any) { logger.warn("⚠️ getColumnList: 카테고리 매핑 조회 실패, 스킵", { @@ -488,7 +490,10 @@ export class TableManagementService { // table_type_columns에 모든 설정 저장 (멀티테넌시 지원) // detailSettings가 문자열이면 그대로, 객체면 JSON.stringify let detailSettingsStr = settings.detailSettings; - if (typeof settings.detailSettings === "object" && settings.detailSettings !== null) { + if ( + typeof settings.detailSettings === "object" && + settings.detailSettings !== null + ) { detailSettingsStr = JSON.stringify(settings.detailSettings); } @@ -734,7 +739,7 @@ export class TableManagementService { inputType?: string ): Promise { try { - // 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로 + // 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로 // DB의 input_type(웹타입)으로 저장하면 안 됨 - 'text'로 변환 let finalWebType = webType; if (webType === "direct" || webType === "auto") { @@ -749,7 +754,8 @@ export class TableManagementService { ); // 웹 타입별 기본 상세 설정 생성 - const defaultDetailSettings = this.generateDefaultDetailSettings(finalWebType); + const defaultDetailSettings = + this.generateDefaultDetailSettings(finalWebType); // 사용자 정의 설정과 기본 설정 병합 const finalDetailSettings = { @@ -768,7 +774,12 @@ export class TableManagementService { input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, updated_date = NOW()`, - [tableName, columnName, finalWebType, JSON.stringify(finalDetailSettings)] + [ + tableName, + columnName, + finalWebType, + JSON.stringify(finalDetailSettings), + ] ); logger.info( `컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${finalWebType}` @@ -796,7 +807,7 @@ export class TableManagementService { detailSettings?: Record ): Promise { try { - // 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로 + // 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로 // DB의 input_type(웹타입)으로 저장하면 안 됨 - 'text'로 변환 let finalInputType = inputType; if (inputType === "direct" || inputType === "auto") { @@ -1473,7 +1484,11 @@ export class TableManagementService { columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime") ) { - return this.buildDateRangeCondition(columnName, actualValue, paramIndex); + return this.buildDateRangeCondition( + columnName, + actualValue, + paramIndex + ); } // 그 외 타입이면 다중선택(IN 조건)으로 처리 @@ -3464,7 +3479,7 @@ export class TableManagementService { // 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색 const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`; const alias = aliasMap.get(aliasKey); - + // 🔧 파이프로 구분된 다중 선택값 처리 if (safeValue.includes("|")) { const multiValues = safeValue diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 6ea347c2..8d6df989 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -51,13 +51,9 @@ export const ButtonConfigPanel: React.FC = ({ }) => { // 🔧 component가 없는 경우 방어 처리 if (!component) { - return ( -
- 컴포넌트 정보를 불러올 수 없습니다. -
- ); + return
컴포넌트 정보를 불러올 수 없습니다.
; } - + // 🔧 component에서 직접 읽기 (useMemo 제거) const config = component.componentConfig || {}; const currentAction = component.componentConfig?.action || {}; @@ -122,7 +118,9 @@ export const ButtonConfigPanel: React.FC = ({ const [modalActionTargetTable, setModalActionTargetTable] = useState(null); const [modalActionSourceColumns, setModalActionSourceColumns] = useState>([]); const [modalActionTargetColumns, setModalActionTargetColumns] = useState>([]); - const [modalActionFieldMappings, setModalActionFieldMappings] = useState>([]); + const [modalActionFieldMappings, setModalActionFieldMappings] = useState< + Array<{ sourceField: string; targetField: string }> + >([]); const [modalFieldMappingSourceOpen, setModalFieldMappingSourceOpen] = useState>({}); const [modalFieldMappingTargetOpen, setModalFieldMappingTargetOpen] = useState>({}); const [modalFieldMappingSourceSearch, setModalFieldMappingSourceSearch] = useState>({}); @@ -353,7 +351,7 @@ export const ButtonConfigPanel: React.FC = ({ useEffect(() => { const actionType = config.action?.type; if (actionType !== "modal") return; - + const autoDetect = config.action?.autoDetectDataSource; if (!autoDetect) { // 데이터 전달이 비활성화되면 상태 초기화 @@ -363,19 +361,19 @@ export const ButtonConfigPanel: React.FC = ({ setModalActionTargetColumns([]); return; } - + const targetScreenId = config.action?.targetScreenId; if (!targetScreenId) return; - + const loadModalActionMappingData = async () => { // 1. 소스 테이블 감지 (현재 화면) let sourceTableName: string | null = currentTableName || null; - + // allComponents에서 분할패널/테이블리스트/통합목록 감지 for (const comp of allComponents) { const compType = comp.componentType || (comp as any).componentConfig?.type; const compConfig = (comp as any).componentConfig || {}; - + if (compType === "split-panel-layout" || compType === "screen-split-panel") { sourceTableName = compConfig.leftPanel?.tableName || compConfig.tableName || null; if (sourceTableName) break; @@ -389,9 +387,9 @@ export const ButtonConfigPanel: React.FC = ({ if (sourceTableName) break; } } - + setModalActionSourceTable(sourceTableName); - + // 2. 대상 화면의 테이블 조회 let targetTableName: string | null = null; try { @@ -405,9 +403,9 @@ export const ButtonConfigPanel: React.FC = ({ } catch (error) { console.error("대상 화면 정보 로드 실패:", error); } - + setModalActionTargetTable(targetTableName); - + // 3. 소스 테이블 컬럼 로드 if (sourceTableName) { try { @@ -416,7 +414,7 @@ export const ButtonConfigPanel: React.FC = ({ let columnData = response.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - + if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ name: col.name || col.columnName, @@ -429,7 +427,7 @@ export const ButtonConfigPanel: React.FC = ({ console.error("소스 테이블 컬럼 로드 실패:", error); } } - + // 4. 대상 테이블 컬럼 로드 if (targetTableName) { try { @@ -438,7 +436,7 @@ export const ButtonConfigPanel: React.FC = ({ let columnData = response.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - + if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ name: col.name || col.columnName, @@ -451,7 +449,7 @@ export const ButtonConfigPanel: React.FC = ({ console.error("대상 테이블 컬럼 로드 실패:", error); } } - + // 5. 기존 필드 매핑 로드 또는 자동 매핑 생성 const existingMappings = config.action?.fieldMappings || []; if (existingMappings.length > 0) { @@ -461,10 +459,16 @@ export const ButtonConfigPanel: React.FC = ({ setModalActionFieldMappings([]); // 빈 배열 = 자동 매핑 } }; - + loadModalActionMappingData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [config.action?.type, config.action?.autoDetectDataSource, config.action?.targetScreenId, currentTableName, allComponents]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + config.action?.type, + config.action?.autoDetectDataSource, + config.action?.targetScreenId, + currentTableName, + allComponents, + ]); // 🆕 현재 테이블 컬럼 로드 (그룹화 컬럼 선택용) useEffect(() => { @@ -818,25 +822,25 @@ export const ButtonConfigPanel: React.FC = ({ 페이지 이동 모달 열기 데이터 전달 - + {/* 엑셀 관련 */} 엑셀 다운로드 엑셀 업로드 - + {/* 고급 기능 */} 즉시 저장 제어 흐름 - + {/* 특수 기능 (필요 시 사용) */} 바코드 스캔 운행알림 및 종료 - + {/* 이벤트 버스 */} 이벤트 발송 - + {/* 복사 */} 복사 (품목코드 초기화) - + {/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김 연관 데이터 버튼 모달 열기 (deprecated) 데이터 전달 + 모달 열기 @@ -985,10 +989,10 @@ export const ButtonConfigPanel: React.FC = ({ }} />
-
@@ -996,11 +1000,11 @@ export const ButtonConfigPanel: React.FC = ({ {/* 🆕 필드 매핑 UI (데이터 전달 활성화 + 테이블이 다른 경우) */} {component.componentConfig?.action?.autoDetectDataSource === true && ( -
+
{/* 테이블 정보 표시 */}
- + 소스: {modalActionSourceTable || "감지 중..."}
@@ -1012,171 +1016,210 @@ export const ButtonConfigPanel: React.FC = ({
{/* 테이블이 같으면 자동 매핑 안내 */} - {modalActionSourceTable && modalActionTargetTable && modalActionSourceTable === modalActionTargetTable && ( -
- 동일한 테이블입니다. 컬럼명이 같은 필드는 자동으로 매핑됩니다. -
- )} + {modalActionSourceTable && + modalActionTargetTable && + modalActionSourceTable === modalActionTargetTable && ( +
+ 동일한 테이블입니다. 컬럼명이 같은 필드는 자동으로 매핑됩니다. +
+ )} {/* 테이블이 다르면 필드 매핑 UI 표시 */} - {modalActionSourceTable && modalActionTargetTable && modalActionSourceTable !== modalActionTargetTable && ( -
-
- - -
- - {(component.componentConfig?.action?.fieldMappings || []).length === 0 && ( -

- 컬럼명이 다른 경우 매핑을 추가하세요. 매핑이 없으면 동일 컬럼명만 전달됩니다. -

- )} - - {(component.componentConfig?.action?.fieldMappings || []).map((mapping: any, index: number) => ( -
- {/* 소스 필드 선택 */} - setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: open }))} - > - - - - - - setModalFieldMappingSourceSearch((prev) => ({ ...prev, [index]: val }))} - /> - - 컬럼을 찾을 수 없습니다. - - {modalActionSourceColumns - .filter((col) => - col.name.toLowerCase().includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) || - col.label.toLowerCase().includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) - ) - .map((col) => ( - { - const newMappings = [...(component.componentConfig?.action?.fieldMappings || [])]; - newMappings[index] = { ...newMappings[index], sourceField: col.name }; - setModalActionFieldMappings(newMappings); - onUpdateProperty("componentConfig.action.fieldMappings", newMappings); - setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: false })); - }} - > - -
- {col.label} - {col.name} -
-
- ))} -
-
-
-
-
- - - - {/* 대상 필드 선택 */} - setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: open }))} - > - - - - - - setModalFieldMappingTargetSearch((prev) => ({ ...prev, [index]: val }))} - /> - - 컬럼을 찾을 수 없습니다. - - {modalActionTargetColumns - .filter((col) => - col.name.toLowerCase().includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) || - col.label.toLowerCase().includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) - ) - .map((col) => ( - { - const newMappings = [...(component.componentConfig?.action?.fieldMappings || [])]; - newMappings[index] = { ...newMappings[index], targetField: col.name }; - setModalActionFieldMappings(newMappings); - onUpdateProperty("componentConfig.action.fieldMappings", newMappings); - setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: false })); - }} - > - -
- {col.label} - {col.name} -
-
- ))} -
-
-
-
-
- - {/* 삭제 버튼 */} + {modalActionSourceTable && + modalActionTargetTable && + modalActionSourceTable !== modalActionTargetTable && ( +
+
+
- ))} -
- )} + + {(component.componentConfig?.action?.fieldMappings || []).length === 0 && ( +

+ 컬럼명이 다른 경우 매핑을 추가하세요. 매핑이 없으면 동일 컬럼명만 전달됩니다. +

+ )} + + {(component.componentConfig?.action?.fieldMappings || []).map((mapping: any, index: number) => ( +
+ {/* 소스 필드 선택 */} + + setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: open })) + } + > + + + + + + + setModalFieldMappingSourceSearch((prev) => ({ ...prev, [index]: val })) + } + /> + + 컬럼을 찾을 수 없습니다. + + {modalActionSourceColumns + .filter( + (col) => + col.name + .toLowerCase() + .includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) || + col.label + .toLowerCase() + .includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()), + ) + .map((col) => ( + { + const newMappings = [ + ...(component.componentConfig?.action?.fieldMappings || []), + ]; + newMappings[index] = { ...newMappings[index], sourceField: col.name }; + setModalActionFieldMappings(newMappings); + onUpdateProperty("componentConfig.action.fieldMappings", newMappings); + setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: false })); + }} + > + +
+ {col.label} + {col.name} +
+
+ ))} +
+
+
+
+
+ + + + {/* 대상 필드 선택 */} + + setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: open })) + } + > + + + + + + + setModalFieldMappingTargetSearch((prev) => ({ ...prev, [index]: val })) + } + /> + + 컬럼을 찾을 수 없습니다. + + {modalActionTargetColumns + .filter( + (col) => + col.name + .toLowerCase() + .includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) || + col.label + .toLowerCase() + .includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()), + ) + .map((col) => ( + { + const newMappings = [ + ...(component.componentConfig?.action?.fieldMappings || []), + ]; + newMappings[index] = { ...newMappings[index], targetField: col.name }; + setModalActionFieldMappings(newMappings); + onUpdateProperty("componentConfig.action.fieldMappings", newMappings); + setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: false })); + }} + > + +
+ {col.label} + {col.name} +
+
+ ))} +
+
+
+
+
+ + {/* 삭제 버튼 */} + +
+ ))} +
+ )}
)}
@@ -1185,9 +1228,10 @@ export const ButtonConfigPanel: React.FC = ({ {/* 🆕 데이터 전달 + 모달 열기 액션 설정 (deprecated - 하위 호환성 유지) */} {component.componentConfig?.action?.type === "openModalWithData" && (
-

데이터 전달 + 모달 설정

+

데이터 전달 + 모달 설정

- 이 옵션은 "모달 열기" 액션으로 통합되었습니다. 새 개발에서는 "모달 열기" + "선택된 데이터 전달"을 사용하세요. + 이 옵션은 "모달 열기" 액션으로 통합되었습니다. 새 개발에서는 "모달 열기" + "선택된 데이터 전달"을 + 사용하세요.

{/* 🆕 블록 기반 제목 빌더 */} @@ -3546,8 +3590,8 @@ export const ButtonConfigPanel: React.FC = ({

이벤트 발송 설정

- 버튼 클릭 시 V2 이벤트 버스를 통해 이벤트를 발송합니다. - 다른 컴포넌트나 서비스에서 이 이벤트를 수신하여 처리할 수 있습니다. + 버튼 클릭 시 V2 이벤트 버스를 통해 이벤트를 발송합니다. 다른 컴포넌트나 서비스에서 이 이벤트를 수신하여 + 처리할 수 있습니다.

@@ -3597,11 +3641,13 @@ export const ButtonConfigPanel: React.FC = ({ type="number" className="h-8 text-xs" placeholder="3" - value={component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling?.leadTimeDays || 3} + value={ + component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling?.leadTimeDays || 3 + } onChange={(e) => { onUpdateProperty( "componentConfig.action.eventConfig.eventPayload.config.scheduling.leadTimeDays", - parseInt(e.target.value) || 3 + parseInt(e.target.value) || 3, ); }} /> @@ -3613,11 +3659,14 @@ export const ButtonConfigPanel: React.FC = ({ type="number" className="h-8 text-xs" placeholder="100" - value={component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling?.maxDailyCapacity || 100} + value={ + component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling + ?.maxDailyCapacity || 100 + } onChange={(e) => { onUpdateProperty( "componentConfig.action.eventConfig.eventPayload.config.scheduling.maxDailyCapacity", - parseInt(e.target.value) || 100 + parseInt(e.target.value) || 100, ); }} /> @@ -3625,8 +3674,8 @@ export const ButtonConfigPanel: React.FC = ({

- 동작 방식: 테이블에서 선택된 데이터를 기반으로 스케줄을 자동 생성합니다. - 생성 전 미리보기 확인 다이얼로그가 표시됩니다. + 동작 방식: 테이블에서 선택된 데이터를 기반으로 스케줄을 자동 생성합니다. 생성 전 + 미리보기 확인 다이얼로그가 표시됩니다.

diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 8b48c461..6c770e48 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -65,7 +65,7 @@ export function TabsWidget({ const [selectedTab, setSelectedTab] = useState(getInitialTab()); const [visibleTabs, setVisibleTabs] = useState(tabs as ExtendedTabItem[]); const [mountedTabs, setMountedTabs] = useState>(() => new Set([getInitialTab()])); - + // 🆕 화면 진입 시 첫 번째 탭 자동 선택 및 마운트 useEffect(() => { // 현재 선택된 탭이 유효하지 않거나 비어있으면 첫 번째 탭 선택 @@ -92,7 +92,7 @@ export function TabsWidget({ }); } }, [tabs]); // tabs가 변경될 때마다 실행 - + // screenId 기반 화면 로드 상태 const [screenLayouts, setScreenLayouts] = useState>({}); const [screenLoadingStates, setScreenLoadingStates] = useState>({}); @@ -109,23 +109,28 @@ export function TabsWidget({ for (const tab of visibleTabs) { const extTab = tab as ExtendedTabItem; // screenId가 있고, 아직 로드하지 않았으며, 인라인 컴포넌트가 없는 경우만 로드 - if (extTab.screenId && !screenLayouts[tab.id] && !screenLoadingStates[tab.id] && (!extTab.components || extTab.components.length === 0)) { - setScreenLoadingStates(prev => ({ ...prev, [tab.id]: true })); + if ( + extTab.screenId && + !screenLayouts[tab.id] && + !screenLoadingStates[tab.id] && + (!extTab.components || extTab.components.length === 0) + ) { + setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: true })); try { const layoutData = await screenApi.getLayout(extTab.screenId); if (layoutData && layoutData.components) { - setScreenLayouts(prev => ({ ...prev, [tab.id]: layoutData.components })); + setScreenLayouts((prev) => ({ ...prev, [tab.id]: layoutData.components })); } } catch (error) { console.error(`탭 "${tab.label}" 화면 로드 실패:`, error); - setScreenErrors(prev => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." })); + setScreenErrors((prev) => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." })); } finally { - setScreenLoadingStates(prev => ({ ...prev, [tab.id]: false })); + setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: false })); } } } }; - + loadScreenLayouts(); }, [visibleTabs, screenLayouts, screenLoadingStates]); @@ -180,11 +185,7 @@ export function TabsWidget({ const getTabsListClass = () => { const baseClass = orientation === "vertical" ? "flex-col" : ""; const variantClass = - variant === "pills" - ? "bg-muted p-1 rounded-lg" - : variant === "underline" - ? "border-b" - : "bg-muted p-1"; + variant === "pills" ? "bg-muted p-1 rounded-lg" : variant === "underline" ? "border-b" : "bg-muted p-1"; return `${baseClass} ${variantClass}`; }; @@ -192,47 +193,47 @@ export function TabsWidget({ const renderTabContent = (tab: ExtendedTabItem) => { const extTab = tab as ExtendedTabItem; const inlineComponents = tab.components || []; - + // 1. screenId가 있고 인라인 컴포넌트가 없는 경우 -> 화면 로드 방식 if (extTab.screenId && inlineComponents.length === 0) { // 로딩 중 if (screenLoadingStates[tab.id]) { return (
- - 화면을 불러오는 중... + + 화면을 불러오는 중...
); } - + // 에러 발생 if (screenErrors[tab.id]) { return ( -
+

{screenErrors[tab.id]}

); } - + // 화면 레이아웃이 로드된 경우 const loadedComponents = screenLayouts[tab.id]; if (loadedComponents && loadedComponents.length > 0) { return renderScreenComponents(loadedComponents); } - + // 아직 로드되지 않은 경우 return (
- +
); } - + // 2. 인라인 컴포넌트가 있는 경우 -> 기존 v2 방식 if (inlineComponents.length > 0) { return renderInlineComponents(tab, inlineComponents); } - + // 3. 둘 다 없는 경우 return (
@@ -246,22 +247,17 @@ export function TabsWidget({ // screenId로 로드한 화면 컴포넌트 렌더링 const renderScreenComponents = (components: ComponentData[]) => { // InteractiveScreenViewerDynamic 동적 로드 - const InteractiveScreenViewerDynamic = require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic; - + const InteractiveScreenViewerDynamic = + require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic; + // 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보 - const maxBottom = Math.max( - ...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), - 300 - ); - const maxRight = Math.max( - ...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), - 400 - ); - + const maxBottom = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), 300); + const maxRight = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), 400); + return ( -
(c.position?.y || 0) + (c.size?.height || 100)), - 300 // 최소 높이 + 300, // 최소 높이 ); const maxRight = Math.max( ...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), - 400 // 최소 너비 + 400, // 최소 너비 ); return ( -
{tab.label} {tab.components && tab.components.length > 0 && ( - - ({tab.components.length}) - + ({tab.components.length}) )} {allowCloseable && ( @@ -390,7 +384,7 @@ export function TabsWidget({ onClick={(e) => handleCloseTab(tab.id, e)} variant="ghost" size="sm" - className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 p-0 hover:bg-destructive/10" + className="hover:bg-destructive/10 absolute top-1/2 right-1 h-5 w-5 -translate-y-1/2 p-0" > diff --git a/frontend/components/unified/UnifiedRepeater.tsx b/frontend/components/unified/UnifiedRepeater.tsx index d802baa7..2f521665 100644 --- a/frontend/components/unified/UnifiedRepeater.tsx +++ b/frontend/components/unified/UnifiedRepeater.tsx @@ -8,7 +8,7 @@ * - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼 * * RepeaterTable 및 ItemSelectionModal 재사용 - * + * * 데이터 전달 인터페이스: * - DataProvidable: 선택된 데이터 제공 * - DataReceivable: 외부에서 데이터 수신 @@ -124,83 +124,91 @@ export const UnifiedRepeater: React.FC = ({ // DataProvidable 인터페이스 구현 // 다른 컴포넌트에서 이 리피터의 데이터를 가져갈 수 있게 함 // ============================================================ - const dataProvider: DataProvidable = useMemo(() => ({ - componentId: parentId || config.fieldName || "unified-repeater", - componentType: "unified-repeater", - - // 선택된 행 데이터 반환 - getSelectedData: () => { - return Array.from(selectedRows).map((idx) => data[idx]).filter(Boolean); - }, - - // 전체 데이터 반환 - getAllData: () => { - return [...data]; - }, - - // 선택 초기화 - clearSelection: () => { - setSelectedRows(new Set()); - }, - }), [parentId, config.fieldName, data, selectedRows]); + const dataProvider: DataProvidable = useMemo( + () => ({ + componentId: parentId || config.fieldName || "unified-repeater", + componentType: "unified-repeater", + + // 선택된 행 데이터 반환 + getSelectedData: () => { + return Array.from(selectedRows) + .map((idx) => data[idx]) + .filter(Boolean); + }, + + // 전체 데이터 반환 + getAllData: () => { + return [...data]; + }, + + // 선택 초기화 + clearSelection: () => { + setSelectedRows(new Set()); + }, + }), + [parentId, config.fieldName, data, selectedRows], + ); // ============================================================ // DataReceivable 인터페이스 구현 // 외부에서 이 리피터로 데이터를 전달받을 수 있게 함 // ============================================================ - const dataReceiver: DataReceivable = useMemo(() => ({ - componentId: parentId || config.fieldName || "unified-repeater", - componentType: "repeater", - - // 데이터 수신 (append, replace, merge 모드 지원) - receiveData: async (incomingData: any[], receiverConfig: DataReceiverConfig) => { - if (!incomingData || incomingData.length === 0) return; + const dataReceiver: DataReceivable = useMemo( + () => ({ + componentId: parentId || config.fieldName || "unified-repeater", + componentType: "repeater", - // 매핑 규칙 적용 - const mappedData = incomingData.map((item, index) => { - const newRow: any = { _id: `received_${Date.now()}_${index}` }; - - if (receiverConfig.mappingRules && receiverConfig.mappingRules.length > 0) { - receiverConfig.mappingRules.forEach((rule) => { - const sourceValue = item[rule.sourceField]; - newRow[rule.targetField] = sourceValue !== undefined ? sourceValue : rule.defaultValue; - }); - } else { - // 매핑 규칙 없으면 그대로 복사 - Object.assign(newRow, item); + // 데이터 수신 (append, replace, merge 모드 지원) + receiveData: async (incomingData: any[], receiverConfig: DataReceiverConfig) => { + if (!incomingData || incomingData.length === 0) return; + + // 매핑 규칙 적용 + const mappedData = incomingData.map((item, index) => { + const newRow: any = { _id: `received_${Date.now()}_${index}` }; + + if (receiverConfig.mappingRules && receiverConfig.mappingRules.length > 0) { + receiverConfig.mappingRules.forEach((rule) => { + const sourceValue = item[rule.sourceField]; + newRow[rule.targetField] = sourceValue !== undefined ? sourceValue : rule.defaultValue; + }); + } else { + // 매핑 규칙 없으면 그대로 복사 + Object.assign(newRow, item); + } + + return newRow; + }); + + // 모드에 따라 데이터 처리 + switch (receiverConfig.mode) { + case "replace": + setData(mappedData); + onDataChange?.(mappedData); + break; + case "merge": + // 중복 제거 후 병합 (id 또는 _id 기준) + const existingIds = new Set(data.map((row) => row.id || row._id)); + const newItems = mappedData.filter((row) => !existingIds.has(row.id || row._id)); + const mergedData = [...data, ...newItems]; + setData(mergedData); + onDataChange?.(mergedData); + break; + case "append": + default: + const appendedData = [...data, ...mappedData]; + setData(appendedData); + onDataChange?.(appendedData); + break; } - - return newRow; - }); + }, - // 모드에 따라 데이터 처리 - switch (receiverConfig.mode) { - case "replace": - setData(mappedData); - onDataChange?.(mappedData); - break; - case "merge": - // 중복 제거 후 병합 (id 또는 _id 기준) - const existingIds = new Set(data.map((row) => row.id || row._id)); - const newItems = mappedData.filter((row) => !existingIds.has(row.id || row._id)); - const mergedData = [...data, ...newItems]; - setData(mergedData); - onDataChange?.(mergedData); - break; - case "append": - default: - const appendedData = [...data, ...mappedData]; - setData(appendedData); - onDataChange?.(appendedData); - break; - } - }, - - // 현재 데이터 반환 - getData: () => { - return [...data]; - }, - }), [parentId, config.fieldName, data, onDataChange]); + // 현재 데이터 반환 + getData: () => { + return [...data]; + }, + }), + [parentId, config.fieldName, data, onDataChange], + ); // ============================================================ // ScreenContext에 DataProvider/DataReceiver 등록 @@ -208,7 +216,7 @@ export const UnifiedRepeater: React.FC = ({ useEffect(() => { if (screenContext && (parentId || config.fieldName)) { const componentId = parentId || config.fieldName || "unified-repeater"; - + screenContext.registerDataProvider(componentId, dataProvider); screenContext.registerDataReceiver(componentId, dataReceiver); @@ -231,7 +239,9 @@ export const UnifiedRepeater: React.FC = ({ componentId: parentId || config.fieldName || "unified-repeater", tableName: config.dataSource?.tableName || "", data: data, - selectedData: Array.from(selectedRows).map((idx) => data[idx]).filter(Boolean), + selectedData: Array.from(selectedRows) + .map((idx) => data[idx]) + .filter(Boolean), }); prevDataLengthRef.current = data.length; } @@ -701,19 +711,22 @@ export const UnifiedRepeater: React.FC = ({ // 🆕 채번 API 호출 (비동기) // 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가 - const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record): Promise => { - try { - const result = await allocateNumberingCode(ruleId, userInputCode, formData); - if (result.success && result.data?.generatedCode) { - return result.data.generatedCode; + const generateNumberingCode = useCallback( + async (ruleId: string, userInputCode?: string, formData?: Record): Promise => { + try { + const result = await allocateNumberingCode(ruleId, userInputCode, formData); + if (result.success && result.data?.generatedCode) { + return result.data.generatedCode; + } + console.error("채번 실패:", result.error); + return ""; + } catch (error) { + console.error("채번 API 호출 실패:", error); + return ""; } - console.error("채번 실패:", result.error); - return ""; - } catch (error) { - console.error("채번 API 호출 실패:", error); - return ""; - } - }, []); + }, + [], + ); // 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경 const handleAddRow = useCallback(async () => { diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index 5c66ba00..eda9e5b2 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -6,7 +6,7 @@ * 렌더링 모드: * - inline: 현재 테이블 컬럼 직접 입력 * - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼 - * + * * RepeaterTable 및 ItemSelectionModal 재사용 */ @@ -63,7 +63,7 @@ export const V2Repeater: React.FC = ({ // 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거 const [autoWidthTrigger, setAutoWidthTrigger] = useState(0); - + // 소스 테이블 컬럼 라벨 매핑 const [sourceColumnLabels, setSourceColumnLabels] = useState>({}); @@ -72,10 +72,10 @@ export const V2Repeater: React.FC = ({ // 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용) const [categoryLabelMap, setCategoryLabelMap] = useState>({}); - + // 현재 테이블 컬럼 정보 (inputType 매핑용) const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState>({}); - + // 동적 데이터 소스 상태 const [activeDataSources, setActiveDataSources] = useState>({}); @@ -88,10 +88,9 @@ export const V2Repeater: React.FC = ({ // 전역 리피터 등록 // 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블) useEffect(() => { - const targetTableName = config.useCustomTable && config.mainTableName - ? config.mainTableName - : config.dataSource?.tableName; - + const targetTableName = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; + if (targetTableName) { if (!window.__v2RepeaterInstances) { window.__v2RepeaterInstances = new Set(); @@ -110,22 +109,21 @@ export const V2Repeater: React.FC = ({ useEffect(() => { const handleSaveEvent = async (event: CustomEvent) => { // 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용 - const tableName = config.useCustomTable && config.mainTableName - ? config.mainTableName - : config.dataSource?.tableName; + const tableName = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; const eventParentId = event.detail?.parentId; const mainFormData = event.detail?.mainFormData; - + // 🆕 마스터 테이블에서 생성된 ID (FK 연결용) const masterRecordId = event.detail?.masterRecordId || mainFormData?.id; - + if (!tableName || data.length === 0) { return; } // V2Repeater 저장 시작 - const saveInfo = { - tableName, + const saveInfo = { + tableName, useCustomTable: config.useCustomTable, mainTableName: config.mainTableName, foreignKeyColumn: config.foreignKeyColumn, @@ -145,10 +143,10 @@ export const V2Repeater: React.FC = ({ } catch { console.warn("테이블 컬럼 정보 조회 실패"); } - + for (let i = 0; i < data.length; i++) { const row = data[i]; - + // 내부 필드 제거 const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_"))); @@ -157,14 +155,14 @@ export const V2Repeater: React.FC = ({ if (config.useCustomTable && config.mainTableName) { // 커스텀 테이블: 리피터 데이터만 저장 mergedData = { ...cleanRow }; - + // 🆕 FK 자동 연결 - foreignKeySourceColumn이 설정된 경우 해당 컬럼 값 사용 if (config.foreignKeyColumn) { // foreignKeySourceColumn이 있으면 mainFormData에서 해당 컬럼 값 사용 // 없으면 마스터 레코드 ID 사용 (기존 동작) const sourceColumn = config.foreignKeySourceColumn; let fkValue: any; - + if (sourceColumn && mainFormData && mainFormData[sourceColumn] !== undefined) { // mainFormData에서 참조 컬럼 값 가져오기 fkValue = mainFormData[sourceColumn]; @@ -172,18 +170,18 @@ export const V2Repeater: React.FC = ({ // 기본: 마스터 레코드 ID 사용 fkValue = masterRecordId; } - + if (fkValue !== undefined && fkValue !== null) { mergedData[config.foreignKeyColumn] = fkValue; } } } else { // 기존 방식: 메인 폼 데이터 병합 - const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {}; + const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {}; mergedData = { - ...mainFormDataWithoutId, - ...cleanRow, - }; + ...mainFormDataWithoutId, + ...cleanRow, + }; } // 유효하지 않은 컬럼 제거 @@ -193,10 +191,9 @@ export const V2Repeater: React.FC = ({ filteredData[key] = value; } } - + await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData); } - } catch (error) { console.error("❌ V2Repeater 저장 실패:", error); throw error; @@ -207,14 +204,13 @@ export const V2Repeater: React.FC = ({ const unsubscribe = v2EventBus.subscribe( V2_EVENTS.REPEATER_SAVE, async (payload) => { - const tableName = config.useCustomTable && config.mainTableName - ? config.mainTableName - : config.dataSource?.tableName; + const tableName = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; if (payload.tableName === tableName) { await handleSaveEvent({ detail: payload } as CustomEvent); } }, - { componentId: `v2-repeater-${config.dataSource?.tableName}` } + { componentId: `v2-repeater-${config.dataSource?.tableName}` }, ); // 레거시 이벤트도 계속 지원 (점진적 마이그레이션) @@ -223,7 +219,14 @@ export const V2Repeater: React.FC = ({ unsubscribe(); window.removeEventListener("repeaterSave" as any, handleSaveEvent); }; - }, [data, config.dataSource?.tableName, config.useCustomTable, config.mainTableName, config.foreignKeyColumn, parentId]); + }, [ + data, + config.dataSource?.tableName, + config.useCustomTable, + config.mainTableName, + config.foreignKeyColumn, + parentId, + ]); // 현재 테이블 컬럼 정보 로드 useEffect(() => { @@ -234,7 +237,7 @@ export const V2Repeater: React.FC = ({ try { const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); const columns = response.data?.data?.columns || response.data?.columns || response.data || []; - + const columnMap: Record = {}; columns.forEach((col: any) => { const name = col.columnName || col.column_name || col.name; @@ -320,7 +323,7 @@ export const V2Repeater: React.FC = ({ try { const response = await apiClient.get(`/table-management/tables/${resolvedSourceTable}/columns`); const columns = response.data?.data?.columns || response.data?.columns || response.data || []; - + const labels: Record = {}; const categoryCols: string[] = []; @@ -364,13 +367,13 @@ export const V2Repeater: React.FC = ({ calculated: true, width: col.width === "auto" ? undefined : col.width, }; - } - + } + // 일반 입력 컬럼 let type: "text" | "number" | "date" | "select" | "category" = "text"; - if (inputType === "number" || inputType === "decimal") type = "number"; - else if (inputType === "date" || inputType === "datetime") type = "date"; - else if (inputType === "code") type = "select"; + if (inputType === "number" || inputType === "decimal") type = "number"; + else if (inputType === "date" || inputType === "datetime") type = "date"; + else if (inputType === "code") type = "select"; else if (inputType === "category") type = "category"; // 🆕 카테고리 타입 // 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식) @@ -383,19 +386,19 @@ export const V2Repeater: React.FC = ({ categoryRef = `${tableName}.${col.key}`; } } - - return { - field: col.key, - label: col.title || colInfo?.displayName || col.key, - type, - editable: col.editable !== false, - width: col.width === "auto" ? undefined : col.width, - required: false, + + return { + field: col.key, + label: col.title || colInfo?.displayName || col.key, + type, + editable: col.editable !== false, + width: col.width === "auto" ? undefined : col.width, + required: false, categoryRef, // 🆕 카테고리 참조 ID 전달 hidden: col.hidden, // 🆕 히든 처리 autoFill: col.autoFill, // 🆕 자동 입력 설정 - }; - }); + }; + }); }, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]); // 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용) @@ -451,26 +454,25 @@ export const V2Repeater: React.FC = ({ // 데이터 변경 핸들러 const handleDataChange = useCallback( (newData: any[]) => { - setData(newData); - - // 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용) - if (onDataChange) { - const targetTable = config.useCustomTable && config.mainTableName - ? config.mainTableName - : config.dataSource?.tableName; - - if (targetTable) { - // 각 행에 _targetTable 추가 - const dataWithTarget = newData.map(row => ({ - ...row, - _targetTable: targetTable, - })); - onDataChange(dataWithTarget); - } else { - onDataChange(newData); + setData(newData); + + // 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용) + if (onDataChange) { + const targetTable = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; + + if (targetTable) { + // 각 행에 _targetTable 추가 + const dataWithTarget = newData.map((row) => ({ + ...row, + _targetTable: targetTable, + })); + onDataChange(dataWithTarget); + } else { + onDataChange(newData); + } } - } - + // 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 setAutoWidthTrigger((prev) => prev + 1); }, @@ -480,26 +482,25 @@ export const V2Repeater: React.FC = ({ // 행 변경 핸들러 const handleRowChange = useCallback( (index: number, newRow: any) => { - const newData = [...data]; - newData[index] = newRow; - setData(newData); - - // 🆕 _targetTable 메타데이터 포함 - if (onDataChange) { - const targetTable = config.useCustomTable && config.mainTableName - ? config.mainTableName - : config.dataSource?.tableName; - - if (targetTable) { - const dataWithTarget = newData.map(row => ({ - ...row, - _targetTable: targetTable, - })); - onDataChange(dataWithTarget); - } else { - onDataChange(newData); + const newData = [...data]; + newData[index] = newRow; + setData(newData); + + // 🆕 _targetTable 메타데이터 포함 + if (onDataChange) { + const targetTable = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; + + if (targetTable) { + const dataWithTarget = newData.map((row) => ({ + ...row, + _targetTable: targetTable, + })); + onDataChange(dataWithTarget); + } else { + onDataChange(newData); + } } - } }, [data, onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName], ); @@ -507,16 +508,16 @@ export const V2Repeater: React.FC = ({ // 행 삭제 핸들러 const handleRowDelete = useCallback( (index: number) => { - const newData = data.filter((_, i) => i !== index); + const newData = data.filter((_, i) => i !== index); handleDataChange(newData); // 🆕 handleDataChange 사용 - - // 선택 상태 업데이트 - const newSelected = new Set(); - selectedRows.forEach((i) => { - if (i < index) newSelected.add(i); - else if (i > index) newSelected.add(i - 1); - }); - setSelectedRows(newSelected); + + // 선택 상태 업데이트 + const newSelected = new Set(); + selectedRows.forEach((i) => { + if (i < index) newSelected.add(i); + else if (i > index) newSelected.add(i - 1); + }); + setSelectedRows(newSelected); }, [data, selectedRows, handleDataChange], ); @@ -535,30 +536,30 @@ export const V2Repeater: React.FC = ({ if (!col.autoFill || col.autoFill.type === "none") return undefined; const now = new Date(); - + switch (col.autoFill.type) { case "currentDate": return now.toISOString().split("T")[0]; // YYYY-MM-DD - + case "currentDateTime": return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss - + case "sequence": return rowIndex + 1; // 1부터 시작하는 순번 - + case "numbering": // 채번은 별도 비동기 처리 필요 return null; // null 반환하여 비동기 처리 필요함을 표시 - + case "fromMainForm": if (col.autoFill.sourceField && mainFormData) { return mainFormData[col.autoFill.sourceField]; } return ""; - + case "fixed": return col.autoFill.fixedValue ?? ""; - + default: return undefined; } @@ -568,19 +569,22 @@ export const V2Repeater: React.FC = ({ // 🆕 채번 API 호출 (비동기) // 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가 - const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record): Promise => { - try { - const result = await allocateNumberingCode(ruleId, userInputCode, formData); - if (result.success && result.data?.generatedCode) { - return result.data.generatedCode; + const generateNumberingCode = useCallback( + async (ruleId: string, userInputCode?: string, formData?: Record): Promise => { + try { + const result = await allocateNumberingCode(ruleId, userInputCode, formData); + if (result.success && result.data?.generatedCode) { + return result.data.generatedCode; + } + console.error("채번 실패:", result.error); + return ""; + } catch (error) { + console.error("채번 API 호출 실패:", error); + return ""; } - console.error("채번 실패:", result.error); - return ""; - } catch (error) { - console.error("채번 API 호출 실패:", error); - return ""; - } - }, []); + }, + [], + ); // 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경 const handleAddRow = useCallback(async () => { @@ -589,7 +593,7 @@ export const V2Repeater: React.FC = ({ } else { const newRow: any = { _id: `new_${Date.now()}` }; const currentRowCount = data.length; - + // 먼저 동기적 자동 입력 값 적용 for (const col of config.columns) { const autoValue = generateAutoFillValueSync(col, currentRowCount); @@ -599,10 +603,10 @@ export const V2Repeater: React.FC = ({ } else if (autoValue !== undefined) { newRow[col.key] = autoValue; } else { - newRow[col.key] = ""; + newRow[col.key] = ""; } } - + const newData = [...data, newRow]; handleDataChange(newData); } @@ -611,23 +615,23 @@ export const V2Repeater: React.FC = ({ // 모달에서 항목 선택 - 비동기로 변경 const handleSelectItems = useCallback( async (items: Record[]) => { - const fkColumn = config.dataSource?.foreignKey; + const fkColumn = config.dataSource?.foreignKey; const currentRowCount = data.length; // 채번이 필요한 컬럼 찾기 const numberingColumns = config.columns.filter( - (col) => !col.isSourceDisplay && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId + (col) => !col.isSourceDisplay && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId, ); - + const newRows = await Promise.all( items.map(async (item, index) => { - const row: any = { _id: `new_${Date.now()}_${Math.random()}` }; - + const row: any = { _id: `new_${Date.now()}_${Math.random()}` }; + // FK 값 저장 (resolvedReferenceKey 사용) if (fkColumn && item[resolvedReferenceKey]) { row[fkColumn] = item[resolvedReferenceKey]; - } - + } + // 모든 컬럼 처리 (순서대로) for (const col of config.columns) { if (col.isSourceDisplay) { @@ -643,20 +647,28 @@ export const V2Repeater: React.FC = ({ row[col.key] = autoValue; } else if (row[col.key] === undefined) { // 입력 컬럼: 빈 값으로 초기화 - row[col.key] = ""; - } + row[col.key] = ""; + } } } - - return row; - }) + + return row; + }), ); - - const newData = [...data, ...newRows]; + + const newData = [...data, ...newRows]; handleDataChange(newData); - setModalOpen(false); + setModalOpen(false); }, - [config.dataSource?.foreignKey, resolvedReferenceKey, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode], + [ + config.dataSource?.foreignKey, + resolvedReferenceKey, + config.columns, + data, + handleDataChange, + generateAutoFillValueSync, + generateNumberingCode, + ], ); // 소스 컬럼 목록 (모달용) - 🆕 columns 배열에서 isSourceDisplay인 것만 필터링 @@ -670,19 +682,19 @@ export const V2Repeater: React.FC = ({ // 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환 const dataRef = useRef(data); dataRef.current = data; - + useEffect(() => { const handleBeforeFormSave = async (event: Event) => { const customEvent = event as CustomEvent; const formData = customEvent.detail?.formData; - + if (!formData || !dataRef.current.length) return; // 채번 placeholder가 있는 행들을 찾아서 실제 값으로 변환 const processedData = await Promise.all( dataRef.current.map(async (row) => { const newRow = { ...row }; - + for (const key of Object.keys(newRow)) { const value = newRow[key]; if (typeof value === "string" && value.startsWith("__NUMBERING_RULE__")) { @@ -706,16 +718,16 @@ export const V2Repeater: React.FC = ({ } } } - + return newRow; }), ); - + // 처리된 데이터를 formData에 추가 const fieldName = config.fieldName || "repeaterData"; formData[fieldName] = processedData; }; - + // V2 EventBus 구독 const unsubscribe = v2EventBus.subscribe( V2_EVENTS.FORM_SAVE_COLLECT, @@ -726,12 +738,12 @@ export const V2Repeater: React.FC = ({ } as CustomEvent; await handleBeforeFormSave(fakeEvent); }, - { componentId: `v2-repeater-${config.dataSource?.tableName}` } + { componentId: `v2-repeater-${config.dataSource?.tableName}` }, ); // 레거시 이벤트도 계속 지원 (점진적 마이그레이션) window.addEventListener("beforeFormSave", handleBeforeFormSave); - + return () => { unsubscribe(); window.removeEventListener("beforeFormSave", handleBeforeFormSave); @@ -744,20 +756,20 @@ export const V2Repeater: React.FC = ({ const handleComponentDataTransfer = async (event: Event) => { const customEvent = event as CustomEvent; const { targetComponentId, data: transferData, mappingRules, mode } = customEvent.detail || {}; - + // 이 컴포넌트가 대상인지 확인 if (targetComponentId !== parentId && targetComponentId !== config.fieldName) { return; } - + if (!transferData || transferData.length === 0) { return; } - + // 데이터 매핑 처리 const mappedData = transferData.map((item: any, index: number) => { const newRow: any = { _id: `transfer_${Date.now()}_${index}` }; - + if (mappingRules && mappingRules.length > 0) { // 매핑 규칙이 있으면 적용 mappingRules.forEach((rule: any) => { @@ -767,10 +779,10 @@ export const V2Repeater: React.FC = ({ // 매핑 규칙 없으면 그대로 복사 Object.assign(newRow, item); } - + return newRow; }); - + // mode에 따라 데이터 처리 if (mode === "replace") { handleDataChange(mappedData); @@ -784,20 +796,20 @@ export const V2Repeater: React.FC = ({ handleDataChange([...data, ...mappedData]); } }; - + // splitPanelDataTransfer: 분할 패널에서 전역 이벤트로 전달 const handleSplitPanelDataTransfer = async (event: Event) => { const customEvent = event as CustomEvent; const { data: transferData, mappingRules, mode, sourcePosition } = customEvent.detail || {}; - + if (!transferData || transferData.length === 0) { return; } - + // 데이터 매핑 처리 const mappedData = transferData.map((item: any, index: number) => { const newRow: any = { _id: `transfer_${Date.now()}_${index}` }; - + if (mappingRules && mappingRules.length > 0) { mappingRules.forEach((rule: any) => { newRow[rule.targetField] = item[rule.sourceField]; @@ -805,10 +817,10 @@ export const V2Repeater: React.FC = ({ } else { Object.assign(newRow, item); } - + return newRow; }); - + // mode에 따라 데이터 처리 if (mode === "replace") { handleDataChange(mappedData); @@ -816,7 +828,7 @@ export const V2Repeater: React.FC = ({ handleDataChange([...data, ...mappedData]); } }; - + // V2 EventBus 구독 const unsubscribeComponent = v2EventBus.subscribe( V2_EVENTS.COMPONENT_DATA_TRANSFER, @@ -831,7 +843,7 @@ export const V2Repeater: React.FC = ({ } as CustomEvent; handleComponentDataTransfer(fakeEvent); }, - { componentId: `v2-repeater-${config.dataSource?.tableName}` } + { componentId: `v2-repeater-${config.dataSource?.tableName}` }, ); const unsubscribeSplitPanel = v2EventBus.subscribe( @@ -846,13 +858,13 @@ export const V2Repeater: React.FC = ({ } as CustomEvent; handleSplitPanelDataTransfer(fakeEvent); }, - { componentId: `v2-repeater-${config.dataSource?.tableName}` } + { componentId: `v2-repeater-${config.dataSource?.tableName}` }, ); // 레거시 이벤트도 계속 지원 (점진적 마이그레이션) window.addEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener); window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener); - + return () => { unsubscribeComponent(); unsubscribeSplitPanel(); @@ -928,11 +940,7 @@ V2Repeater.displayName = "V2Repeater"; // V2ErrorBoundary로 래핑된 안전한 버전 export export const SafeV2Repeater: React.FC = (props) => { return ( - + ); diff --git a/frontend/lib/api/numberingRule.ts b/frontend/lib/api/numberingRule.ts index 0800e752..b0ec38e2 100644 --- a/frontend/lib/api/numberingRule.ts +++ b/frontend/lib/api/numberingRule.ts @@ -26,13 +26,9 @@ export async function getNumberingRules(): Promise> { +export async function getAvailableNumberingRules(menuObjid?: number): Promise> { try { - const url = menuObjid - ? `/numbering-rules/available/${menuObjid}` - : "/numbering-rules/available"; + const url = menuObjid ? `/numbering-rules/available/${menuObjid}` : "/numbering-rules/available"; const response = await apiClient.get(url); return response.data; } catch (error: any) { @@ -46,7 +42,7 @@ export async function getAvailableNumberingRules( * @returns 해당 테이블의 채번 규칙 목록 */ export async function getAvailableNumberingRulesForScreen( - tableName: string + tableName: string, ): Promise> { try { const response = await apiClient.get("/numbering-rules/available-for-screen", { @@ -70,9 +66,7 @@ export async function getNumberingRuleById(ruleId: string): Promise> { +export async function createNumberingRule(config: NumberingRuleConfig): Promise> { try { const response = await apiClient.post("/numbering-rules", config); return response.data; @@ -83,7 +77,7 @@ export async function createNumberingRule( export async function updateNumberingRule( ruleId: string, - config: Partial + config: Partial, ): Promise> { try { const response = await apiClient.put(`/numbering-rules/${ruleId}`, config); @@ -110,7 +104,7 @@ export async function deleteNumberingRule(ruleId: string): Promise + formData?: Record, ): Promise> { // ruleId 유효성 검사 if (!ruleId || ruleId === "undefined" || ruleId === "null") { @@ -127,11 +121,8 @@ export async function previewNumberingCode( return response.data; } catch (error: unknown) { const err = error as { response?: { data?: { error?: string; message?: string } }; message?: string }; - const errorMessage = - err.response?.data?.error || - err.response?.data?.message || - err.message || - "코드 미리보기 실패"; + const errorMessage = + err.response?.data?.error || err.response?.data?.message || err.message || "코드 미리보기 실패"; return { success: false, error: errorMessage }; } } @@ -146,7 +137,7 @@ export async function previewNumberingCode( export async function allocateNumberingCode( ruleId: string, userInputCode?: string, - formData?: Record + formData?: Record, ): Promise> { try { const response = await apiClient.post(`/numbering-rules/${ruleId}/allocate`, { @@ -162,9 +153,7 @@ export async function allocateNumberingCode( /** * @deprecated 기존 generateNumberingCode는 previewNumberingCode를 사용하세요 */ -export async function generateNumberingCode( - ruleId: string -): Promise> { +export async function generateNumberingCode(ruleId: string): Promise> { console.warn("generateNumberingCode는 deprecated. previewNumberingCode 사용 권장"); return previewNumberingCode(ruleId); } @@ -188,13 +177,9 @@ export async function resetSequence(ruleId: string): Promise> * numbering_rules 테이블 사용 * @param menuObjid 메뉴 OBJID (선택) - 필터링용 */ -export async function getNumberingRulesFromTest( - menuObjid?: number -): Promise> { +export async function getNumberingRulesFromTest(menuObjid?: number): Promise> { try { - const url = menuObjid - ? `/numbering-rules/test/list/${menuObjid}` - : "/numbering-rules/test/list"; + const url = menuObjid ? `/numbering-rules/test/list/${menuObjid}` : "/numbering-rules/test/list"; const response = await apiClient.get(url); return response.data; } catch (error: any) { @@ -211,7 +196,7 @@ export async function getNumberingRulesFromTest( */ export async function getNumberingRuleByColumn( tableName: string, - columnName: string + columnName: string, ): Promise> { try { const response = await apiClient.get("/numbering-rules/test/by-column", { @@ -230,9 +215,7 @@ export async function getNumberingRuleByColumn( * [테스트] 테스트 테이블에 채번규칙 저장 * numbering_rules 테이블 사용 */ -export async function saveNumberingRuleToTest( - config: NumberingRuleConfig -): Promise> { +export async function saveNumberingRuleToTest(config: NumberingRuleConfig): Promise> { try { const response = await apiClient.post("/numbering-rules/test/save", config); return response.data; @@ -248,9 +231,7 @@ export async function saveNumberingRuleToTest( * [테스트] 테스트 테이블에서 채번규칙 삭제 * numbering_rules 테이블 사용 */ -export async function deleteNumberingRuleFromTest( - ruleId: string -): Promise> { +export async function deleteNumberingRuleFromTest(ruleId: string): Promise> { try { const response = await apiClient.delete(`/numbering-rules/test/${ruleId}`); return response.data; @@ -270,7 +251,7 @@ export async function getNumberingRuleByColumnWithCategory( tableName: string, columnName: string, categoryColumn?: string, - categoryValueId?: number + categoryValueId?: number, ): Promise> { try { const response = await apiClient.get("/numbering-rules/test/by-column-with-category", { @@ -290,7 +271,7 @@ export async function getNumberingRuleByColumnWithCategory( */ export async function getRulesByTableColumn( tableName: string, - columnName: string + columnName: string, ): Promise> { try { const response = await apiClient.get("/numbering-rules/test/rules-by-table-column", { @@ -304,4 +285,3 @@ export async function getRulesByTableColumn( }; } } - diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 0cfdd542..6765e6c7 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -74,13 +74,13 @@ export function RepeatScreenModalComponent({ const showCardTitle = componentConfig?.showCardTitle ?? true; const cardTitle = componentConfig?.cardTitle || "카드 {index}"; const grouping = componentConfig?.grouping; - + // 🆕 v3: 자유 레이아웃 const contentRows = componentConfig?.contentRows || []; - + // 🆕 v3.1: Footer 설정 const footerConfig = componentConfig?.footerConfig; - + // (레거시 호환) const cardLayout = componentConfig?.cardLayout || []; const cardMode = componentConfig?.cardMode || "simple"; @@ -93,7 +93,7 @@ export function RepeatScreenModalComponent({ const [isLoading, setIsLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [isSaving, setIsSaving] = useState(false); - + // 🆕 v3.1: 외부 테이블 데이터 (테이블 행별로 관리) const [externalTableData, setExternalTableData] = useState>({}); // 🆕 v3.1: 삭제 확인 다이얼로그 @@ -108,12 +108,12 @@ export function RepeatScreenModalComponent({ useEffect(() => { const handleTriggerSave = async (event: Event) => { if (!(event instanceof CustomEvent)) return; - + console.log("[RepeatScreenModal] triggerRepeatScreenModalSave 이벤트 수신"); - + try { setIsSaving(true); - + // 기존 데이터 저장 if (cardMode === "withTable") { await saveGroupedData(); @@ -128,24 +128,28 @@ export function RepeatScreenModalComponent({ await processSyncSaves(); console.log("[RepeatScreenModal] 외부 트리거 저장 완료"); - + // 저장 완료 이벤트 발생 - window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", { - detail: { success: true } - })); - + window.dispatchEvent( + new CustomEvent("repeatScreenModalSaveComplete", { + detail: { success: true }, + }), + ); + // 성공 콜백 실행 if (event.detail?.onSuccess) { event.detail.onSuccess(); } } catch (error: any) { console.error("[RepeatScreenModal] 외부 트리거 저장 실패:", error); - + // 저장 실패 이벤트 발생 - window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", { - detail: { success: false, error: error.message } - })); - + window.dispatchEvent( + new CustomEvent("repeatScreenModalSaveComplete", { + detail: { success: false, error: error.message }, + }), + ); + // 실패 콜백 실행 if (event.detail?.onError) { event.detail.onError(error); @@ -177,7 +181,7 @@ export function RepeatScreenModalComponent({ // key 형식: cardId-contentRowId const keyParts = key.split("-"); const cardId = keyParts.slice(0, -1).join("-"); // contentRowId를 제외한 나머지가 cardId - + // contentRow 찾기 const contentRow = contentRows.find((r) => key.includes(r.id)); if (!contentRow?.tableDataSource?.enabled) continue; @@ -187,24 +191,22 @@ export function RepeatScreenModalComponent({ const representativeData = card?._representativeData || {}; const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable; - + // dirty 행 또는 새로운 행 필터링 (삭제된 행 제외) // 🆕 v3.13: _isNew 행도 포함 (새로 추가된 행은 _isDirty가 없을 수 있음) const dirtyRows = rows.filter((row) => (row._isDirty || row._isNew) && !row._isDeleted); - + console.log(`[RepeatScreenModal] beforeFormSave - ${targetTable} 행 필터링:`, { totalRows: rows.length, dirtyRows: dirtyRows.length, - rowDetails: rows.map(r => ({ _isDirty: r._isDirty, _isNew: r._isNew, _isDeleted: r._isDeleted })) + rowDetails: rows.map((r) => ({ _isDirty: r._isDirty, _isNew: r._isNew, _isDeleted: r._isDeleted })), }); - + if (dirtyRows.length === 0) continue; // 저장할 필드만 추출 - const editableFields = (contentRow.tableColumns || []) - .filter((col) => col.editable) - .map((col) => col.field); - + const editableFields = (contentRow.tableColumns || []).filter((col) => col.editable).map((col) => col.field); + // 🆕 v3.13: joinConditions에서 sourceKey (저장 대상 테이블의 FK 컬럼) 추출 const joinConditions = contentRow.tableDataSource.joinConditions || []; const joinKeys = joinConditions.map((cond) => cond.sourceKey); @@ -217,14 +219,14 @@ export function RepeatScreenModalComponent({ for (const row of dirtyRows) { const saveData: Record = {}; - + // 허용된 필드만 포함 for (const field of allowedFields) { if (row[field] !== undefined) { saveData[field] = row[field]; } } - + // 🆕 v3.13: joinConditions를 사용하여 FK 값 자동 채우기 // 예: sales_order_id (sourceKey) = card의 id (targetKey) for (const joinCond of joinConditions) { @@ -232,14 +234,16 @@ export function RepeatScreenModalComponent({ // sourceKey가 저장 데이터에 없거나 null인 경우, 카드의 대표 데이터에서 targetKey 값을 가져옴 if (!saveData[sourceKey] && representativeData[targetKey] !== undefined) { saveData[sourceKey] = representativeData[targetKey]; - console.log(`[RepeatScreenModal] beforeFormSave - FK 자동 채우기: ${sourceKey} = ${representativeData[targetKey]} (from card.${targetKey})`); + console.log( + `[RepeatScreenModal] beforeFormSave - FK 자동 채우기: ${sourceKey} = ${representativeData[targetKey]} (from card.${targetKey})`, + ); } } - + // _isNew 플래그 유지 saveData._isNew = row._isNew; saveData._targetTable = targetTable; - + // 기존 레코드의 경우 id 포함 if (!row._isNew && row._originalData?.id) { saveData.id = row._originalData.id; @@ -333,7 +337,7 @@ export function RepeatScreenModalComponent({ // formData에서 선택된 행 ID 가져오기 let selectedIds: any[] = []; - + if (formData) { // 1. 명시적으로 설정된 filterField 확인 if (dataSource.filterField) { @@ -342,10 +346,10 @@ export function RepeatScreenModalComponent({ selectedIds = Array.isArray(filterValue) ? filterValue : [filterValue]; } } - + // 2. 일반적인 선택 필드 확인 (fallback) if (selectedIds.length === 0) { - const commonFields = ['selectedRows', 'selectedIds', 'checkedRows', 'checkedIds', 'ids']; + const commonFields = ["selectedRows", "selectedIds", "checkedRows", "checkedIds", "ids"]; for (const field of commonFields) { if (formData[field]) { const value = formData[field]; @@ -355,7 +359,7 @@ export function RepeatScreenModalComponent({ } } } - + // 3. formData에 id가 있으면 단일 행 if (selectedIds.length === 0 && formData.id) { selectedIds = [formData.id]; @@ -412,10 +416,10 @@ export function RepeatScreenModalComponent({ // 🆕 v3: contentRows가 있으면 새로운 방식 사용 const useNewLayout = contentRows && contentRows.length > 0; - + // 그룹핑 모드 확인 (groupByField가 없어도 enabled면 그룹핑 모드로 처리) const useGrouping = grouping?.enabled; - + if (useGrouping) { // 그룹핑 모드 const grouped = processGroupedData(loadedData, grouping); @@ -428,7 +432,7 @@ export function RepeatScreenModalComponent({ _originalData: { ...row }, _isDirty: false, ...(await loadCardData(row)), - })) + })), ); setCardsData(initialCards); } @@ -448,7 +452,7 @@ export function RepeatScreenModalComponent({ const loadExternalTableData = async () => { // contentRows에서 외부 테이블 데이터 소스가 있는 table 타입 행 찾기 const tableRowsWithExternalSource = contentRows.filter( - (row) => row.type === "table" && row.tableDataSource?.enabled + (row) => row.type === "table" && row.tableDataSource?.enabled, ); if (tableRowsWithExternalSource.length === 0) return; @@ -473,7 +477,7 @@ export function RepeatScreenModalComponent({ // 숫자형 ID인 경우 숫자로 변환 (문자열 '189' → 숫자 189) // 백엔드에서 entity 타입 컬럼 검색 시 문자열이면 ILIKE 검색을 수행하므로 // 정확한 ID 매칭을 위해 숫자로 변환해야 함 - if (condition.sourceKey.endsWith('_id') || condition.sourceKey === 'id') { + if (condition.sourceKey.endsWith("_id") || condition.sourceKey === "id") { const numValue = Number(refValue); if (!isNaN(numValue)) { refValue = numValue; @@ -497,24 +501,21 @@ export function RepeatScreenModalComponent({ }); // API 호출 - 메인 테이블 데이터 - const response = await apiClient.post( - `/table-management/tables/${dataSourceConfig.sourceTable}/data`, - { - search: filters, - page: 1, - size: dataSourceConfig.limit || 100, - sort: dataSourceConfig.orderBy - ? { - column: dataSourceConfig.orderBy.column, - direction: dataSourceConfig.orderBy.direction, - } - : undefined, - } - ); + const response = await apiClient.post(`/table-management/tables/${dataSourceConfig.sourceTable}/data`, { + search: filters, + page: 1, + size: dataSourceConfig.limit || 100, + sort: dataSourceConfig.orderBy + ? { + column: dataSourceConfig.orderBy.column, + direction: dataSourceConfig.orderBy.direction, + } + : undefined, + }); if (response.data.success && response.data.data?.data) { let tableData = response.data.data.data; - + console.log(`[RepeatScreenModal] 소스 테이블 데이터 로드 완료:`, { sourceTable: dataSourceConfig.sourceTable, rowCount: tableData.length, @@ -538,7 +539,7 @@ export function RepeatScreenModalComponent({ // 🆕 v3.4: 필터 조건 적용 if (dataSourceConfig.filterConfig?.enabled) { const { filterField, filterType, referenceField, referenceSource } = dataSourceConfig.filterConfig; - + // 비교 값 가져오기 let referenceValue: any; if (referenceSource === "formData") { @@ -558,8 +559,10 @@ export function RepeatScreenModalComponent({ return rowValue !== referenceValue; } }); - - console.log(`[RepeatScreenModal] 필터 적용: ${filterField} ${filterType} ${referenceValue}, 결과: ${tableData.length}건`); + + console.log( + `[RepeatScreenModal] 필터 적용: ${filterField} ${filterType} ${referenceValue}, 결과: ${tableData.length}건`, + ); } } @@ -573,7 +576,7 @@ export function RepeatScreenModalComponent({ _isDeleted: false, ...row, })); - + // 디버그: 저장된 외부 테이블 데이터 확인 console.log(`[RepeatScreenModal] 외부 테이블 데이터 저장:`, { key, @@ -595,17 +598,17 @@ export function RepeatScreenModalComponent({ if (prevKeys === newKeys) { // 키가 같으면 데이터 내용 비교 const isSame = Object.keys(newExternalData).every( - (key) => JSON.stringify(prev[key]) === JSON.stringify(newExternalData[key]) + (key) => JSON.stringify(prev[key]) === JSON.stringify(newExternalData[key]), ); if (isSame) return prev; } - + // 🆕 v3.2: 외부 테이블 데이터 로드 후 집계 재계산 // 비동기적으로 처리하여 무한 루프 방지 setTimeout(() => { recalculateAggregationsWithExternalData(newExternalData); }, 0); - + return newExternalData; }); }; @@ -617,7 +620,7 @@ export function RepeatScreenModalComponent({ // 🆕 v3.3: 추가 조인 테이블 데이터 로드 및 병합 const loadAndMergeJoinData = async ( mainData: any[], - additionalJoins: { id: string; joinTable: string; joinType: string; sourceKey: string; targetKey: string }[] + additionalJoins: { id: string; joinTable: string; joinType: string; sourceKey: string; targetKey: string }[], ): Promise => { if (mainData.length === 0) return mainData; @@ -627,23 +630,20 @@ export function RepeatScreenModalComponent({ // 메인 데이터에서 조인 키 값들 추출 const joinKeyValues = [...new Set(mainData.map((row) => row[joinConfig.sourceKey]).filter(Boolean))]; - + if (joinKeyValues.length === 0) continue; try { // 조인 테이블 데이터 조회 - const joinResponse = await apiClient.post( - `/table-management/tables/${joinConfig.joinTable}/data`, - { - search: { [joinConfig.targetKey]: joinKeyValues }, - page: 1, - size: 1000, // 충분히 큰 값 - } - ); + const joinResponse = await apiClient.post(`/table-management/tables/${joinConfig.joinTable}/data`, { + search: { [joinConfig.targetKey]: joinKeyValues }, + page: 1, + size: 1000, // 충분히 큰 값 + }); if (joinResponse.data.success && joinResponse.data.data?.data) { const joinData = joinResponse.data.data.data; - + // 조인 데이터를 맵으로 변환 (빠른 조회를 위해) const joinDataMap = new Map(); for (const joinRow of joinData) { @@ -654,7 +654,7 @@ export function RepeatScreenModalComponent({ mainData = mainData.map((row) => { const joinKey = row[joinConfig.sourceKey]; const joinRow = joinDataMap.get(joinKey); - + if (joinRow) { // 조인 테이블의 컬럼들을 메인 데이터에 추가 (접두사 없이) const mergedRow = { ...row }; @@ -700,7 +700,7 @@ export function RepeatScreenModalComponent({ // contentRows에서 외부 테이블 데이터 소스가 있는 모든 table 타입 행 찾기 const tableRowsWithExternalSource = contentRows.filter( - (row) => row.type === "table" && row.tableDataSource?.enabled + (row) => row.type === "table" && row.tableDataSource?.enabled, ); if (tableRowsWithExternalSource.length === 0) return; @@ -710,10 +710,10 @@ export function RepeatScreenModalComponent({ // 🆕 v3.11: 테이블 행 ID별로 외부 데이터를 구분하여 저장 const externalRowsByTableId: Record = {}; const allExternalRows: any[] = []; - + for (const tableRow of tableRowsWithExternalSource) { const key = `${card._cardId}-${tableRow.id}`; - // 🆕 v3.7: 삭제된 행은 집계에서 제외 + // 🆕 v3.7: 삭제된 행은 집계에서 제외 const rows = (extData[key] || []).filter((row) => !row._isDeleted); externalRowsByTableId[tableRow.id] = rows; allExternalRows.push(...rows); @@ -721,30 +721,31 @@ export function RepeatScreenModalComponent({ // 집계 재계산 const newAggregations: Record = {}; - + grouping.aggregations!.forEach((agg) => { const sourceType = agg.sourceType || "column"; - + if (sourceType === "column") { const sourceTable = agg.sourceTable || dataSource?.sourceTable; const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; - + if (isExternalTable) { // 외부 테이블 집계 newAggregations[agg.resultField] = calculateColumnAggregation( - allExternalRows, - agg.sourceField || "", - agg.type || "sum" + allExternalRows, + agg.sourceField || "", + agg.type || "sum", ); } else { // 기본 테이블 집계 (기존 값 유지) - newAggregations[agg.resultField] = card._aggregations[agg.resultField] || + newAggregations[agg.resultField] = + card._aggregations[agg.resultField] || calculateColumnAggregation(card._rows, agg.sourceField || "", agg.type || "sum"); } } else if (sourceType === "formula" && agg.formula) { // 🆕 v3.11: externalTableRefs 기반으로 필터링된 외부 데이터 사용 let filteredExternalRows: any[]; - + if (agg.externalTableRefs && agg.externalTableRefs.length > 0) { // 특정 테이블만 참조 filteredExternalRows = []; @@ -757,14 +758,14 @@ export function RepeatScreenModalComponent({ // 모든 외부 테이블 데이터 사용 (기존 동작) filteredExternalRows = allExternalRows; } - + // 가상 집계 (연산식) - 외부 테이블 데이터 포함하여 재계산 newAggregations[agg.resultField] = evaluateFormulaWithContext( agg.formula, card._representativeData, card._rows, filteredExternalRows, - newAggregations // 이전 집계 결과 참조 + newAggregations, // 이전 집계 결과 참조 ); } }); @@ -854,16 +855,16 @@ export function RepeatScreenModalComponent({ targetColumn: rowNumbering.targetColumn, numberingRuleId: rowNumbering.numberingRuleId, }); - + // 채번 API 호출 (allocate: 실제 시퀀스 증가) // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); const userInputCode = newRowData[rowNumbering.targetColumn] as string; const response = await allocateNumberingCode(rowNumbering.numberingRuleId, userInputCode, newRowData); - + if (response.success && response.data) { newRowData[rowNumbering.targetColumn] = response.data.generatedCode; - + console.log("[RepeatScreenModal] 자동 채번 완료:", { column: rowNumbering.targetColumn, generatedCode: response.data.generatedCode, @@ -888,12 +889,12 @@ export function RepeatScreenModalComponent({ ...prev, [key]: [...(prev[key] || []), newRowData], }; - + // 🆕 v3.5: 새 행 추가 시 집계 재계산 setTimeout(() => { recalculateAggregationsWithExternalData(newData); }, 0); - + return newData; }); }; @@ -902,7 +903,7 @@ export function RepeatScreenModalComponent({ const saveTableAreaData = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => { const key = `${cardId}-${contentRowId}`; const rows = externalTableData[key] || []; - + console.log("[RepeatScreenModal] saveTableAreaData 시작:", { key, rowsCount: rows.length, @@ -910,7 +911,7 @@ export function RepeatScreenModalComponent({ tableDataSource: contentRow?.tableDataSource, tableCrud: contentRow?.tableCrud, }); - + if (!contentRow?.tableDataSource?.enabled) { console.warn("[RepeatScreenModal] 외부 테이블 데이터 소스가 설정되지 않음"); return { success: false, message: "데이터 소스가 설정되지 않았습니다." }; @@ -922,7 +923,7 @@ export function RepeatScreenModalComponent({ console.log("[RepeatScreenModal] 저장 대상:", { targetTable, dirtyRowsCount: dirtyRows.length, - dirtyRows: dirtyRows.map(r => ({ _isNew: r._isNew, _isDirty: r._isDirty, data: r })), + dirtyRows: dirtyRows.map((r) => ({ _isNew: r._isNew, _isDirty: r._isDirty, data: r })), }); if (dirtyRows.length === 0) { @@ -934,7 +935,7 @@ export function RepeatScreenModalComponent({ // 🆕 v3.6: editable한 컬럼 + 조인 키만 추출 (읽기 전용 컬럼은 제외) const allowedFields = new Set(); - + // tableColumns에서 editable: true인 필드만 추가 (읽기 전용 컬럼 제외) if (contentRow.tableColumns) { contentRow.tableColumns.forEach((col) => { @@ -945,20 +946,23 @@ export function RepeatScreenModalComponent({ } }); } - + // 조인 조건의 sourceKey 추가 (예: sales_order_id) - 이건 항상 필요 if (contentRow.tableDataSource?.joinConditions) { contentRow.tableDataSource.joinConditions.forEach((cond) => { if (cond.sourceKey) allowedFields.add(cond.sourceKey); }); } - + console.log("[RepeatScreenModal] 저장 허용 필드 (editable + 조인키):", Array.from(allowedFields)); - console.log("[RepeatScreenModal] tableColumns 정보:", contentRow.tableColumns?.map(c => ({ - field: c.field, - editable: c.editable, - inputType: c.inputType - }))); + console.log( + "[RepeatScreenModal] tableColumns 정보:", + contentRow.tableColumns?.map((c) => ({ + field: c.field, + editable: c.editable, + inputType: c.inputType, + })), + ); // 삭제할 행 (기존 데이터 중 _isDeleted가 true인 것) const deletedRows = dirtyRows.filter((row) => row._isDeleted && row._originalData?.id); @@ -971,25 +975,30 @@ export function RepeatScreenModalComponent({ // 🆕 v3.7: 삭제 처리 (배열 형태로 body에 전달) for (const row of deletedRows) { const deleteId = row._originalData.id; - console.log(`[RepeatScreenModal] DELETE 요청: /table-management/tables/${targetTable}/delete`, [{ id: deleteId }]); + console.log(`[RepeatScreenModal] DELETE 요청: /table-management/tables/${targetTable}/delete`, [ + { id: deleteId }, + ]); savePromises.push( - apiClient.request({ - method: "DELETE", - url: `/table-management/tables/${targetTable}/delete`, - data: [{ id: deleteId }], - }).then((res) => { - console.log("[RepeatScreenModal] DELETE 응답:", res.data); - return { type: "delete", id: deleteId }; - }).catch((err) => { - console.error("[RepeatScreenModal] DELETE 실패:", err.response?.data || err.message); - throw err; - }) + apiClient + .request({ + method: "DELETE", + url: `/table-management/tables/${targetTable}/delete`, + data: [{ id: deleteId }], + }) + .then((res) => { + console.log("[RepeatScreenModal] DELETE 응답:", res.data); + return { type: "delete", id: deleteId }; + }) + .catch((err) => { + console.error("[RepeatScreenModal] DELETE 실패:", err.response?.data || err.message); + throw err; + }), ); } for (const row of rowsToSave) { const { _rowId, _originalData, _isDirty, _isNew, _isDeleted, ...allData } = row; - + // 허용된 필드만 필터링 const dataToSave: Record = {}; for (const field of allowedFields) { @@ -1009,16 +1018,19 @@ export function RepeatScreenModalComponent({ // INSERT - /add 엔드포인트 사용 console.log(`[RepeatScreenModal] INSERT 요청: /table-management/tables/${targetTable}/add`, dataToSave); savePromises.push( - apiClient.post(`/table-management/tables/${targetTable}/add`, dataToSave).then((res) => { - console.log("[RepeatScreenModal] INSERT 응답:", res.data); - if (res.data?.data?.id) { - savedIds.push(res.data.data.id); - } - return res; - }).catch((err) => { - console.error("[RepeatScreenModal] INSERT 실패:", err.response?.data || err.message); - throw err; - }) + apiClient + .post(`/table-management/tables/${targetTable}/add`, dataToSave) + .then((res) => { + console.log("[RepeatScreenModal] INSERT 응답:", res.data); + if (res.data?.data?.id) { + savedIds.push(res.data.data.id); + } + return res; + }) + .catch((err) => { + console.error("[RepeatScreenModal] INSERT 실패:", err.response?.data || err.message); + throw err; + }), ); } else if (_originalData?.id) { // UPDATE - /edit 엔드포인트 사용 (originalData와 updatedData 형식) @@ -1028,14 +1040,17 @@ export function RepeatScreenModalComponent({ }; console.log(`[RepeatScreenModal] UPDATE 요청: /table-management/tables/${targetTable}/edit`, updatePayload); savePromises.push( - apiClient.put(`/table-management/tables/${targetTable}/edit`, updatePayload).then((res) => { - console.log("[RepeatScreenModal] UPDATE 응답:", res.data); - savedIds.push(_originalData.id); - return res; - }).catch((err) => { - console.error("[RepeatScreenModal] UPDATE 실패:", err.response?.data || err.message); - throw err; - }) + apiClient + .put(`/table-management/tables/${targetTable}/edit`, updatePayload) + .then((res) => { + console.log("[RepeatScreenModal] UPDATE 응답:", res.data); + savedIds.push(_originalData.id); + return res; + }) + .catch((err) => { + console.error("[RepeatScreenModal] UPDATE 실패:", err.response?.data || err.message); + throw err; + }), ); } } @@ -1055,7 +1070,15 @@ export function RepeatScreenModalComponent({ _isDirty: false, _isNew: false, _isEditing: false, // 🆕 v3.8: 수정 모드 해제 - _originalData: { ...row, _rowId: undefined, _originalData: undefined, _isDirty: undefined, _isNew: undefined, _isDeleted: undefined, _isEditing: undefined }, + _originalData: { + ...row, + _rowId: undefined, + _originalData: undefined, + _isDirty: undefined, + _isNew: undefined, + _isDeleted: undefined, + _isEditing: undefined, + }, })); } return updated; @@ -1063,9 +1086,8 @@ export function RepeatScreenModalComponent({ const savedCount = rowsToSave.length; const deletedCount = deletedRows.length; - const message = deletedCount > 0 - ? `${savedCount}건 저장, ${deletedCount}건 삭제 완료` - : `${savedCount}건 저장 완료`; + const message = + deletedCount > 0 ? `${savedCount}건 저장, ${deletedCount}건 삭제 완료` : `${savedCount}건 저장 완료`; return { success: true, message, savedCount, deletedCount, savedIds }; } catch (error: any) { @@ -1081,7 +1103,7 @@ export function RepeatScreenModalComponent({ const result = await saveTableAreaData(cardId, contentRowId, contentRow); if (result.success) { console.log("[RepeatScreenModal] 테이블 영역 저장 성공:", result); - + // 🆕 v3.9: 집계 저장 설정이 있는 경우 연관 테이블 동기화 const card = groupedCardsData.find((c) => c._cardId === cardId); if (card && grouping?.aggregations) { @@ -1103,16 +1125,16 @@ export function RepeatScreenModalComponent({ for (const agg of grouping.aggregations) { const saveConfig = agg.saveConfig; - + // 저장 설정이 없거나 비활성화된 경우 스킵 if (!saveConfig?.enabled) continue; - + // 자동 저장이 아닌 경우, 레이아웃에 연결되어 있는지 확인 필요 // (현재는 자동 저장과 동일하게 처리 - 추후 레이아웃 연결 체크 추가 가능) - + // 집계 결과 값 가져오기 const aggregatedValue = card._aggregations[agg.resultField]; - + if (aggregatedValue === undefined) { console.warn(`[RepeatScreenModal] 집계 결과 없음: ${agg.resultField}`); continue; @@ -1120,7 +1142,7 @@ export function RepeatScreenModalComponent({ // 조인 키로 대상 레코드 식별 const sourceKeyValue = card._representativeData[saveConfig.joinKey.sourceField]; - + if (!sourceKeyValue) { console.warn(`[RepeatScreenModal] 조인 키 값 없음: ${saveConfig.joinKey.sourceField}`); continue; @@ -1137,22 +1159,25 @@ export function RepeatScreenModalComponent({ // UPDATE API 호출 const updatePayload = { originalData: { [saveConfig.joinKey.targetField]: sourceKeyValue }, - updatedData: { + updatedData: { [saveConfig.targetColumn]: aggregatedValue, [saveConfig.joinKey.targetField]: sourceKeyValue, }, }; savePromises.push( - apiClient.put(`/table-management/tables/${saveConfig.targetTable}/edit`, updatePayload) + apiClient + .put(`/table-management/tables/${saveConfig.targetTable}/edit`, updatePayload) .then((res) => { - console.log(`[RepeatScreenModal] 집계 저장 성공: ${agg.resultField} -> ${saveConfig.targetTable}.${saveConfig.targetColumn}`); + console.log( + `[RepeatScreenModal] 집계 저장 성공: ${agg.resultField} -> ${saveConfig.targetTable}.${saveConfig.targetColumn}`, + ); return res; }) .catch((err) => { console.error(`[RepeatScreenModal] 집계 저장 실패: ${agg.resultField}`, err.response?.data || err.message); throw err; - }) + }), ); } @@ -1167,7 +1192,12 @@ export function RepeatScreenModalComponent({ }; // 🆕 v3.1: 외부 테이블 행 삭제 요청 - const handleDeleteExternalRowRequest = (cardId: string, rowId: string, contentRowId: string, contentRow: CardContentRowConfig) => { + const handleDeleteExternalRowRequest = ( + cardId: string, + rowId: string, + contentRowId: string, + contentRow: CardContentRowConfig, + ) => { if (contentRow.tableCrud?.deleteConfirm?.enabled !== false) { // 삭제 확인 팝업 표시 setPendingDeleteInfo({ cardId, rowId, contentRowId }); @@ -1196,7 +1226,7 @@ export function RepeatScreenModalComponent({ } console.log(`[RepeatScreenModal] DELETE API 호출: ${targetTable}, id=${targetRow._originalData.id}`); - + // 백엔드는 배열 형태의 데이터를 기대함 await apiClient.request({ method: "DELETE", @@ -1207,19 +1237,19 @@ export function RepeatScreenModalComponent({ console.log(`[RepeatScreenModal] DELETE 성공: ${targetTable}, id=${targetRow._originalData.id}`); // 성공 시 UI에서 완전히 제거 - setExternalTableData((prev) => { - const newData = { - ...prev, + setExternalTableData((prev) => { + const newData = { + ...prev, [key]: prev[key].filter((row) => row._rowId !== rowId), - }; - + }; + // 행 삭제 시 집계 재계산 - setTimeout(() => { - recalculateAggregationsWithExternalData(newData); - }, 0); - - return newData; - }); + setTimeout(() => { + recalculateAggregationsWithExternalData(newData); + }, 0); + + return newData; + }); } catch (error: any) { console.error(`[RepeatScreenModal] DELETE 실패:`, error.response?.data || error.message); // 에러 시에도 다이얼로그 닫기 @@ -1253,16 +1283,14 @@ export function RepeatScreenModalComponent({ const newData = { ...prev, [key]: (prev[key] || []).map((row) => - row._rowId === rowId - ? { ...row, _isDeleted: false, _isDirty: true } - : row + row._rowId === rowId ? { ...row, _isDeleted: false, _isDirty: true } : row, ), }; - + setTimeout(() => { recalculateAggregationsWithExternalData(newData); }, 0); - + return newData; }); }; @@ -1272,11 +1300,7 @@ export function RepeatScreenModalComponent({ const key = `${cardId}-${contentRowId}`; setExternalTableData((prev) => ({ ...prev, - [key]: (prev[key] || []).map((row) => - row._rowId === rowId - ? { ...row, _isEditing: true } - : row - ), + [key]: (prev[key] || []).map((row) => (row._rowId === rowId ? { ...row, _isEditing: true } : row)), })); }; @@ -1287,39 +1311,45 @@ export function RepeatScreenModalComponent({ ...prev, [key]: (prev[key] || []).map((row) => row._rowId === rowId - ? { - ...row._originalData, - _rowId: row._rowId, + ? { + ...row._originalData, + _rowId: row._rowId, _originalData: row._originalData, - _isEditing: false, + _isEditing: false, _isDirty: false, _isNew: false, _isDeleted: false, } - : row + : row, ), })); }; // 🆕 v3.1: 외부 테이블 행 데이터 변경 - const handleExternalRowDataChange = (cardId: string, contentRowId: string, rowId: string, field: string, value: any) => { + const handleExternalRowDataChange = ( + cardId: string, + contentRowId: string, + rowId: string, + field: string, + value: any, + ) => { const key = `${cardId}-${contentRowId}`; - + // 데이터 업데이트 setExternalTableData((prev) => { const newData = { ...prev, [key]: (prev[key] || []).map((row) => - row._rowId === rowId ? { ...row, [field]: value, _isDirty: true } : row + row._rowId === rowId ? { ...row, [field]: value, _isDirty: true } : row, ), }; - + // 🆕 v3.5: 데이터 변경 시 집계 실시간 재계산 // setTimeout으로 비동기 처리하여 상태 업데이트 후 재계산 setTimeout(() => { recalculateAggregationsWithExternalData(newData); }, 0); - + return newData; }); }; @@ -1372,18 +1402,18 @@ export function RepeatScreenModalComponent({ if (groupingConfig.aggregations) { groupingConfig.aggregations.forEach((agg) => { const sourceType = agg.sourceType || "column"; - + if (sourceType === "column") { // 컬럼 집계 (기본 테이블만 - 외부 테이블은 나중에 처리) const sourceTable = agg.sourceTable || dataSource?.sourceTable; const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; - + if (!isExternalTable) { // 기본 테이블 집계 aggregations[agg.resultField] = calculateColumnAggregation( - rows, - agg.sourceField || "", - agg.type || "sum" + rows, + agg.sourceField || "", + agg.type || "sum", ); } else { // 외부 테이블 집계는 나중에 계산 (placeholder) @@ -1398,7 +1428,7 @@ export function RepeatScreenModalComponent({ representativeData, rows, [], // 외부 테이블 데이터 없음 - aggregations // 이전 집계 결과 참조 + aggregations, // 이전 집계 결과 참조 ); } else { aggregations[agg.resultField] = 0; @@ -1427,9 +1457,9 @@ export function RepeatScreenModalComponent({ // 집계 계산 (컬럼 집계용) const calculateColumnAggregation = ( - rows: any[], - sourceField: string, - type: "sum" | "count" | "avg" | "min" | "max" + rows: any[], + sourceField: string, + type: "sum" | "count" | "avg" | "min" | "max", ): number => { const values = rows.map((row) => Number(row[sourceField]) || 0); @@ -1455,7 +1485,7 @@ export function RepeatScreenModalComponent({ cardRows: any[], // 기본 테이블 행들 externalRows: any[], // 외부 테이블 행들 previousAggregations: Record, // 이전 집계 결과들 - representativeData: Record // 카드 대표 데이터 + representativeData: Record, // 카드 대표 데이터 ): number => { const sourceType = agg.sourceType || "column"; @@ -1463,26 +1493,16 @@ export function RepeatScreenModalComponent({ // 컬럼 집계 const sourceTable = agg.sourceTable || dataSource?.sourceTable; const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; - + // 외부 테이블인 경우 externalRows 사용, 아니면 cardRows 사용 const targetRows = isExternalTable ? externalRows : cardRows; - - return calculateColumnAggregation( - targetRows, - agg.sourceField || "", - agg.type || "sum" - ); + + return calculateColumnAggregation(targetRows, agg.sourceField || "", agg.type || "sum"); } else if (sourceType === "formula") { // 가상 집계 (연산식) if (!agg.formula) return 0; - - return evaluateFormulaWithContext( - agg.formula, - representativeData, - cardRows, - externalRows, - previousAggregations - ); + + return evaluateFormulaWithContext(agg.formula, representativeData, cardRows, externalRows, previousAggregations); } return 0; @@ -1491,7 +1511,7 @@ export function RepeatScreenModalComponent({ // 🆕 v3.1: 집계 표시값 계산 (formula, external 등 지원) const calculateAggregationDisplayValue = ( aggField: AggregationDisplayConfig, - card: GroupedCardData + card: GroupedCardData, ): number | string => { const sourceType = aggField.sourceType || "aggregation"; @@ -1526,7 +1546,7 @@ export function RepeatScreenModalComponent({ representativeData: Record, cardRows: any[], // 기본 테이블 행들 externalRows: any[], // 외부 테이블 행들 - previousAggregations: Record // 이전 집계 결과들 + previousAggregations: Record, // 이전 집계 결과들 ): number => { try { let expression = formula; @@ -1615,11 +1635,7 @@ export function RepeatScreenModalComponent({ }; // 레거시 호환: 기존 evaluateFormula 유지 - const evaluateFormula = ( - formula: string, - representativeData: Record, - rows?: any[] - ): number => { + const evaluateFormula = (formula: string, representativeData: Record, rows?: any[]): number => { return evaluateFormulaWithContext(formula, representativeData, rows || [], [], {}); }; @@ -1647,7 +1663,7 @@ export function RepeatScreenModalComponent({ } } } - + // 테이블 타입의 컬럼 처리 if (contentRow.type === "table" && contentRow.tableColumns) { for (const col of contentRow.tableColumns) { @@ -1680,7 +1696,7 @@ export function RepeatScreenModalComponent({ // Simple 모드: 카드 데이터 변경 const handleCardDataChange = (cardId: string, field: string, value: any) => { setCardsData((prev) => - prev.map((card) => (card._cardId === cardId ? { ...card, [field]: value, _isDirty: true } : card)) + prev.map((card) => (card._cardId === cardId ? { ...card, [field]: value, _isDirty: true } : card)), ); }; @@ -1691,7 +1707,7 @@ export function RepeatScreenModalComponent({ if (card._cardId !== cardId) return card; const updatedRows = card._rows.map((row) => - row._rowId === rowId ? { ...row, [field]: value, _isDirty: true } : row + row._rowId === rowId ? { ...row, [field]: value, _isDirty: true } : row, ); // 집계값 재계산 @@ -1707,7 +1723,7 @@ export function RepeatScreenModalComponent({ _rows: updatedRows, _aggregations: newAggregations, }; - }) + }), ); }; @@ -1763,7 +1779,7 @@ export function RepeatScreenModalComponent({ // key 형식: cardId-contentRowId const [cardId, contentRowId] = key.split("-").slice(0, 2); const contentRow = contentRows.find((r) => r.id === contentRowId || key.includes(r.id)); - + if (!contentRow?.tableDataSource?.enabled) continue; const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable; @@ -1774,13 +1790,13 @@ export function RepeatScreenModalComponent({ if (_isNew) { // INSERT - savePromises.push( - apiClient.post(`/table-management/tables/${targetTable}/data`, dataToSave).then(() => {}) - ); + savePromises.push(apiClient.post(`/table-management/tables/${targetTable}/data`, dataToSave).then(() => {})); } else if (_originalData?.id) { // UPDATE savePromises.push( - apiClient.put(`/table-management/tables/${targetTable}/data/${_originalData.id}`, dataToSave).then(() => {}) + apiClient + .put(`/table-management/tables/${targetTable}/data/${_originalData.id}`, dataToSave) + .then(() => {}), ); } } @@ -1796,7 +1812,13 @@ export function RepeatScreenModalComponent({ ...row, _isDirty: false, _isNew: false, - _originalData: { ...row, _rowId: undefined, _originalData: undefined, _isDirty: undefined, _isNew: undefined }, + _originalData: { + ...row, + _rowId: undefined, + _originalData: undefined, + _isDirty: undefined, + _isNew: undefined, + }, })); } return updated; @@ -1837,9 +1859,7 @@ export function RepeatScreenModalComponent({ // 각 조인 키별로 집계 계산 및 업데이트 for (const keyValue of joinKeyValues) { // 해당 조인 키에 해당하는 행들만 필터링 - const filteredRows = rows.filter( - (row) => row[syncSave.joinKey.sourceField] === keyValue - ); + const filteredRows = rows.filter((row) => row[syncSave.joinKey.sourceField] === keyValue); // 집계 계산 let aggregatedValue: number = 0; @@ -1866,12 +1886,15 @@ export function RepeatScreenModalComponent({ break; } - console.log(`[SyncSave] ${sourceTable}.${syncSave.sourceColumn} → ${syncSave.targetTable}.${syncSave.targetColumn}`, { - joinKey: keyValue, - aggregationType: syncSave.aggregationType, - values, - aggregatedValue, - }); + console.log( + `[SyncSave] ${sourceTable}.${syncSave.sourceColumn} → ${syncSave.targetTable}.${syncSave.targetColumn}`, + { + joinKey: keyValue, + aggregationType: syncSave.aggregationType, + values, + aggregatedValue, + }, + ); // 대상 테이블 업데이트 syncPromises.push( @@ -1880,12 +1903,14 @@ export function RepeatScreenModalComponent({ [syncSave.targetColumn]: aggregatedValue, }) .then(() => { - console.log(`[SyncSave] 업데이트 완료: ${syncSave.targetTable}.${syncSave.targetColumn} = ${aggregatedValue} (id=${keyValue})`); + console.log( + `[SyncSave] 업데이트 완료: ${syncSave.targetTable}.${syncSave.targetColumn} = ${aggregatedValue} (id=${keyValue})`, + ); }) .catch((err) => { console.error(`[SyncSave] 업데이트 실패:`, err); throw err; - }) + }), ); } } @@ -1930,7 +1955,7 @@ export function RepeatScreenModalComponent({ config: btn.customAction.config, componentId: component?.id, }, - }) + }), ); } break; @@ -2031,7 +2056,7 @@ export function RepeatScreenModalComponent({ prev.map((card) => ({ ...card, _rows: card._rows.map((row) => ({ ...row, _isDirty: false })), - })) + })), ); }; @@ -2048,7 +2073,7 @@ export function RepeatScreenModalComponent({ } else { await apiClient.post(`/table-management/tables/${tableName}/data`, dataToSave); } - }) + }), ); }); @@ -2066,9 +2091,7 @@ export function RepeatScreenModalComponent({ } // 🆕 v3.1: 외부 테이블 데이터 수정 여부 - const hasExternalDirty = Object.values(externalTableData).some((rows) => - rows.some((row) => row._isDirty) - ); + const hasExternalDirty = Object.values(externalTableData).some((rows) => rows.some((row) => row._isDirty)); return hasBaseDirty || hasExternalDirty; }, [cardMode, cardsData, groupedCardsData, externalTableData]); @@ -2086,25 +2109,25 @@ export function RepeatScreenModalComponent({ return (
-
+
{/* 아이콘 */} -
- +
+
{/* 제목 */} -
-
Repeat Screen Modal
-
반복 화면 모달
+
+
Repeat Screen Modal
+
반복 화면 모달
v3 자유 레이아웃
{/* 행 구성 정보 */} -
+
{contentRows.length > 0 ? ( <> {rowTypeCounts.header > 0 && ( @@ -2136,24 +2159,24 @@ export function RepeatScreenModalComponent({ {/* 통계 정보 */}
-
{contentRows.length}
-
행 (Rows)
+
{contentRows.length}
+
행 (Rows)
-
+
-
{grouping?.aggregations?.length || 0}
-
집계 설정
+
{grouping?.aggregations?.length || 0}
+
집계 설정
-
+
-
{dataSource?.sourceTable ? 1 : 0}
-
데이터 소스
+
{dataSource?.sourceTable ? 1 : 0}
+
데이터 소스
{/* 데이터 소스 정보 */} {dataSource?.sourceTable && ( -
+
소스 테이블: {dataSource.sourceTable} {dataSource.filterField && (필터: {dataSource.filterField})}
@@ -2161,20 +2184,20 @@ export function RepeatScreenModalComponent({ {/* 그룹핑 정보 */} {grouping?.enabled && ( -
+
그룹핑: {grouping.groupByField}
)} {/* 카드 제목 정보 */} {showCardTitle && cardTitle && ( -
+
카드 제목: {cardTitle}
)} {/* 설정 안내 */} -
+
오른쪽 패널에서 행을 추가하고 타입(헤더/집계/테이블/필드)을 선택하세요
@@ -2186,8 +2209,8 @@ export function RepeatScreenModalComponent({ if (isLoading) { return (
- - 데이터를 불러오는 중... + + 데이터를 불러오는 중...
); } @@ -2195,12 +2218,12 @@ export function RepeatScreenModalComponent({ // 오류 상태 if (loadError) { return ( -
-
+
+
데이터 로드 실패
-

{loadError}

+

{loadError}

); } @@ -2213,23 +2236,23 @@ export function RepeatScreenModalComponent({ if (useGrouping) { return (
-
+
{groupedCardsData.map((card, cardIndex) => ( r._isDirty) && "border-primary shadow-lg" + card._rows.some((r) => r._isDirty) && "border-primary shadow-lg", )} > {/* 카드 제목 (선택사항) */} {showCardTitle && ( - + {getCardTitle(card._representativeData, cardIndex)} {card._rows.some((r) => r._isDirty) && ( - + 수정됨 )} @@ -2243,10 +2266,10 @@ export function RepeatScreenModalComponent({
{contentRow.type === "table" && contentRow.tableDataSource?.enabled ? ( // 🆕 v3.1: 외부 테이블 데이터 소스 사용 -
+
{/* 테이블 헤더 영역: 제목 + 버튼들 */} {(contentRow.tableTitle || contentRow.tableCrud?.allowCreate) && ( -
+
{contentRow.tableTitle || ""}
{/* 추가 버튼 */} @@ -2255,7 +2278,7 @@ export function RepeatScreenModalComponent({ variant="outline" size="sm" onClick={() => handleAddExternalRow(card._cardId, contentRow.id, contentRow)} - className="h-7 text-xs gap-1" + className="h-7 gap-1 text-xs" > 추가 @@ -2269,15 +2292,17 @@ export function RepeatScreenModalComponent({ {/* 🆕 v3.13: hidden 컬럼 필터링 */} - {(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => ( - - {col.label} - - ))} + {(contentRow.tableColumns || []) + .filter((col) => !col.hidden) + .map((col) => ( + + {col.label} + + ))} {(contentRow.tableCrud?.allowUpdate || contentRow.tableCrud?.allowDelete) && ( 작업 )} @@ -2288,8 +2313,11 @@ export function RepeatScreenModalComponent({ {(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? ( !col.hidden)?.length || 0) + (contentRow.tableCrud?.allowDelete ? 1 : 0)} - className="text-center py-8 text-muted-foreground" + colSpan={ + (contentRow.tableColumns?.filter((col) => !col.hidden)?.length || 0) + + (contentRow.tableCrud?.allowDelete ? 1 : 0) + } + className="text-muted-foreground py-8 text-center" > 데이터가 없습니다. @@ -2299,64 +2327,82 @@ export function RepeatScreenModalComponent({ {/* 🆕 v3.13: hidden 컬럼 필터링 */} - {(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => ( - - {renderTableCell( - col, - row, - (value) => handleExternalRowDataChange(card._cardId, contentRow.id, row._rowId, col.field, value), - row._isNew || row._isEditing // 신규 행이거나 수정 모드일 때만 편집 가능 - )} - - ))} + {(contentRow.tableColumns || []) + .filter((col) => !col.hidden) + .map((col) => ( + + {renderTableCell( + col, + row, + (value) => + handleExternalRowDataChange( + card._cardId, + contentRow.id, + row._rowId, + col.field, + value, + ), + row._isNew || row._isEditing, // 신규 행이거나 수정 모드일 때만 편집 가능 + )} + + ))} {(contentRow.tableCrud?.allowUpdate || contentRow.tableCrud?.allowDelete) && (
{/* 수정 버튼: 저장된 행(isNew가 아닌)이고 편집 모드가 아닐 때만 표시 */} - {contentRow.tableCrud?.allowUpdate && !row._isNew && !row._isEditing && !row._isDeleted && ( - - )} + {contentRow.tableCrud?.allowUpdate && + !row._isNew && + !row._isEditing && + !row._isDeleted && ( + + )} {/* 수정 취소 버튼: 편집 모드일 때만 표시 */} {row._isEditing && !row._isNew && ( )} {/* 삭제/복원 버튼 */} - {contentRow.tableCrud?.allowDelete && ( - row._isDeleted ? ( + {contentRow.tableCrud?.allowDelete && + (row._isDeleted ? ( - ) - )} + ))}
)} @@ -2392,16 +2444,16 @@ export function RepeatScreenModalComponent({ // 레거시: tableLayout 사용 <> {tableLayout?.headerRows && tableLayout.headerRows.length > 0 && ( -
+
{tableLayout.headerRows.map((row, rowIndex) => (
@@ -2416,7 +2468,7 @@ export function RepeatScreenModalComponent({ )} {tableLayout?.tableColumns && tableLayout.tableColumns.length > 0 && ( -
+
+ # {col.label} @@ -699,7 +679,7 @@ export function SimpleRepeaterTableComponent({ + 삭제
+ {allowAdd ? (
표시할 데이터가 없습니다 @@ -725,9 +701,9 @@ export function SimpleRepeaterTableComponent({
+ {rowIndex + 1}
@@ -2440,10 +2492,10 @@ export function RepeatScreenModalComponent({ className={cn("text-sm", col.align && `text-${col.align}`)} > {renderTableCell( - col, - row, + col, + row, (value) => handleRowDataChange(card._cardId, row._rowId, col.field, value), - row._isNew || row._isEditing + row._isNew || row._isEditing, )} ))} @@ -2465,11 +2517,11 @@ export function RepeatScreenModalComponent({
{footerConfig.buttons.map((btn) => ( @@ -2497,7 +2549,7 @@ export function RepeatScreenModalComponent({ {/* 데이터 없음 */} {groupedCardsData.length === 0 && !isLoading && ( -
표시할 데이터가 없습니다.
+
표시할 데이터가 없습니다.
)} {/* 🆕 v3.1: 삭제 확인 다이얼로그 */} @@ -2505,9 +2557,7 @@ export function RepeatScreenModalComponent({ 삭제 확인 - - 이 행을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. - + 이 행을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. 취소 @@ -2517,7 +2567,7 @@ export function RepeatScreenModalComponent({ handleDeleteExternalRow( pendingDeleteInfo.cardId, pendingDeleteInfo.rowId, - pendingDeleteInfo.contentRowId + pendingDeleteInfo.contentRowId, ); } }} @@ -2535,50 +2585,52 @@ export function RepeatScreenModalComponent({ // 단순 모드 렌더링 (그룹핑 없음) return (
-
+
{cardsData.map((card, cardIndex) => ( {/* 카드 제목 (선택사항) */} {showCardTitle && ( - + {getCardTitle(card, cardIndex)} - {card._isDirty && (수정됨)} + {card._isDirty && (수정됨)} )} {/* 🆕 v3: contentRows 기반 렌더링 */} - {useNewLayout ? ( - contentRows.map((contentRow, rowIndex) => ( -
- {renderSimpleContentRow(contentRow, card, (value, field) => - handleCardDataChange(card._cardId, field, value) - )} -
- )) - ) : ( - // 레거시: cardLayout 사용 - cardLayout.map((row, rowIndex) => ( -
- {row.columns.map((col, colIndex) => ( -
- {renderColumn(col, card, (value) => handleCardDataChange(card._cardId, col.field, value))} -
- ))} -
- )) - )} + {useNewLayout + ? contentRows.map((contentRow, rowIndex) => ( +
+ {renderSimpleContentRow(contentRow, card, (value, field) => + handleCardDataChange(card._cardId, field, value), + )} +
+ )) + : // 레거시: cardLayout 사용 + cardLayout.map((row, rowIndex) => ( +
+ {row.columns.map((col, colIndex) => ( +
+ {renderColumn(col, card, (value) => handleCardDataChange(card._cardId, col.field, value))} +
+ ))} +
+ ))}
))} @@ -2589,11 +2641,11 @@ export function RepeatScreenModalComponent({
{footerConfig.buttons.map((btn) => ( @@ -2621,7 +2673,7 @@ export function RepeatScreenModalComponent({ {/* 데이터 없음 */} {cardsData.length === 0 && !isLoading && ( -
표시할 데이터가 없습니다.
+
표시할 데이터가 없습니다.
)}
); @@ -2632,30 +2684,30 @@ function renderContentRow( contentRow: CardContentRowConfig, card: GroupedCardData, aggregations: AggregationConfig[], - onRowDataChange: (cardId: string, rowId: string, field: string, value: any) => void + onRowDataChange: (cardId: string, rowId: string, field: string, value: any) => void, ) { switch (contentRow.type) { case "header": case "fields": // contentRow에서 직접 columns 가져오기 (v3 구조) const headerColumns = contentRow.columns || []; - + if (headerColumns.length === 0) { return ( -
+
헤더 컬럼이 설정되지 않았습니다.
); } - + return (
@@ -2670,22 +2722,18 @@ function renderContentRow( case "aggregation": // contentRow에서 직접 aggregationFields 가져오기 (v3 구조) const aggFields = contentRow.aggregationFields || []; - + if (aggFields.length === 0) { return ( -
+
집계 필드가 설정되지 않았습니다. (레이아웃 탭에서 집계 필드를 추가하세요)
); } - + return (
{aggFields.map((aggField, fieldIndex) => { // 집계 결과에서 값 가져오기 (aggregationResultField 사용) @@ -2694,16 +2742,16 @@ function renderContentRow(
-
{aggField.label || aggField.aggregationResultField}
+
{aggField.label || aggField.aggregationResultField}
{typeof value === "number" ? value.toLocaleString() : value || "-"} @@ -2717,21 +2765,19 @@ function renderContentRow( case "table": // contentRow에서 직접 tableColumns 가져오기 (v3 구조) const tableColumns = contentRow.tableColumns || []; - + if (tableColumns.length === 0) { return ( -
+
테이블 컬럼이 설정되지 않았습니다. (레이아웃 탭에서 테이블 컬럼을 추가하세요)
); } - + return ( -
+
{contentRow.tableTitle && ( -
- {contentRow.tableTitle} -
+
{contentRow.tableTitle}
)}
{contentRow.showTableHeader !== false && ( @@ -2758,10 +2804,10 @@ function renderContentRow( className={cn("text-sm", col.align && `text-${col.align}`)} > {renderTableCell( - col, - row, + col, + row, (value) => onRowDataChange(card._cardId, row._rowId, col.field, value), - row._isNew || row._isEditing + row._isNew || row._isEditing, )} ))} @@ -2781,7 +2827,7 @@ function renderContentRow( function renderSimpleContentRow( contentRow: CardContentRowConfig, card: CardData, - onChange: (value: any, field: string) => void + onChange: (value: any, field: string) => void, ) { switch (contentRow.type) { case "header": @@ -2790,10 +2836,10 @@ function renderSimpleContentRow(
@@ -2809,40 +2855,37 @@ function renderSimpleContentRow( // 단순 모드에서도 집계 표시 (단일 카드 기준) // contentRow에서 직접 aggregationFields 가져오기 (v3 구조) const aggFields = contentRow.aggregationFields || []; - + if (aggFields.length === 0) { return ( -
+
집계 필드가 설정되지 않았습니다.
); } - + return (
{aggFields.map((aggField, fieldIndex) => { // 단순 모드에서는 카드 데이터에서 직접 값을 가져옴 (aggregationResultField 사용) - const value = card[aggField.aggregationResultField] || card._originalData?.[aggField.aggregationResultField]; + const value = + card[aggField.aggregationResultField] || card._originalData?.[aggField.aggregationResultField]; return (
-
{aggField.label || aggField.aggregationResultField}
+
{aggField.label || aggField.aggregationResultField}
{typeof value === "number" ? value.toLocaleString() : value || "-"} @@ -2857,21 +2900,19 @@ function renderSimpleContentRow( // 단순 모드에서도 테이블 표시 (단일 행) // contentRow에서 직접 tableColumns 가져오기 (v3 구조) const tableColumns = contentRow.tableColumns || []; - + if (tableColumns.length === 0) { return ( -
+
테이블 컬럼이 설정되지 않았습니다.
); } - + return ( -
+
{contentRow.tableTitle && ( -
- {contentRow.tableTitle} -
+
{contentRow.tableTitle}
)}
{contentRow.showTableHeader !== false && ( @@ -2912,11 +2953,7 @@ function renderSimpleContentRow( } // 단순 모드 테이블 셀 렌더링 -function renderSimpleTableCell( - col: TableColumnConfig, - card: CardData, - onChange: (value: any) => void -) { +function renderSimpleTableCell(col: TableColumnConfig, card: CardData, onChange: (value: any) => void) { const value = card[col.field] || card._originalData?.[col.field]; if (!col.editable) { @@ -2940,12 +2977,7 @@ function renderSimpleTableCell( ); case "date": return ( - onChange(e.target.value)} - className="h-8 text-sm" - /> + onChange(e.target.value)} className="h-8 text-sm" /> ); case "select": return ( @@ -2964,12 +2996,7 @@ function renderSimpleTableCell( ); default: return ( - onChange(e.target.value)} - className="h-8 text-sm" - /> + onChange(e.target.value)} className="h-8 text-sm" /> ); } } @@ -2986,11 +3013,7 @@ function getBackgroundClass(color: string): string { } // 헤더 컬럼 렌더링 (집계값 포함) -function renderHeaderColumn( - col: CardColumnConfig, - card: GroupedCardData, - aggregations: AggregationConfig[] -) { +function renderHeaderColumn(col: CardColumnConfig, card: GroupedCardData, aggregations: AggregationConfig[]) { let value: any; // 집계값 타입이면 집계 결과에서 가져옴 @@ -3000,16 +3023,16 @@ function renderHeaderColumn( return (
- +
{typeof value === "number" ? value.toLocaleString() : value || "-"} - {aggConfig && ({aggConfig.type})} + {aggConfig && ({aggConfig.type})}
); @@ -3020,13 +3043,9 @@ function renderHeaderColumn( return (
- +
{value || "-"}
@@ -3036,7 +3055,12 @@ function renderHeaderColumn( // 테이블 셀 렌더링 // 🆕 v3.8: isRowEditable 파라미터 추가 - 행이 편집 가능한 상태인지 (신규 행이거나 수정 모드) -function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (value: any) => void, isRowEditable?: boolean) { +function renderTableCell( + col: TableColumnConfig, + row: CardRowData, + onChange: (value: any) => void, + isRowEditable?: boolean, +) { const value = row[col.field]; // Badge 타입 @@ -3047,7 +3071,7 @@ function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (va // 🆕 v3.8: 행 수준 편집 가능 여부 체크 // isRowEditable이 false이면 컬럼 설정과 관계없이 읽기 전용 - const canEdit = col.editable && (isRowEditable !== false); + const canEdit = col.editable && isRowEditable !== false; // 읽기 전용 if (!canEdit) { @@ -3056,7 +3080,11 @@ function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (va } if (col.type === "date") { // ISO 8601 형식을 표시용으로 변환 - const displayDate = value ? (typeof value === 'string' && value.includes('T') ? value.split('T')[0] : value) : "-"; + const displayDate = value + ? typeof value === "string" && value.includes("T") + ? value.split("T")[0] + : value + : "-"; return {displayDate}; } return {value || "-"}; @@ -3065,33 +3093,20 @@ function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (va // 편집 가능 switch (col.type) { case "text": - return ( - onChange(e.target.value)} - className="h-8 text-sm" - /> - ); + return onChange(e.target.value)} className="h-8 text-sm" />; case "number": return ( onChange(Number(e.target.value) || 0)} - className="h-8 text-sm text-right" + className="h-8 text-right text-sm" /> ); case "date": // ISO 8601 형식('2025-12-02T00:00:00.000Z')을 'YYYY-MM-DD' 형식으로 변환 - const dateValue = value ? (typeof value === 'string' && value.includes('T') ? value.split('T')[0] : value) : ""; - return ( - onChange(e.target.value)} - className="h-8 text-sm" - /> - ); + const dateValue = value ? (typeof value === "string" && value.includes("T") ? value.split("T")[0] : value) : ""; + return onChange(e.target.value)} className="h-8 text-sm" />; default: return {value || "-"}; } @@ -3110,7 +3125,7 @@ function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: a {isReadOnly && ( -
+
{value || "-"}
)} @@ -3139,7 +3154,7 @@ function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: a {col.type === "date" && ( onChange(e.target.value)} className="h-10 text-sm" /> @@ -3165,12 +3180,12 @@ function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: a value={value || ""} onChange={(e) => onChange(e.target.value)} placeholder={col.placeholder} - className="text-sm min-h-[80px]" + className="min-h-[80px] text-sm" /> )} {col.type === "component" && col.componentType && ( -
+
컴포넌트: {col.componentType} (개발 중)
)} diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 0f5c851b..26acaf34 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -115,14 +115,14 @@ const CascadingSelectField: React.FC = ({ type="button" variant="ghost" size="sm" - className="absolute right-0 top-0 h-full px-2 hover:bg-transparent" + className="absolute top-0 right-0 h-full px-2 hover:bg-transparent" onClick={() => !isDisabled && setOpen(!open)} disabled={isDisabled} > {loading ? ( - + ) : ( - + )}
@@ -149,12 +149,7 @@ const CascadingSelectField: React.FC = ({ setOpen(false); }} > - + {option.label} ))} @@ -437,19 +432,19 @@ export function UniversalFormModalComponent({ } // 🆕 테이블 섹션 데이터 병합 (품목 리스트 등) - // 참고: initializeForm에서 DB 로드 시 __tableSection_ (더블), + // 참고: initializeForm에서 DB 로드 시 __tableSection_ (더블), // handleTableDataChange에서 수정 시 _tableSection_ (싱글) 사용 for (const [key, value] of Object.entries(formData)) { // 싱글/더블 언더스코어 모두 처리 if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) { // 저장 시에는 _tableSection_ 키로 통일 (buttonActions.ts에서 이 키를 기대) - const normalizedKey = key.startsWith("__tableSection_") - ? key.replace("__tableSection_", "_tableSection_") + const normalizedKey = key.startsWith("__tableSection_") + ? key.replace("__tableSection_", "_tableSection_") : key; event.detail.formData[normalizedKey] = value; console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key} → ${normalizedKey}, ${value.length}개 항목`); } - + // 🆕 원본 테이블 섹션 데이터도 병합 (삭제 추적용) if (key.startsWith("_originalTableSectionData_") && Array.isArray(value)) { event.detail.formData[key] = value; @@ -948,13 +943,17 @@ export function UniversalFormModalComponent({ // 각 테이블 섹션별로 별도의 키에 원본 데이터 저장 (groupedDataInitializedRef와 무관하게 항상 저장) const originalTableSectionKey = `_originalTableSectionData_${section.id}`; newFormData[originalTableSectionKey] = JSON.parse(JSON.stringify(items)); - console.log(`[initializeForm] 테이블 섹션 ${section.id}: formData[${originalTableSectionKey}]에 원본 ${items.length}건 저장`); - + console.log( + `[initializeForm] 테이블 섹션 ${section.id}: formData[${originalTableSectionKey}]에 원본 ${items.length}건 저장`, + ); + // 기존 originalGroupedData에도 추가 (하위 호환성) if (!groupedDataInitializedRef.current) { setOriginalGroupedData((prev) => { const newOriginal = [...prev, ...JSON.parse(JSON.stringify(items))]; - console.log(`[initializeForm] 테이블 섹션 ${section.id}: originalGroupedData에 ${items.length}건 추가 (총 ${newOriginal.length}건)`); + console.log( + `[initializeForm] 테이블 섹션 ${section.id}: originalGroupedData에 ${items.length}건 추가 (총 ${newOriginal.length}건)`, + ); return newOriginal; }); } @@ -1639,12 +1638,12 @@ export function UniversalFormModalComponent({ /> ); } - + // 🆕 연쇄 드롭다운 처리 (selectOptions.type === "cascading" 방식) if (field.selectOptions?.type === "cascading" && field.selectOptions?.cascading?.parentField) { const cascadingOpts = field.selectOptions.cascading; const parentValue = formData[cascadingOpts.parentField]; - + // selectOptions 기반 cascading config를 CascadingDropdownConfig 형태로 변환 const cascadingConfig: CascadingDropdownConfig = { enabled: true, @@ -2393,7 +2392,7 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); const [inputValue, setInputValue] = useState(value || ""); - + const allowCustomInput = optionConfig?.allowCustomInput || false; useEffect(() => { @@ -2433,14 +2432,14 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa type="button" variant="ghost" size="sm" - className="absolute right-0 top-0 h-full px-2 hover:bg-transparent" + className="absolute top-0 right-0 h-full px-2 hover:bg-transparent" onClick={() => !disabled && !loading && setOpen(!open)} disabled={disabled || loading} > {loading ? ( - + ) : ( - + )}
@@ -2463,12 +2462,7 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa setOpen(false); }} > - + {option.label} ))} diff --git a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx index c656d8db..eba973e4 100644 --- a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx +++ b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx @@ -47,7 +47,7 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer { const style = component.style || {}; const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay; // labelDisplay: true → 라벨 표시, false → 숨김, undefined → 기존 동작 유지(숨김) - const effectiveLabel = labelDisplay === true ? (style.labelText || component.label) : undefined; + const effectiveLabel = labelDisplay === true ? style.labelText || component.label : undefined; return ( setTimeout(resolve, 100)); - + // 🔧 디버그: beforeFormSave 이벤트 후 formData 확인 console.log("🔍 [handleSave] beforeFormSave 이벤트 후:", { keys: Object.keys(context.formData || {}), @@ -1626,7 +1626,9 @@ export class ButtonActionExecutor { // saveResult.data.data.data = 실제 폼 데이터 { sabun, user_name... } const savedRecord = saveResult?.data?.data || saveResult?.data || {}; const actualFormData = savedRecord?.data || savedRecord; - const formData: Record = (Object.keys(actualFormData).length > 0 ? actualFormData : context.formData || {}) as Record; + const formData: Record = ( + Object.keys(actualFormData).length > 0 ? actualFormData : context.formData || {} + ) as Record; console.log("📦 [executeAfterSaveControl] savedRecord 구조:", Object.keys(savedRecord)); console.log("📦 [executeAfterSaveControl] actualFormData 추출:", Object.keys(formData)); console.log("📦 [executeAfterSaveControl] formData.sabun:", formData.sabun); @@ -2924,8 +2926,7 @@ export class ButtonActionExecutor { if (v2ListComponent) { dataSourceId = - v2ListComponent.componentConfig.dataSource?.table || - v2ListComponent.componentConfig.tableName; + v2ListComponent.componentConfig.dataSource?.table || v2ListComponent.componentConfig.tableName; console.log("✨ V2List 자동 감지:", { componentId: v2ListComponent.id, tableName: dataSourceId, @@ -3061,7 +3062,7 @@ export class ButtonActionExecutor { // 🔧 수정: openModalWithData는 "신규 등록 + 연결 데이터 전달"용이므로 // editData가 아닌 splitPanelParentData로 전달해야 채번 등이 정상 작동함 const isPassDataMode = passSelectedData && selectedData.length > 0; - + // 🔧 isEditMode 옵션이 명시적으로 true인 경우에만 수정 모드로 처리 const useAsEditData = config.isEditMode === true; -- 2.43.0