diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 84a8729c..6833cea1 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -4,6 +4,10 @@ "command": "node", "args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"] }, + "agent-pipeline": { + "command": "node", + "args": ["/Users/gbpark/ERP-node/_local/agent-pipeline/build/index.js"] + }, "Framelink Figma MCP": { "command": "npx", "args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"] diff --git a/.gitignore b/.gitignore index a4c207df..a61636e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Claude Code (로컬 전용 - Git 제외) .claude/ +# 개인 로컬 전용 (agent-pipeline 등) +_local/ + # Dependencies node_modules/ npm-debug.log* diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index e91124af..e12e397f 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -1533,16 +1533,18 @@ export class MenuCopyService { // === 기존 복사본이 있는 경우: 업데이트 === const existingScreenId = existingCopy.screen_id; - // 원본 V2 레이아웃 조회 + // 원본 V2 레이아웃 조회 (company_code 필터링 추가) const sourceLayoutV2Result = await client.query<{ layout_data: any }>( - `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`, - [originalScreenId] + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2`, + [originalScreenId, companyCode] ); - // 대상 V2 레이아웃 조회 + // 대상 V2 레이아웃 조회 (company_code 필터링 추가) const targetLayoutV2Result = await client.query<{ layout_data: any }>( - `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`, - [existingScreenId] + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2`, + [existingScreenId, companyCode] ); // 변경 여부 확인 (V2 레이아웃 비교) @@ -1662,10 +1664,11 @@ export class MenuCopyService { isUpdate, } of screenDefsToProcess) { try { - // 원본 V2 레이아웃 조회 + // 원본 V2 레이아웃 조회 (company_code 필터링 추가) const layoutV2Result = await client.query<{ layout_data: any }>( - `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`, - [originalScreenId] + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2`, + [originalScreenId, companyCode] ); const layoutData = layoutV2Result.rows[0]?.layout_data; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 74506a39..99b0f4d3 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -5145,11 +5145,21 @@ export class ScreenManagementService { } if (!layout) { - + logger.info(`[getLayoutV2] 레이아웃 없음`, { + screenId, + companyCode, + isSuperAdmin, + }); return null; } - + logger.info(`[getLayoutV2] 레이아웃 조회 성공`, { + screenId, + companyCode, + componentCount: Array.isArray(layout.layout_data?.components) ? layout.layout_data.components.length : 0, + version: layout.layout_data?.version, + }); + return layout.layout_data; } @@ -5169,8 +5179,6 @@ export class ScreenManagementService { const hasConditionConfig = 'conditionConfig' in layoutData; const conditionConfig = layoutData.conditionConfig || null; - - // 권한 확인 const screens = await query<{ company_code: string | null }>( `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, @@ -5187,44 +5195,79 @@ export class ScreenManagementService { throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다."); } - // 화면의 기본 테이블 업데이트 (테이블이 선택된 경우) - const mainTableName = layoutData.mainTableName; - if (mainTableName) { - await query( - `UPDATE screen_definitions SET table_name = $1, updated_date = NOW() WHERE screen_id = $2`, - [mainTableName, screenId], - ); - console.log(`✅ [saveLayoutV2] 화면 기본 테이블 업데이트: ${mainTableName}`); + // 입력값 검증 (보안: XSS, DoS 방어) + const { layerId: _lid, layerName: _ln, conditionConfig: _cc, mainTableName: _mtn, ...pureLayoutData } = layoutData; + + // 1. version 검증 + if (!pureLayoutData.version || !['2.0', '2.1'].includes(pureLayoutData.version)) { + throw new Error('지원하지 않는 레이아웃 버전입니다. (허용: 2.0, 2.1)'); + } + + // 2. components 배열 크기 제한 (최대 500개) + if (Array.isArray(pureLayoutData.components) && pureLayoutData.components.length > 500) { + throw new Error('컴포넌트는 최대 500개까지 저장 가능합니다.'); + } + + // 3. JSON 크기 제한 (최대 10MB) + const jsonSize = JSON.stringify(pureLayoutData).length; + if (jsonSize > 10 * 1024 * 1024) { + throw new Error('레이아웃 데이터가 너무 큽니다. (최대 10MB)'); + } + + // 4. layerName 길이 제한 (최대 100자) + if (layerName && layerName.length > 100) { + throw new Error('레이어 이름은 최대 100자까지 입력 가능합니다.'); } - // 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장) - const { layerId: _lid, layerName: _ln, conditionConfig: _cc, mainTableName: _mtn, ...pureLayoutData } = layoutData; const dataToSave = { - version: "2.0", + version: pureLayoutData.version, ...pureLayoutData, }; - if (hasConditionConfig) { - // conditionConfig가 명시적으로 전달된 경우: condition_config도 함께 저장 - await query( - `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) - ON CONFLICT (screen_id, company_code, layer_id) - DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`, - [screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)], - ); - } else { - // conditionConfig가 전달되지 않은 경우: 기존 condition_config 유지, layout_data만 업데이트 - await query( - `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) - ON CONFLICT (screen_id, company_code, layer_id) - DO UPDATE SET layout_data = $5, layer_name = $4, updated_at = NOW()`, - [screenId, companyCode, layerId, layerName, JSON.stringify(dataToSave)], - ); - } + // 트랜잭션으로 묶어서 데이터 정합성 보장 + await transaction(async (client) => { + // 화면의 기본 테이블 업데이트 (테이블이 선택된 경우) + const mainTableName = layoutData.mainTableName; + if (mainTableName) { + await client.query( + `UPDATE screen_definitions SET table_name = $1, updated_date = NOW() WHERE screen_id = $2`, + [mainTableName, screenId], + ); + logger.info(`[saveLayoutV2] 화면 기본 테이블 업데이트: ${mainTableName}`, { + screenId, + companyCode, + mainTableName, + }); + } - + if (hasConditionConfig) { + // conditionConfig가 명시적으로 전달된 경우: condition_config도 함께 저장 + await client.query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + ON CONFLICT (screen_id, company_code, layer_id) + DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`, + [screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)], + ); + } else { + // conditionConfig가 전달되지 않은 경우: 기존 condition_config 유지, layout_data만 업데이트 + await client.query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + ON CONFLICT (screen_id, company_code, layer_id) + DO UPDATE SET layout_data = $5, layer_name = $4, updated_at = NOW()`, + [screenId, companyCode, layerId, layerName, JSON.stringify(dataToSave)], + ); + } + + logger.info(`[saveLayoutV2] 레이아웃 저장 완료`, { + screenId, + companyCode, + layerId, + componentCount: Array.isArray(pureLayoutData.components) ? pureLayoutData.components.length : 0, + dataSizeKB: Math.round(jsonSize / 1024), + }); + }); } /** @@ -5358,13 +5401,18 @@ export class ScreenManagementService { throw new Error("기본 레이어는 삭제할 수 없습니다."); } - await query( + const result = await query( `DELETE FROM screen_layouts_v2 WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`, [screenId, companyCode, layerId], ); - console.log(`레이어 삭제 완료: screen_id=${screenId}, layer_id=${layerId}`); + logger.info(`[deleteLayer] 레이어 삭제 완료`, { + screenId, + companyCode, + layerId, + affectedRows: result.rowCount || 0, + }); } /** diff --git a/docs/screen-designer-upgrade-plan.md b/docs/screen-designer-upgrade-plan.md new file mode 100644 index 00000000..515f382c --- /dev/null +++ b/docs/screen-designer-upgrade-plan.md @@ -0,0 +1,1026 @@ +# 화면 디자이너 V2 - 재설계 계획서 + +> 작성일: 2026-02-27 +> 방향: 기존 기능 형태는 인정하되, 새로운 방식으로 재설립 +> 핵심 철학: "컴포넌트를 계속 추가하는 시스템"에서 "소수의 강력한 컴포넌트로 유연하게 조합하는 시스템"으로 + +--- + +## 1. 현재 시스템의 근본적 문제 + +### 1.1 "컴포넌트 폭증" 문제 + +현재 등록된 컴포넌트 수: **70개 이상** + +``` +기본 입력 13종 + V2 입력 4종 + V2 데이터 5종 +기존 레이아웃 8종 + V2 레이아웃 16종 + 특수 10종+ +ConfigPanel 26개 + Renderer 21개 + 위젯 10종+ +``` + +**문제**: 새 기능이 필요할 때마다 아래 작업을 반복해야 함 + +``` +새 기능 요청 → ComponentDefinition 작성 + → Renderer 작성 + → ConfigPanel 작성 + → index.ts에 import 추가 + → 테스트 + → 약 5~8개 파일, 500~1500줄 추가 +``` + +거래처별 품목정보? → `customer-item-mapping` 컴포넌트 추가 +BOM 트리? → `v2-bom-tree` + `v2-bom-item-editor` 추가 +세금계산서? → `tax-invoice-list` 추가 +타임라인? → `v2-timeline-scheduler` 추가 + +**매번 개발자가 코드를 작성해야만 새 기능이 가능**하다. + +### 1.2 설정 패널의 사용성 문제 + +ConfigPanel이 26개이고, 각각 UI 구조/용어/설정 방식이 다름: + +| 패널 | 설정 항목 | 사용자 관점 | +|------|-----------|------------| +| TextConfigPanel | inputType, placeholder, maxLength... | "이게 뭐지?" | +| EntityConfigPanel | joinTable, joinColumn, displayColumn... | "조인? 컬럼?" | +| SelectConfigPanel | optionSource, staticOptions, dynamicApi... | "동적 API?" | +| FlowWidgetConfigPanel | flowDefinitionId, stepMapping... | "스텝 매핑?" | +| ButtonConfigPanel | actionType, dataFlow, targetScreen... | "데이터 플로우?" | + +**사용자(현업 관리자)가 이 설정들을 이해하고 사용하기가 극히 어려움.** + +### 1.3 컴포넌트 간 연결의 제한성 + +현재 컴포넌트들은 대부분 **독립적**으로 동작: + +- 거래처를 선택하면 → 납품처가 자동으로 바뀌어야 하는데 → 별도 코드 필요 +- 마스터 테이블에서 행을 선택하면 → 디테일 테이블이 자동 필터링 → SplitPanel로 하드코딩 +- 버튼 클릭하면 → 특정 필드 값에 따라 다른 동작 → FlowWidget으로만 가능 + +**컴포넌트 간 동적 상호작용을 사용자가 설정할 방법이 없음.** + +### 1.4 V1/V2/기존/신규 혼재 + +```typescript +// 같은 테이블 컴포넌트가 3종류 +import "./table-list/TableListRenderer"; // 기존 +import "./v2-table-list/TableListRenderer"; // V2 +import "./v2-table-grouped/TableGroupedRenderer"; // V2 그룹화 +``` + +동일 기능의 컴포넌트가 "기존 호환"이라는 이유로 중복 존재. +어떤 걸 써야 하는지 사용자도 개발자도 혼란. + +--- + +## 2. 새로운 비전 + +### 2.1 핵심 원칙 + +``` +❌ 기존: "기능 = 컴포넌트" → 기능이 늘어나면 컴포넌트가 늘어남 +✅ 새롭게: "기능 = 설정" → 소수의 메타 컴포넌트 + 풍부한 설정으로 무한 확장 +``` + +``` +❌ 기존: "개발자가 만들어주는 화면" → 요청 → 개발 → 배포 → 1~3일 +✅ 새롭게: "사용자가 조립하는 화면" → 드래그 → 설정 → 바로 사용 → 5분 +``` + +### 2.2 메타 컴포넌트 체계 + +70개 이상의 컴포넌트를 **7개의 메타 컴포넌트**로 통합: + +| 메타 컴포넌트 | 대체 범위 | 핵심 능력 | +|---------------|-----------|-----------| +| **Field** | text, number, date, select, entity, checkbox, radio, file, textarea, code, numbering | 테이블 컬럼의 webType에 따라 자동 변환 | +| **DataView** | table-list, repeater, card-display, pivot-grid, bom-tree, aggregation, timeline | 같은 데이터를 테이블/카드/트리/피벗/타임라인 등 뷰 모드 전환 | +| **Action** | button-primary, flow-widget, related-data-buttons | 클릭 시 저장/삭제/조회/이동/모달/API 호출 등 설정 | +| **Layout** | split-panel, tabs, section-card, section-paper, conditional-container, accordion | 영역 분할, 탭, 섹션, 조건부 표시 | +| **Display** | text-display, divider, badge, alert, stats-card, chart, image, progress | 읽기 전용 정보 표시 | +| **Search** | table-search, advanced-filters, autocomplete | 데이터 필터링/검색 | +| **Modal** | universal-form-modal, repeat-screen-modal | 데이터 입력/편집 팝업 | + +### 2.3 "테이블 드롭 → 화면 완성" 흐름 + +**현재** (약 15분): +``` +1. 테이블 컬럼을 하나씩 드래그 +2. 각 컬럼마다 컴포넌트 타입 확인 +3. entity 컬럼이면 → EntityConfigPanel에서 조인 테이블, 조인 컬럼 수동 설정 +4. select 컬럼이면 → SelectConfigPanel에서 옵션 소스 수동 설정 +5. 버튼 컴포넌트 별도 추가 +6. 데이터 테이블 별도 추가 +7. 각각 설정... +``` + +**새로운 방식** (약 2분): +``` +1. 테이블을 통째로 드롭 +2. 시스템이 자동으로: + - 컬럼 타입(webType) 분석 → Field 컴포넌트 자동 생성 + - entity 컬럼 → 조인 정보 자동 감지 (FK 관계) + - select 컬럼 → 코드 테이블 자동 연결 + - CRUD 버튼 자동 생성 (Action 컴포넌트) + - DataView(테이블 뷰) 자동 생성 +3. 사용자는 세부 설정만 조정 +``` + +--- + +## 3. 메타 컴포넌트 상세 설계 + +### 3.1 Field (통합 입력 컴포넌트) + +**대체 대상**: text-input, number-input, date-input, select-basic, checkbox-basic, radio-basic, entity-search-input, file-upload, textarea, code-input, numbering-rule, v2-input, v2-select, v2-date, v2-file-upload, autocomplete-search-input, toggle-switch, slider + +**핵심 개념**: 하나의 Field 컴포넌트가 `webType`에 따라 모양과 동작이 바뀜 + +```typescript +interface FieldConfig { + // 자동 결정 (테이블 컬럼 기반) + webType: "text" | "number" | "date" | "datetime" | "select" | "entity" + | "checkbox" | "radio" | "file" | "textarea" | "code" | "numbering" + | "email" | "tel" | "url" | "password" | "color" | "toggle" | "slider"; + + // 데이터 바인딩 (자동) + tableName: string; + columnName: string; + + // 조인 정보 (entity 타입일 때 자동 감지) + join?: { + targetTable: string; // FK가 참조하는 테이블 + targetColumn: string; // PK 컬럼 + displayColumn: string; // 표시할 컬럼 (자동: name 또는 첫 번째 text 컬럼) + additionalColumns?: string[]; // 추가 표시 컬럼 + searchable?: boolean; // 검색 가능 여부 + filterBy?: Record; // 다른 Field 값으로 필터링 + }; + + // select 옵션 (자동 감지) + options?: { + source: "code_table" | "static" | "api"; + codeCategory?: string; // code_table일 때 + staticList?: { value: string; label: string }[]; + apiEndpoint?: string; + }; + + // 공통 설정 + label?: string; + placeholder?: string; + required?: boolean; + readonly?: boolean; + disabled?: boolean; + defaultValue?: any; + + // 검증 + validation?: { + min?: number; + max?: number; + minLength?: number; + maxLength?: number; + pattern?: string; + customMessage?: string; + }; + + // 연동 (다른 Field와의 관계) + linkedTo?: { + fieldId: string; // 연결된 Field의 ID + relationship: "filter" | "copy" | "calculate" | "show_hide"; + expression?: string; // 계산식 또는 조건식 + }[]; + + // 레이아웃 + display?: { + width?: string; // "full" | "half" | "third" | "quarter" | 커스텀 + labelPosition?: "top" | "left" | "hidden"; + helpText?: string; + }; +} +``` + +**사용자 설정 UI (새로운 방식)**: + +``` +┌─────────────────────────────────────┐ +│ Field 설정 │ +├─────────────────────────────────────┤ +│ 표시 형태: [텍스트 ▼] │ ← 드롭다운 하나로 webType 전환 +│ │ +│ 라벨: [수주번호 ] │ +│ 필수: [✓] 읽기전용: [ ] │ +│ │ +│ ─── 데이터 연결 ──── │ +│ 테이블: sales_order_mng (자동) │ +│ 컬럼: order_no (자동) │ +│ │ +│ ─── 연동 설정 ──── │ +│ + 다른 필드와 연결 추가 │ +│ [거래처] 변경 시 → [납품처] 필터링 │ +│ [수량] × [단가] → [금액] 자동계산 │ +│ │ +│ ─── 표시 ──── │ +│ 너비: [절반 ▼] │ +│ 도움말: [주문 번호를 입력하세요] │ +└─────────────────────────────────────┘ +``` + +**핵심 차이점**: +- 기존: 컴포넌트 타입 선택 → 타입별 ConfigPanel → 복잡한 설정 +- 새로운: Field 하나 놓으면 → webType 자동 감지 → 간단한 통합 설정 + +--- + +### 3.2 DataView (통합 데이터 뷰) + +**대체 대상**: table-list, v2-table-list, v2-repeater, card-display, v2-card-display, pivot-grid, v2-pivot-grid, bom-tree, aggregation-widget, v2-aggregation-widget, v2-table-grouped, v2-timeline-scheduler, simple-repeater-table, modal-repeater-table + +**핵심 개념**: 하나의 데이터 소스를 여러 뷰 모드로 전환 + +```typescript +interface DataViewConfig { + // 데이터 소스 + dataSource: { + tableName: string; + columns: ColumnConfig[]; // 표시할 컬럼 목록 + defaultSort?: { column: string; direction: "asc" | "desc" }; + defaultFilter?: FilterConfig[]; + pageSize?: number; + + // 마스터-디테일 자동 연결 + masterField?: string; // 상위 DataView의 어떤 컬럼과 연결 + detailForeignKey?: string; // 하위 테이블의 FK 컬럼 + }; + + // 뷰 모드 (사용자가 런타임에 전환 가능) + viewMode: "table" | "card" | "list" | "tree" | "pivot" | "timeline" | "kanban" | "calendar"; + allowedViewModes?: string[]; // 허용된 뷰 모드 목록 + + // 테이블 뷰 설정 + tableConfig?: { + showRowNumber?: boolean; + showCheckbox?: boolean; + stickyHeader?: boolean; + groupBy?: string[]; // 그룹화 컬럼 + summaryColumns?: string[]; // 합계 표시 컬럼 + editableColumns?: string[]; // 인라인 편집 가능 컬럼 + frozenColumns?: number; // 고정 컬럼 수 + }; + + // 카드 뷰 설정 + cardConfig?: { + titleColumn: string; + descriptionColumn?: string; + imageColumn?: string; + columnsPerRow?: 2 | 3 | 4; + cardStyle?: "compact" | "standard" | "detailed"; + }; + + // 트리 뷰 설정 + treeConfig?: { + parentColumn: string; + childColumn: string; + labelColumn: string; + expandLevel?: number; + }; + + // 피벗 설정 + pivotConfig?: { + rowFields: string[]; + columnFields: string[]; + valueFields: { column: string; aggregation: "sum" | "count" | "avg" | "min" | "max" }[]; + }; + + // CRUD 동작 (Action 컴포넌트와 자동 연결) + actions?: { + create?: boolean; + read?: boolean; + update?: boolean; + delete?: boolean; + export?: boolean; + import?: boolean; + }; + + // 검색/필터 (Search 컴포넌트와 자동 연결) + searchable?: boolean; + filterColumns?: string[]; +} +``` + +**사용자 설정 UI**: + +``` +┌─────────────────────────────────────┐ +│ DataView 설정 │ +├─────────────────────────────────────┤ +│ 테이블: [sales_order_mng ▼] │ +│ │ +│ 뷰 모드: [테이블] [카드] [트리] │ ← 클릭 한 번으로 전환 +│ ☑테이블 ☑카드 ☐트리 │ ← 허용 모드 체크 +│ │ +│ ─── 컬럼 선택 ──── │ +│ ☑ 수주번호 표시명: 수주번호 │ +│ ☑ 거래처 표시명: 거래처 │ +│ ☑ 수주수량 표시명: 수량 합계☑│ +│ ☐ 생성일 (숨김) │ +│ [↕ 드래그로 순서 변경] │ +│ │ +│ ─── 기능 ──── │ +│ ☑ 행 번호 ☑ 체크박스 ☑ 검색 │ +│ ☑ 등록 ☑ 수정 ☑ 삭제 │ +│ ☐ 엑셀 내보내기 ☐ 가져오기 │ +│ │ +│ ─── 연결 ──── │ +│ 마스터: [없음 ▼] │ ← 다른 DataView 선택 시 +│ 연결 키: [customer_code ▼] │ 자동 마스터-디테일 +└─────────────────────────────────────┘ +``` + +--- + +### 3.3 Action (통합 액션 컴포넌트) + +**대체 대상**: button-primary, v2-button-primary, flow-widget, related-data-buttons + +**핵심 개념**: 하나의 Action 컴포넌트에 여러 동작을 설정으로 정의 + +```typescript +interface ActionConfig { + // 표시 + label: string; + variant: "default" | "outline" | "destructive" | "ghost"; + icon?: string; + position?: "toolbar" | "inline" | "floating" | "context-menu"; + + // 동작 정의 (파이프라인 방식 - 순차 실행) + steps: ActionStep[]; + + // 실행 조건 + enableCondition?: { + type: "always" | "selected" | "field_value" | "expression"; + fieldId?: string; + operator?: "eq" | "ne" | "gt" | "lt" | "empty" | "not_empty"; + value?: any; + expression?: string; + }; + + // 확인 대화상자 + confirmDialog?: { + title: string; + message: string; + confirmText?: string; + cancelText?: string; + variant?: "default" | "destructive"; + }; +} + +type ActionStep = + | { type: "save"; target: string } // 데이터 저장 + | { type: "delete"; target: string } // 데이터 삭제 + | { type: "refresh"; target: string } // 데이터 새로고침 + | { type: "navigate"; screenId: number } // 화면 이동 + | { type: "openModal"; modalId: string } // 모달 열기 + | { type: "setField"; fieldId: string; value: any } // 필드 값 설정 + | { type: "api"; method: string; endpoint: string; body?: any } // API 호출 + | { type: "flowMove"; flowId: number; stepId: number } // 플로우 이동 + | { type: "export"; format: "excel" | "csv" | "pdf" } // 내보내기 + | { type: "validate"; target: string } // 유효성 검사 + | { type: "toast"; message: string; variant: "success" | "error" | "warning" }; // 알림 +``` + +**사용자 설정 UI**: + +``` +┌─────────────────────────────────────┐ +│ Action 설정 │ +├─────────────────────────────────────┤ +│ 버튼명: [저장 ] │ +│ 스타일: [기본●] [외곽] [위험] [고스트]│ +│ 아이콘: [Save ▼] │ +│ │ +│ ─── 동작 순서 ──── │ +│ 1. [유효성 검사 ▼] → 주문 폼 │ +│ 2. [데이터 저장 ▼] → sales_order │ +│ 3. [알림 ▼] → "저장 완료" │ +│ 4. [새로고침 ▼] → 주문 목록 │ +│ [+ 동작 추가] │ +│ │ +│ ─── 실행 조건 ──── │ +│ [행 선택 시만 ▼] │ +│ │ +│ ─── 확인 ──── │ +│ ☑ 실행 전 확인 대화상자 │ +│ 제목: [저장 확인] │ +│ 메시지: [변경사항을 저장합니다] │ +└─────────────────────────────────────┘ +``` + +--- + +### 3.4 Layout (통합 레이아웃) + +**대체 대상**: split-panel-layout, v2-split-panel-layout, tabs, v2-tabs, section-card, v2-section-card, section-paper, v2-section-paper, conditional-container, accordion, screen-split-panel, repeat-container + +```typescript +interface LayoutConfig { + mode: "columns" | "rows" | "tabs" | "accordion" | "card" | "conditional"; + + // columns/rows 모드 + areas?: { + id: string; + label?: string; + size?: string; // "1fr", "300px", "auto" + minSize?: string; + maxSize?: string; + collapsible?: boolean; + resizable?: boolean; // 드래그로 크기 조절 + }[]; + + // tabs 모드 + tabs?: { + id: string; + label: string; + icon?: string; + closable?: boolean; + lazy?: boolean; // 탭 전환 시 지연 로드 + }[]; + + // conditional 모드 (조건에 따라 영역 표시/숨김) + conditions?: { + areaId: string; + showWhen: { + fieldId: string; + operator: "eq" | "ne" | "in" | "not_in"; + value: any; + }; + }[]; + + // 공통 + gap?: number; + padding?: number; + bordered?: boolean; + title?: string; +} +``` + +--- + +### 3.5 Display (통합 표시 컴포넌트) + +**대체 대상**: text-display, v2-text-display, divider-line, v2-divider-line, badge, alert, stats-card, chart, image, progress-bar, v2-media, v2-split-line + +```typescript +interface DisplayConfig { + displayType: "text" | "heading" | "divider" | "badge" | "alert" | "stat" + | "chart" | "image" | "progress" | "spacer" | "html"; + + // 데이터 바인딩 (선택) + dataBinding?: { + tableName?: string; + columnName?: string; + expression?: string; // 계산식: "{total_amount} / {quantity}" + format?: string; // 숫자/날짜 포맷 + }; + + // 텍스트 설정 + text?: { + content: string; + size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl"; + weight?: "normal" | "medium" | "semibold" | "bold"; + color?: string; + align?: "left" | "center" | "right"; + }; + + // 차트 설정 + chart?: { + chartType: "bar" | "line" | "pie" | "donut" | "area"; + dataSource: string; + xAxis: string; + yAxis: string[]; + colors?: string[]; + }; + + // 통계 카드 + stat?: { + value: string; + label: string; + change?: string; + changeType?: "increase" | "decrease" | "neutral"; + icon?: string; + }; +} +``` + +--- + +### 3.6 Search (통합 검색) + +**대체 대상**: table-search-widget, v2-table-search-widget, AdvancedSearchFilters + +```typescript +interface SearchConfig { + // 연결된 DataView + targetDataView: string; + + // 검색 모드 + mode: "simple" | "advanced" | "combined"; + + // 검색 필드 + fields?: { + columnName: string; + label: string; + searchType: "text" | "exact" | "range" | "select" | "date_range" | "entity"; + defaultExpanded?: boolean; + }[]; + + // 퀵 필터 (버튼 형태) + quickFilters?: { + label: string; + filter: { column: string; operator: string; value: any }; + }[]; +} +``` + +--- + +### 3.7 Modal (통합 모달) + +**대체 대상**: universal-form-modal, repeat-screen-modal, selected-items-detail-input + +```typescript +interface ModalConfig { + // 트리거 + trigger: "button" | "row_click" | "row_double_click" | "action"; + + // 모달 내부 + content: { + type: "form" | "screen" | "custom"; + + // form: 필드 자동 구성 + formConfig?: { + tableName: string; + mode: "create" | "edit" | "view"; + columns: string[]; // 표시할 컬럼 + layout?: "single" | "two_column"; + }; + + // screen: 다른 화면 임베딩 + screenId?: number; + + // 데이터 전달 + passData?: { + from: string; // 현재 화면의 필드/컬럼 + to: string; // 모달 내부의 필드/컬럼 + }[]; + }; + + // 크기 + size?: "sm" | "md" | "lg" | "xl" | "full"; + + // 닫힐 때 동작 + onClose?: ActionStep[]; +} +``` + +--- + +## 4. 컴포넌트 간 동적 연동 시스템 + +### 4.1 현재의 한계 + +``` +[거래처 Field] → 독립 동작 ← [납품처 Field] + 연결 없음 +``` + +### 4.2 새로운 연동 시스템: Reactive Bindings + +```typescript +interface ReactiveBinding { + id: string; + source: { + componentId: string; + event: "change" | "select" | "click" | "load"; + field?: string; + }; + target: { + componentId: string; + action: "filter" | "setValue" | "show" | "hide" | "enable" | "disable" | "refresh"; + field?: string; + }; + transform?: { + type: "direct" | "lookup" | "calculate" | "condition"; + expression?: string; + }; +} +``` + +**사용자가 설정하는 방식**: + +``` +┌─────────────────────────────────────────────┐ +│ 연동 설정 │ +├─────────────────────────────────────────────┤ +│ │ +│ [거래처 ●]─── 변경 시 ───→[납품처 ●] │ +│ └ 필터링: customer_code 일치 │ +│ │ +│ [수량 ●]──┐ │ +│ ├─ 계산 ──→[금액 ●] │ +│ [단가 ●]──┘ 수량 × 단가 │ +│ │ +│ [상태 ●]─── "출고완료" 일 때 ──→[삭제 ●] │ +│ └ 비활성화 │ +│ │ +│ [+ 새 연동 추가] │ +└─────────────────────────────────────────────┘ +``` + +### 4.3 마스터-디테일 자동 연결 + +현재는 SplitPanel + 하드코딩된 이벤트 처리가 필요하지만, +새로운 시스템에서는: + +``` +DataView(마스터: 수주목록) + ↕ 자동 연결 (FK: order_id) +DataView(디테일: 수주상세품목) +``` + +**설정 방법**: DataView의 `dataSource.masterField` 하나만 선택하면 자동 연결 + +--- + +## 5. 통합 설정 패널 재설계 + +### 5.1 현재 → 새로운 방식 + +**현재**: 26개의 각기 다른 ConfigPanel + +**새로운**: 1개의 통합 설정 패널 (UnifiedConfigPanel) + +``` +┌─────────────────────────────────────┐ +│ 📋 설정 [×] │ +├─────────────────────────────────────┤ +│ [기본] [데이터] [표시] [연동] [조건]│ ← 5개 탭으로 통일 +├─────────────────────────────────────┤ +│ │ +│ ◆ 기본 탭 │ +│ 컴포넌트 유형: Field │ +│ 표시 형태: [텍스트 ▼] │ +│ 라벨: [수주번호] │ +│ 필수: ☑ 읽기전용: ☐ │ +│ │ +│ ◆ 데이터 탭 │ +│ 테이블: sales_order_mng │ +│ 컬럼: order_no │ +│ 기본값: [자동생성] │ +│ │ +│ ◆ 표시 탭 │ +│ 너비: [━━━━━━○━━] 50% │ +│ 라벨 위치: [좌측 ▼] │ +│ 도움말: [...] │ +│ │ +│ ◆ 연동 탭 │ +│ 거래처 변경 시 → 납품처 필터링 │ +│ [+ 연동 추가] │ +│ │ +│ ◆ 조건 탭 │ +│ 표시 조건: [항상 ▼] │ +│ 활성 조건: [항상 ▼] │ +└─────────────────────────────────────┘ +``` + +### 5.2 설정 난이도별 UI 분리 + +``` +[간편 모드] ← 기본 (현업 관리자용) + 라벨, 필수, 읽기전용, 너비 정도만 + +[상세 모드] ← 토글로 전환 (파워유저용) + 검증 규칙, 계산식, 조건부 표시, API 연결 등 + +[개발자 모드] ← 숨겨진 모드 (개발자 전용) + 커스텀 렌더러, 이벤트 훅, CSS 오버라이드 +``` + +--- + +## 6. 캔버스/디자이너 UI 재설계 + +### 6.1 새로운 디자이너 레이아웃 + +``` +┌─────────────────────────────────────────────────────┐ +│ [← 목록] │ 수주관리 화면 │ 💾 자동저장 ON │ [미리보기]│ +├──────────┼──────────────────────────────┼────────────┤ +│ │ │ │ +│ 컴포넌트 │ ┌──────────────────┐ │ 설정 │ +│ --------│ │ │ │ -------- │ +│ 📊 Data │ │ [캔버스 영역] │ │ [기본] │ +│ 📝 Field│ │ │ │ [데이터] │ +│ ▶ Action│ │ 드래그하여 │ │ [표시] │ +│ 📐 Layout │ 컴포넌트 추가 │ │ [연동] │ +│ 📄 Display │ │ │ [조건] │ +│ 🔍 Search│ │ │ │ │ +│ 📋 Modal │ └──────────────────┘ │ │ +│ │ │ │ +│ 테이블 │ ────────────────────────── │ │ +│ --------│ [줌: 100%] [맞춤] [그리드] │ │ +│ 수주관리 │ │ │ +│ └ 컬럼들│ │ │ +├──────────┼──────────────────────────────┼────────────┤ +│ [연동 설정] 컴포넌트 간 연결 시각화 │ +│ [거래처] ─── 변경 시 ──→ [납품처] 필터링 │ +└─────────────────────────────────────────────────────┘ +``` + +### 6.2 핵심 UX 개선 사항 + +| 항목 | 현재 | 새로운 | +|------|------|--------| +| 컴포넌트 패널 | 70개 위젯 나열 | 7개 메타 컴포넌트 + 테이블 컬럼 | +| 드롭 피드백 | 없음 | 스냅 가이드라인 + 드롭 영역 하이라이트 | +| 설정 패널 | 컴포넌트별 26개 | 통합 5탭 패널 (간편/상세/개발자 모드) | +| 빈 캔버스 | 빈 화면 | "테이블을 드래그하여 시작하세요" 가이드 | +| 줌 | 마우스 휠만 | 우하단 줌 컨트롤 (+/-/맞춤/100%) | +| 저장 | 수동만 | 자동 저장 + 수동 저장 | +| 연동 | 코드 필요 | 하단 연동 패널에서 시각적 설정 | +| 미리보기 | 새 창 | 인라인 미리보기 모드 (디자인↔미리보기 전환) | + +### 6.3 "테이블 드롭" 자동 화면 생성 + +테이블을 캔버스에 드롭하면 자동으로 화면 구성: + +``` +[sales_order_mng 테이블 드롭] + ↓ +┌─────────────────────────────────────┐ +│ 🪄 화면 자동 생성 │ +├─────────────────────────────────────┤ +│ 어떤 형태로 만들까요? │ +│ │ +│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │목록형│ │폼형 │ │마디 │ │카드 │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ ≡≡≡ │ │ □□ │ │≡≡│□│ │ ▦▦▦ │ │ +│ │ ≡≡≡ │ │ □□ │ │≡≡│□│ │ ▦▦▦ │ │ +│ └─────┘ └─────┘ └─────┘ └─────┘ │ +│ 테이블 입력폼 마스터 카드 그리드│ +│ + CRUD + 저장 디테일 + 필터 │ +│ │ +│ 포함할 컬럼: │ +│ ☑ order_no (수주번호) │ +│ ☑ customer_code (거래처) → entity │ +│ ☑ order_date (수주일) → date │ +│ ☑ quantity (수량) → number │ +│ ☐ created_by (생성자) [숨김 추천] │ +│ ☐ updated_at (수정일) [숨김 추천] │ +│ │ +│ [생성하기] │ +└─────────────────────────────────────┘ +``` + +**생성 결과** (목록형 선택 시): + +``` +자동 생성되는 컴포넌트: +1. Search 컴포넌트 (검색바) +2. Action 컴포넌트 그룹 (등록/수정/삭제 버튼) +3. DataView 컴포넌트 (테이블 뷰, 선택한 컬럼 포함) +4. Modal 컴포넌트 (등록/수정용 폼 모달, Field 자동 구성) +``` + +--- + +## 7. 마이그레이션 전략 + +### 7.1 기존 레이아웃 호환 + +기존 1,407개 레이아웃(version: "2.0")은 유지하면서 새 시스템 도입: + +```typescript +// 레이아웃 로드 시 자동 변환 +function loadLayout(data: LayoutData): LayoutData { + if (data.version === "2.0") { + // 기존 컴포넌트 → 메타 컴포넌트 매핑 + return migrateTo3_0(data); + } + return data; +} + +function migrateTo3_0(data: LayoutData): LayoutData { + const newComponents = data.components.map(comp => { + // text-input, number-input, date-input 등 → Field + if (isInputComponent(comp.componentType)) { + return convertToField(comp); + } + // table-list, v2-table-list 등 → DataView + if (isDataComponent(comp.componentType)) { + return convertToDataView(comp); + } + // button-primary → Action + if (isButtonComponent(comp.componentType)) { + return convertToAction(comp); + } + return comp; // 변환 불가 시 기존 유지 + }); + + return { ...data, version: "3.0", components: newComponents }; +} +``` + +### 7.2 단계적 전환 계획 + +``` +Phase A: 메타 컴포넌트 코어 개발 (3~4주) + - Field, DataView, Action 핵심 3개 먼저 + - 통합 설정 패널 (UnifiedConfigPanel) + - Reactive Bindings 엔진 + +Phase B: 자동 생성 시스템 (2~3주) + - 테이블 드롭 → 화면 자동 생성 + - 컬럼 webType → Field 자동 변환 + - FK 관계 → 연동 자동 설정 + +Phase C: 나머지 메타 컴포넌트 (2~3주) + - Layout, Display, Search, Modal + - 뷰 모드 전환 (테이블↔카드↔트리) + +Phase D: 마이그레이션 & 정리 (2주) + - 기존 레이아웃 자동 변환 + - 레거시 컴포넌트 정리 + - 사용자 가이드 작성 +``` + +### 7.3 공존 기간 + +``` +[현재] → [Phase A~C] → [Phase D] → [완료] +70개 위젯 70개 위젯 70개 위젯 7개 메타 + + 7개 메타 + 7개 메타 (기본) (기존 호환 유지) + (선택적 사용) (기존은 마이그레이션) +``` + +--- + +## 8. 기존 컴포넌트 → 메타 컴포넌트 매핑표 + +### 8.1 Field로 통합 (18개 → 1개) + +| 기존 컴포넌트 | webType 매핑 | 비고 | +|---------------|-------------|------| +| text-input | text | | +| number-input | number | | +| date-input | date | | +| select-basic | select | options 자동 감지 | +| checkbox-basic | checkbox | | +| radio-basic | radio | | +| textarea-basic | textarea | | +| file-upload | file | | +| entity-search-input | entity | join 자동 감지 | +| autocomplete-search-input | entity | searchable: true | +| slider-basic | slider | | +| toggle-switch | toggle | | +| numbering-rule | numbering | autoGeneration 설정 | +| image-widget | file | accept: "image/*" | +| v2-input | text/number/email/tel/url | inputType에 따라 | +| v2-select | select | | +| v2-date | date/datetime | | +| v2-file-upload | file | | + +### 8.2 DataView로 통합 (14개 → 1개) + +| 기존 컴포넌트 | viewMode 매핑 | 비고 | +|---------------|-------------|------| +| table-list | table | | +| v2-table-list | table | | +| v2-table-grouped | table | groupBy 설정 | +| v2-repeater | table | editableColumns 설정 | +| simple-repeater-table | table | inline edit | +| modal-repeater-table | table + modal | | +| card-display | card | | +| v2-card-display | card | | +| pivot-grid | pivot | | +| v2-pivot-grid | pivot | | +| v2-bom-tree | tree | | +| aggregation-widget | table | summaryColumns 설정 | +| v2-aggregation-widget | table | summaryColumns 설정 | +| v2-timeline-scheduler | timeline | | + +### 8.3 Action으로 통합 (4개 → 1개) + +| 기존 컴포넌트 | 비고 | +|---------------|------| +| button-primary | steps 설정 | +| v2-button-primary | steps 설정 | +| flow-widget | flowMove step | +| related-data-buttons | navigate step | + +### 8.4 Layout으로 통합 (12개 → 1개) + +| 기존 컴포넌트 | mode 매핑 | +|---------------|-----------| +| split-panel-layout | columns | +| v2-split-panel-layout | columns | +| screen-split-panel | columns (화면 임베딩) | +| tabs | tabs | +| v2-tabs-widget | tabs | +| section-card | card | +| v2-section-card | card | +| section-paper | card (variant) | +| v2-section-paper | card (variant) | +| conditional-container | conditional | +| accordion-basic | accordion | +| repeat-container | rows (반복) | +| v2-repeat-container | rows (반복) | + +### 8.5 Display로 통합 (12개 → 1개) + +| 기존 컴포넌트 | displayType 매핑 | +|---------------|-----------------| +| text-display | text | +| v2-text-display | text | +| divider-line | divider | +| v2-divider-line | divider | +| v2-split-line | divider | +| badge | badge | +| alert | alert | +| stats-card | stat | +| chart | chart | +| image-display | image | +| progress-bar | progress | +| v2-media | image | + +### 8.6 특수 컴포넌트 (별도 유지 또는 DataView 확장) + +| 기존 컴포넌트 | 처리 방안 | +|---------------|-----------| +| v2-bom-item-editor | DataView(tree) + inline edit | +| rack-structure | 별도 유지 (특수 시각화) | +| v2-rack-structure | 별도 유지 | +| v2-location-swap-selector | 별도 유지 (특수 UI) | +| location-swap-selector | 별도 유지 | +| map | 별도 유지 (지도) | +| category-manager | DataView(tree) + Action 조합 | +| v2-category-manager | DataView(tree) + Action 조합 | +| customer-item-mapping | DataView + Search 조합 | +| tax-invoice-list | DataView + Action + Modal 조합 | +| mail-recipient-selector | 별도 유지 (특수 UI) | +| v2-process-work-standard | DataView + Modal 조합 | +| v2-item-routing | DataView(tree) + Action 조합 | +| repeater-field-group | Layout(rows) + Field 조합 | + +--- + +## 9. 기술 스택 결정 사항 + +| 영역 | 선택 | 이유 | +|------|------|------| +| 상태 관리 | Zustand | 경량, 보일러플레이트 적음, React에 최적화 | +| DnD | @dnd-kit | 터치 지원, 커스텀 오버레이, 접근성 | +| 연동 엔진 | 자체 구현 (EventBus 확장) | 기존 EventBus 활용, 프로젝트 특화 | +| 설정 UI | Schema-driven form | configSchema 기반 자동 폼 생성 | +| 캔버스 | CSS Grid + 절대 좌표 하이브리드 | 기존 호환 + 반응형 전환 | + +--- + +## 10. 성공 지표 + +| 지표 | 현재 | 목표 | +|------|------|------| +| 새 화면 만드는 시간 (현업) | 불가능 (개발자 필요) | 5~10분 | +| 새 화면 만드는 시간 (개발자) | 15~30분 | 2~5분 | +| 새 위젯 타입 추가 시 개발 공수 | 1~3일 (5~8파일) | 대부분 설정으로 해결, 특수한 경우만 개발 | +| 컴포넌트 패널 항목 수 | 70개+ (혼란) | 7개 메타 + 테이블 컬럼 | +| ConfigPanel 파일 수 | 26개 | 1개 (UnifiedConfigPanel) | +| 컴포넌트 간 연동 설정 | 코드 필요 | UI로 클릭 설정 | +| ScreenDesigner.tsx 줄 수 | 7,593줄 | 1,500줄 이하 (훅 분리) | + +--- + +## 11. 위험 요소 + +| 위험 | 영향 | 완화 방안 | +|------|------|-----------| +| 기존 1,407개 레이아웃 마이그레이션 실패 | 운영 중단 | 자동 변환 + 수동 검증 + 롤백 가능 | +| 메타 컴포넌트가 모든 케이스를 커버 못 함 | 특수 기능 불가 | 특수 컴포넌트 별도 유지 (8.6 참고) | +| 통합 설정 패널이 오히려 복잡해질 수 있음 | UX 저하 | 간편/상세/개발자 3단계 모드 분리 | +| Reactive Bindings 디버깅 어려움 | 유지보수 | 연동 시각화 패널 + 로그 제공 | +| 개발 기간 장기화 | 일정 지연 | Phase별 독립 배포 가능하게 설계 | + +--- + +## 12. 결론 + +**현재 시스템의 본질적 한계**: 기능 = 컴포넌트. 기능이 늘면 컴포넌트가 늘고, 코드가 늘고, 복잡도가 늘어남. + +**새로운 방향**: 7개의 메타 컴포넌트(Field, DataView, Action, Layout, Display, Search, Modal)로 통합하고, 컴포넌트 간 Reactive Bindings로 동적 연동을 지원. + +**사용자가 얻는 것**: 테이블 드롭 한 번으로 기본 화면 완성, 설정 몇 번으로 업무에 맞게 커스터마이징, 개발자 없이 화면 변경 가능. + +**개발팀이 얻는 것**: 새 기능 요청 시 코드 작성 대신 설정 가이드 제공, ConfigPanel 26개 → 1개로 유지보수 대폭 감소, 7,593줄 거대 파일 분리. + +전체 예상 기간: **9~12주** (Phase A~D) diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 7f94bca0..b39e82fe 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -98,6 +98,7 @@ import StyleEditor from "./StyleEditor"; import { RealtimePreview } from "./RealtimePreviewDynamic"; import FloatingPanel from "./FloatingPanel"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; +import { V2ErrorBoundary } from "@/lib/v2-core/components/V2ErrorBoundary"; import { MultilangSettingsModal } from "./modals/MultilangSettingsModal"; import DesignerToolbar from "./DesignerToolbar"; import TablesPanel from "./panels/TablesPanel"; @@ -2089,8 +2090,31 @@ export default function ScreenDesigner({ mainTableName: currentMainTableName, // 화면의 기본 테이블 }; + // 🛡️ 대형 레이아웃 경고 (100개 이상 컴포넌트) + const totalComponents = updatedComponents.length; + if (totalComponents >= 100) { + toast.warning(`컴포넌트 개수가 많습니다 (${totalComponents}개). 저장/로드 시간이 길어질 수 있습니다.`, { + duration: 5000, + }); + } + // V2/POP API 사용 여부에 따라 분기 const v2Layout = convertLegacyToV2(layoutWithResolution); + + // JSON 페이로드 크기 검증 (5MB 경고, 10MB 제한) + const payloadSize = JSON.stringify(v2Layout).length; + const MAX_WARNING_SIZE = 5 * 1024 * 1024; // 5MB + const MAX_PAYLOAD_SIZE = 10 * 1024 * 1024; // 10MB + + if (payloadSize > MAX_PAYLOAD_SIZE) { + toast.error(`레이아웃 크기가 너무 큽니다 (${(payloadSize / 1024 / 1024).toFixed(2)}MB / 10MB 제한). 레이아웃을 분할하거나 컴포넌트를 줄여주세요.`); + return; // 저장 중단 + } else if (payloadSize > MAX_WARNING_SIZE) { + toast.warning(`레이아웃 크기가 큽니다 (${(payloadSize / 1024 / 1024).toFixed(2)}MB). 저장에 시간이 걸릴 수 있습니다.`, { + duration: 5000, + }); + } + if (USE_POP_API) { // POP 모드: screen_layouts_pop 테이블에 저장 await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); @@ -7012,25 +7036,33 @@ export default function ScreenDesigner({ }; return ( - handleComponentClick(component, e)} - onDoubleClick={(e) => handleComponentDoubleClick(component, e)} - onDragStart={(e) => startComponentDrag(component, e)} - onDragEnd={endDrag} - selectedScreen={selectedScreen} - tableName={selectedScreen?.tableName} // 🆕 디자인 모드에서도 옵션 로딩을 위해 전달 - menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 - // onZoneComponentDrop 제거 - onZoneClick={handleZoneClick} - // 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영) - onConfigChange={(config) => { + + handleComponentClick(component, e)} + onDoubleClick={(e) => handleComponentDoubleClick(component, e)} + onDragStart={(e) => startComponentDrag(component, e)} + onDragEnd={endDrag} + selectedScreen={selectedScreen} + tableName={selectedScreen?.tableName} // 🆕 디자인 모드에서도 옵션 로딩을 위해 전달 + menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 + // onZoneComponentDrop 제거 + onZoneClick={handleZoneClick} + // 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영) + onConfigChange={(config) => { // console.log("📤 테이블 설정 변경을 상세설정에 반영:", config); // 컴포넌트의 componentConfig 업데이트 @@ -7220,6 +7252,7 @@ export default function ScreenDesigner({ ); })} + ); })} diff --git a/frontend/lib/schemas/componentConfig.ts b/frontend/lib/schemas/componentConfig.ts index 2fd03bd4..a5d5c50b 100644 --- a/frontend/lib/schemas/componentConfig.ts +++ b/frontend/lib/schemas/componentConfig.ts @@ -1022,6 +1022,27 @@ export function loadLayoutV2(layoutData: any): LayoutV2 & { components: Array }>; layers: Array }> }>; } { + // 🛡️ Version 엄격 검증 + if (!layoutData || typeof layoutData !== "object") { + throw new Error("레이아웃 데이터가 올바르지 않습니다."); + } + + const version = layoutData.version; + const ALLOWED_VERSIONS = ["2.0", "2.1"]; + + if (!version || !ALLOWED_VERSIONS.includes(version)) { + throw new Error(`지원하지 않는 레이아웃 버전입니다 (${version}). 지원 버전: ${ALLOWED_VERSIONS.join(", ")}`); + } + + // 컴포넌트 배열 검증 + if (layoutData.components && !Array.isArray(layoutData.components)) { + throw new Error("components는 배열이어야 합니다."); + } + + if (layoutData.layers && !Array.isArray(layoutData.layers)) { + throw new Error("layers는 배열이어야 합니다."); + } + const parsed = layoutV2Schema.parse(layoutData || { version: "2.1", components: [], layers: [] }); // 마이그레이션: components만 있고 layers가 없는 경우 Default Layer 생성 @@ -1061,6 +1082,16 @@ export function saveLayoutV2( components: Array }>, layers?: Array }> }>, ): LayoutV2 { + // 🛡️ 입력값 검증 (백엔드 전송 전 pre-validation) + const totalComponents = layers + ? layers.reduce((sum, layer) => sum + layer.components.length, 0) + : components.length; + + // 컴포넌트 개수 제한 (1000개) + if (totalComponents > 1000) { + throw new Error(`컴포넌트 개수 제한 초과 (${totalComponents}/1000). 레이아웃을 분할하거나 불필요한 컴포넌트를 제거하세요.`); + } + // 레이어가 있는 경우 레이어 구조 저장 if (layers && layers.length > 0) { const savedLayers = layers.map((layer) => ({ @@ -1068,11 +1099,19 @@ export function saveLayoutV2( components: layer.components.map(saveComponentV2), })); - return { + const layout: LayoutV2 = { version: "2.1", layers: savedLayers, components: savedLayers.flatMap((l) => l.components), // 하위 호환성 }; + + // JSON 크기 검증 (10MB 제한) + const jsonSize = JSON.stringify(layout).length; + if (jsonSize > 10_000_000) { + throw new Error(`레이아웃 크기 제한 초과 (${(jsonSize / 1024 / 1024).toFixed(2)}MB / 10MB). 레이아웃을 분할하세요.`); + } + + return layout; } // 레이어가 없는 경우 (기존 방식) - Default Layer로 감싸서 저장 @@ -1087,9 +1126,17 @@ export function saveLayoutV2( components: savedComponents, }; - return { + const layout: LayoutV2 = { version: "2.1", layers: [defaultLayer], components: savedComponents, }; + + // JSON 크기 검증 + const jsonSize = JSON.stringify(layout).length; + if (jsonSize > 10_000_000) { + throw new Error(`레이아웃 크기 제한 초과 (${(jsonSize / 1024 / 1024).toFixed(2)}MB / 10MB).`); + } + + return layout; } diff --git a/frontend/lib/v2-core/components/V2ErrorBoundary.tsx b/frontend/lib/v2-core/components/V2ErrorBoundary.tsx index d411f9c9..51941173 100644 --- a/frontend/lib/v2-core/components/V2ErrorBoundary.tsx +++ b/frontend/lib/v2-core/components/V2ErrorBoundary.tsx @@ -153,34 +153,38 @@ export class V2ErrorBoundary extends Component< } private renderCompactFallback(): ReactNode { - const { componentType, recoverable = true } = this.props; + const { componentType, componentId, recoverable = true } = this.props; const { error } = this.state; return ( -
-
- - - {componentType} 로드 실패 - +
+
+
+ +
+

+ {componentType || "컴포넌트"} 오류 +

+ {error && ( +

+ {error.message.substring(0, 60)} + {error.message.length > 60 ? "..." : ""} +

+ )} +
+
+ {recoverable && ( + + )}
- {error && ( -

- {error.message.substring(0, 100)} - {error.message.length > 100 ? "..." : ""} -

- )} - {recoverable && ( - - )}
); }