diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index a28712c1..c8e8ce82 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3245,6 +3245,7 @@ export const resetUserPassword = async ( /** * 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용) + * column_labels 테이블에서 라벨 정보도 함께 가져옴 */ export async function getTableSchema( req: AuthenticatedRequest, @@ -3264,20 +3265,25 @@ export async function getTableSchema( logger.info("테이블 스키마 조회", { tableName, companyCode }); - // information_schema에서 컬럼 정보 가져오기 + // information_schema와 column_labels를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기 const schemaQuery = ` SELECT - column_name, - data_type, - is_nullable, - column_default, - character_maximum_length, - numeric_precision, - numeric_scale - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = $1 - ORDER BY ordinal_position + ic.column_name, + ic.data_type, + ic.is_nullable, + ic.column_default, + ic.character_maximum_length, + ic.numeric_precision, + ic.numeric_scale, + cl.column_label, + cl.display_order + FROM information_schema.columns ic + LEFT JOIN column_labels cl + ON cl.table_name = ic.table_name + AND cl.column_name = ic.column_name + WHERE ic.table_schema = 'public' + AND ic.table_name = $1 + ORDER BY COALESCE(cl.display_order, ic.ordinal_position), ic.ordinal_position `; const columns = await query(schemaQuery, [tableName]); @@ -3290,9 +3296,10 @@ export async function getTableSchema( return; } - // 컬럼 정보를 간단한 형태로 변환 + // 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함) const columnList = columns.map((col: any) => ({ name: col.column_name, + label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용 type: col.data_type, nullable: col.is_nullable === "YES", default: col.column_default, diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index 00727f1d..fbb88750 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -424,18 +424,16 @@ export class EntityJoinController { config.referenceTable ); - // 현재 display_column으로 사용 중인 컬럼 제외 + // 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음) const currentDisplayColumn = config.displayColumn || config.displayColumns[0]; - const availableColumns = columns.filter( - (col) => col.columnName !== currentDisplayColumn - ); - + + // 모든 컬럼 표시 (기본 표시 컬럼도 포함) return { joinConfig: config, tableName: config.referenceTable, currentDisplayColumn: currentDisplayColumn, - availableColumns: availableColumns.map((col) => ({ + availableColumns: columns.map((col) => ({ columnName: col.columnName, columnLabel: col.displayName || col.columnName, dataType: col.dataType, diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts index de4eb913..7aa1d825 100644 --- a/backend-node/src/routes/cascadingAutoFillRoutes.ts +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -51,3 +51,4 @@ router.get("/data/:groupCode", getAutoFillData); export default router; + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts index c2f12782..5f57c6ca 100644 --- a/backend-node/src/routes/cascadingConditionRoutes.ts +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -47,3 +47,4 @@ router.get("/filtered-options/:relationCode", getFilteredOptions); export default router; + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts index 71e6c418..b0e3c79a 100644 --- a/backend-node/src/routes/cascadingHierarchyRoutes.ts +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -63,3 +63,4 @@ router.get("/:groupCode/options/:levelOrder", getLevelOptions); export default router; + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts index d92d7d72..0cec35d2 100644 --- a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -51,3 +51,4 @@ router.get("/options/:exclusionCode", getExcludedOptions); export default router; + diff --git a/backend-node/src/services/externalDbConnectionPoolService.ts b/backend-node/src/services/externalDbConnectionPoolService.ts index 73077ef1..f35150ac 100644 --- a/backend-node/src/services/externalDbConnectionPoolService.ts +++ b/backend-node/src/services/externalDbConnectionPoolService.ts @@ -164,8 +164,8 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { } try { - const [rows] = await this.pool.execute(sql, params); - return rows; + const [rows] = await this.pool.execute(sql, params); + return rows; } catch (error: any) { // 연결 닫힘 오류 감지 if ( diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index a0e707c1..b12d7a4a 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -332,6 +332,8 @@ export class MenuCopyService { /** * 플로우 수집 + * - 화면 레이아웃에서 참조된 모든 flowId 수집 + * - dataflowConfig.flowConfig.flowId 및 selectedDiagramId 모두 수집 */ private async collectFlows( screenIds: Set, @@ -340,6 +342,7 @@ export class MenuCopyService { logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`); const flowIds = new Set(); + const flowDetails: Array<{ flowId: number; flowName: string; screenId: number }> = []; for (const screenId of screenIds) { const layoutsResult = await client.query( @@ -352,13 +355,35 @@ export class MenuCopyService { // webTypeConfig.dataflowConfig.flowConfig.flowId const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; - if (flowId) { - flowIds.add(flowId); + const flowName = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || "Unknown"; + + if (flowId && typeof flowId === "number" && flowId > 0) { + if (!flowIds.has(flowId)) { + flowIds.add(flowId); + flowDetails.push({ flowId, flowName, screenId }); + logger.info(` 📎 화면 ${screenId}에서 플로우 발견: id=${flowId}, name="${flowName}"`); + } + } + + // selectedDiagramId도 확인 (flowId와 동일할 수 있지만 다를 수도 있음) + const selectedDiagramId = props?.webTypeConfig?.dataflowConfig?.selectedDiagramId; + if (selectedDiagramId && typeof selectedDiagramId === "number" && selectedDiagramId > 0) { + if (!flowIds.has(selectedDiagramId)) { + flowIds.add(selectedDiagramId); + flowDetails.push({ flowId: selectedDiagramId, flowName: "SelectedDiagram", screenId }); + logger.info(` 📎 화면 ${screenId}에서 selectedDiagramId 발견: id=${selectedDiagramId}`); + } } } } - logger.info(`✅ 플로우 수집 완료: ${flowIds.size}개`); + if (flowIds.size > 0) { + logger.info(`✅ 플로우 수집 완료: ${flowIds.size}개`); + logger.info(` 📋 수집된 flowIds: [${Array.from(flowIds).join(", ")}]`); + } else { + logger.info(`📭 수집된 플로우 없음 (화면에 플로우 참조가 없음)`); + } + return flowIds; } @@ -473,15 +498,21 @@ export class MenuCopyService { } } - // flowId 매핑 (숫자 또는 숫자 문자열) - if (key === "flowId") { + // flowId, selectedDiagramId 매핑 (숫자 또는 숫자 문자열) + // selectedDiagramId는 dataflowConfig에서 flowId와 동일한 값을 참조하므로 함께 변환 + if (key === "flowId" || key === "selectedDiagramId") { const numValue = typeof value === "number" ? value : parseInt(value); - if (!isNaN(numValue)) { + if (!isNaN(numValue) && numValue > 0) { const newId = flowIdMap.get(numValue); if (newId) { obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지 - logger.debug( - ` 🔗 플로우 참조 업데이트 (${currentPath}): ${value} → ${newId}` + logger.info( + ` 🔗 플로우 참조 업데이트 (${currentPath}): ${value} → ${newId}` + ); + } else { + // 매핑이 없으면 경고 로그 + logger.warn( + ` ⚠️ 플로우 매핑 없음 (${currentPath}): ${value} - 원본 플로우가 복사되지 않았을 수 있음` ); } } @@ -742,6 +773,8 @@ export class MenuCopyService { /** * 플로우 복사 + * - 대상 회사에 같은 이름+테이블의 플로우가 있으면 재사용 (ID 매핑만) + * - 없으면 새로 복사 */ private async copyFlows( flowIds: Set, @@ -757,10 +790,11 @@ export class MenuCopyService { } logger.info(`🔄 플로우 복사 중: ${flowIds.size}개`); + logger.info(` 📋 복사 대상 flowIds: [${Array.from(flowIds).join(", ")}]`); for (const originalFlowId of flowIds) { try { - // 1) flow_definition 조회 + // 1) 원본 flow_definition 조회 const flowDefResult = await client.query( `SELECT * FROM flow_definition WHERE id = $1`, [originalFlowId] @@ -772,8 +806,29 @@ export class MenuCopyService { } const flowDef = flowDefResult.rows[0]; + logger.info(` 🔍 원본 플로우 발견: id=${originalFlowId}, name="${flowDef.name}", table="${flowDef.table_name}", company="${flowDef.company_code}"`); - // 2) flow_definition 복사 + // 2) 대상 회사에 이미 같은 이름+테이블의 플로우가 있는지 확인 + const existingFlowResult = await client.query<{ id: number }>( + `SELECT id FROM flow_definition + WHERE company_code = $1 AND name = $2 AND table_name = $3 + LIMIT 1`, + [targetCompanyCode, flowDef.name, flowDef.table_name] + ); + + let newFlowId: number; + + if (existingFlowResult.rows.length > 0) { + // 기존 플로우가 있으면 재사용 + newFlowId = existingFlowResult.rows[0].id; + flowIdMap.set(originalFlowId, newFlowId); + logger.info( + ` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${newFlowId} (${flowDef.name})` + ); + continue; // 스텝/연결 복사 생략 (기존 것 사용) + } + + // 3) 새 flow_definition 복사 const newFlowResult = await client.query<{ id: number }>( `INSERT INTO flow_definition ( name, description, table_name, is_active, @@ -792,11 +847,11 @@ export class MenuCopyService { ] ); - const newFlowId = newFlowResult.rows[0].id; + newFlowId = newFlowResult.rows[0].id; flowIdMap.set(originalFlowId, newFlowId); logger.info( - ` ✅ 플로우 복사: ${originalFlowId} → ${newFlowId} (${flowDef.name})` + ` ✅ 플로우 신규 복사: ${originalFlowId} → ${newFlowId} (${flowDef.name})` ); // 3) flow_step 복사 diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 9fc0f079..92a35663 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1751,7 +1751,7 @@ export class ScreenManagementService { // 기타 label: "text-display", code: "select-basic", - entity: "select-basic", + entity: "entity-search-input", // 엔티티는 entity-search-input 사용 category: "select-basic", }; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 9a8623a0..e2f26138 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1447,7 +1447,8 @@ export class TableManagementService { tableName, columnName, actualValue, - paramIndex + paramIndex, + operator // operator 전달 (equals면 직접 매칭) ); default: @@ -1676,7 +1677,8 @@ export class TableManagementService { tableName: string, columnName: string, value: any, - paramIndex: number + paramIndex: number, + operator: string = "contains" // 연결 필터에서 "equals"로 전달되면 직접 매칭 ): Promise<{ whereClause: string; values: any[]; @@ -1688,7 +1690,7 @@ export class TableManagementService { columnName ); - // 🆕 배열 처리: IN 절 사용 + // 배열 처리: IN 절 사용 if (Array.isArray(value)) { if (value.length === 0) { // 빈 배열이면 항상 false 조건 @@ -1720,13 +1722,35 @@ export class TableManagementService { } if (typeof value === "string" && value.trim() !== "") { - const displayColumn = entityTypeInfo.displayColumn || "name"; + // equals 연산자인 경우: 직접 값 매칭 (연결 필터에서 코드 값으로 필터링 시 사용) + if (operator === "equals") { + logger.info( + `🔍 [buildEntitySearchCondition] equals 연산자 - 직접 매칭: ${columnName} = ${value}` + ); + return { + whereClause: `${columnName} = $${paramIndex}`, + values: [value], + paramCount: 1, + }; + } + + // contains 연산자 (기본): 참조 테이블의 표시 컬럼으로 검색 const referenceColumn = entityTypeInfo.referenceColumn || "id"; + const referenceTable = entityTypeInfo.referenceTable; + + // displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직) + let displayColumn = entityTypeInfo.displayColumn; + if (!displayColumn || displayColumn === "none" || displayColumn === "") { + displayColumn = await this.findDisplayColumnForTable(referenceTable, referenceColumn); + logger.info( + `🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}` + ); + } // 참조 테이블의 표시 컬럼으로 검색 return { whereClause: `EXISTS ( - SELECT 1 FROM ${entityTypeInfo.referenceTable} ref + SELECT 1 FROM ${referenceTable} ref WHERE ref.${referenceColumn} = ${columnName} AND ref.${displayColumn} ILIKE $${paramIndex} )`, @@ -1754,6 +1778,66 @@ export class TableManagementService { } } + /** + * 참조 테이블에서 표시 컬럼 자동 감지 (entityJoinService와 동일한 우선순위) + * 우선순위: *_name > name > label/*_label > title > referenceColumn + */ + private async findDisplayColumnForTable( + tableName: string, + referenceColumn?: string + ): Promise { + try { + const result = await query<{ column_name: string }>( + `SELECT column_name + FROM information_schema.columns + WHERE table_name = $1 + AND table_schema = 'public' + ORDER BY ordinal_position`, + [tableName] + ); + + const allColumns = result.map((r) => r.column_name); + + // entityJoinService와 동일한 우선순위 + // 1. *_name 컬럼 (item_name, customer_name, process_name 등) - company_name 제외 + const nameColumn = allColumns.find( + (col) => col.endsWith("_name") && col !== "company_name" + ); + if (nameColumn) { + return nameColumn; + } + + // 2. name 컬럼 + if (allColumns.includes("name")) { + return "name"; + } + + // 3. label 또는 *_label 컬럼 + const labelColumn = allColumns.find( + (col) => col === "label" || col.endsWith("_label") + ); + if (labelColumn) { + return labelColumn; + } + + // 4. title 컬럼 + if (allColumns.includes("title")) { + return "title"; + } + + // 5. 참조 컬럼 (referenceColumn) + if (referenceColumn && allColumns.includes(referenceColumn)) { + return referenceColumn; + } + + // 6. 기본값: 첫 번째 비-id 컬럼 또는 id + return allColumns.find((col) => col !== "id") || "id"; + } catch (error) { + logger.error(`표시 컬럼 감지 실패: ${tableName}`, error); + return referenceColumn || "id"; // 오류 시 기본값 + } + } + /** * 불린 검색 조건 구성 */ diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index 985d730a..a181ac21 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -583,3 +583,4 @@ const result = await executeNodeFlow(flowId, { + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md index 285dc6ba..916fbc54 100644 --- a/docs/메일발송_기능_사용_가이드.md +++ b/docs/메일발송_기능_사용_가이드.md @@ -356,3 +356,4 @@ - [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가? + diff --git a/docs/즉시저장_버튼_액션_구현_계획서.md b/docs/즉시저장_버튼_액션_구현_계획서.md new file mode 100644 index 00000000..6ce86286 --- /dev/null +++ b/docs/즉시저장_버튼_액션_구현_계획서.md @@ -0,0 +1,345 @@ +# 즉시 저장(quickInsert) 버튼 액션 구현 계획서 + +## 1. 개요 + +### 1.1 목적 +화면에서 entity 타입 선택박스로 데이터를 선택한 후, 버튼 클릭 시 특정 테이블에 즉시 INSERT하는 기능 구현 + +### 1.2 사용 사례 +- **공정별 설비 관리**: 좌측에서 공정 선택 → 우측에서 설비 선택 → "설비 추가" 버튼 클릭 → `process_equipment` 테이블에 즉시 저장 + +### 1.3 화면 구성 예시 +``` +┌─────────────────────────────────────────────────────────────┐ +│ [entity 선택박스] [버튼: quickInsert] │ +│ ┌─────────────────────────────┐ ┌──────────────┐ │ +│ │ MCT-01 - 머시닝센터 #1 ▼ │ │ + 설비 추가 │ │ +│ └─────────────────────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 기술 설계 + +### 2.1 버튼 액션 타입 추가 + +```typescript +// types/screen-management.ts +type ButtonActionType = + | "save" + | "cancel" + | "delete" + | "edit" + | "add" + | "search" + | "reset" + | "submit" + | "close" + | "popup" + | "navigate" + | "custom" + | "quickInsert" // 🆕 즉시 저장 +``` + +### 2.2 quickInsert 설정 구조 + +```typescript +interface QuickInsertColumnMapping { + targetColumn: string; // 저장할 테이블의 컬럼명 + sourceType: "component" | "leftPanel" | "fixed" | "currentUser"; + + // sourceType별 추가 설정 + sourceComponentId?: string; // component: 값을 가져올 컴포넌트 ID + sourceColumn?: string; // leftPanel: 좌측 선택 데이터의 컬럼명 + fixedValue?: any; // fixed: 고정값 + userField?: string; // currentUser: 사용자 정보 필드 (userId, userName, companyCode) +} + +interface QuickInsertConfig { + targetTable: string; // 저장할 테이블명 + columnMappings: QuickInsertColumnMapping[]; + + // 저장 후 동작 + afterInsert?: { + refreshRightPanel?: boolean; // 우측 패널 새로고침 + clearComponents?: string[]; // 초기화할 컴포넌트 ID 목록 + showSuccessMessage?: boolean; // 성공 메시지 표시 + successMessage?: string; // 커스텀 성공 메시지 + }; + + // 중복 체크 (선택사항) + duplicateCheck?: { + enabled: boolean; + columns: string[]; // 중복 체크할 컬럼들 + errorMessage?: string; // 중복 시 에러 메시지 + }; +} + +interface ButtonComponentConfig { + // 기존 설정들... + actionType: ButtonActionType; + + // 🆕 quickInsert 전용 설정 + quickInsertConfig?: QuickInsertConfig; +} +``` + +### 2.3 데이터 흐름 + +``` +1. 사용자가 entity 선택박스에서 설비 선택 + └─ equipment_code = "EQ-001" (내부값) + └─ 표시: "MCT-01 - 머시닝센터 #1" + +2. 사용자가 "설비 추가" 버튼 클릭 + +3. quickInsert 핸들러 실행 + ├─ columnMappings 순회 + │ ├─ equipment_code: component에서 값 가져오기 → "EQ-001" + │ └─ process_code: leftPanel에서 값 가져오기 → "PRC-001" + │ + └─ INSERT 데이터 구성 + { + equipment_code: "EQ-001", + process_code: "PRC-001", + company_code: "COMPANY_7", // 자동 추가 + writer: "wace" // 자동 추가 + } + +4. API 호출: POST /api/table-management/tables/process_equipment/add + +5. 성공 시 + ├─ 성공 메시지 표시 + ├─ 우측 패널(카드/테이블) 새로고침 + └─ 선택박스 초기화 +``` + +--- + +## 3. 구현 계획 + +### 3.1 Phase 1: 타입 정의 및 설정 UI + +| 작업 | 파일 | 설명 | +|------|------|------| +| 1-1 | `frontend/types/screen-management.ts` | QuickInsertConfig 타입 추가 | +| 1-2 | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | quickInsert 설정 UI 추가 | + +### 3.2 Phase 2: 버튼 액션 핸들러 구현 + +| 작업 | 파일 | 설명 | +|------|------|------| +| 2-1 | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | quickInsert 핸들러 추가 | +| 2-2 | 컴포넌트 값 수집 로직 | 같은 화면의 다른 컴포넌트에서 값 가져오기 | + +### 3.3 Phase 3: 테스트 및 검증 + +| 작업 | 설명 | +|------|------| +| 3-1 | 공정별 설비 화면에서 테스트 | +| 3-2 | 중복 저장 방지 테스트 | +| 3-3 | 에러 처리 테스트 | + +--- + +## 4. 상세 구현 + +### 4.1 ButtonConfigPanel 설정 UI + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 버튼 액션 타입 │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 즉시 저장 (quickInsert) ▼ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ─────────────── 즉시 저장 설정 ─────────────── │ +│ │ +│ 대상 테이블 * │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ process_equipment ▼ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ 컬럼 매핑 [+ 추가] │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 매핑 #1 [삭제] │ │ +│ │ 대상 컬럼: equipment_code │ │ +│ │ 값 소스: 컴포넌트 선택 │ │ +│ │ 컴포넌트: [equipment-select ▼] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 매핑 #2 [삭제] │ │ +│ │ 대상 컬럼: process_code │ │ +│ │ 값 소스: 좌측 패널 데이터 │ │ +│ │ 소스 컬럼: process_code │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ─────────────── 저장 후 동작 ─────────────── │ +│ │ +│ ☑ 우측 패널 새로고침 │ +│ ☑ 선택박스 초기화 │ +│ ☑ 성공 메시지 표시 │ +│ │ +│ ─────────────── 중복 체크 (선택) ─────────────── │ +│ │ +│ ☐ 중복 체크 활성화 │ +│ 체크 컬럼: equipment_code, process_code │ +│ 에러 메시지: 이미 등록된 설비입니다. │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 4.2 핸들러 구현 (의사 코드) + +```typescript +const handleQuickInsert = async (config: QuickInsertConfig) => { + // 1. 컬럼 매핑에서 값 수집 + const insertData: Record = {}; + + for (const mapping of config.columnMappings) { + let value: any; + + switch (mapping.sourceType) { + case "component": + // 같은 화면의 컴포넌트에서 값 가져오기 + value = getComponentValue(mapping.sourceComponentId); + break; + + case "leftPanel": + // 분할 패널 좌측 선택 데이터에서 값 가져오기 + value = splitPanelContext?.selectedLeftData?.[mapping.sourceColumn]; + break; + + case "fixed": + value = mapping.fixedValue; + break; + + case "currentUser": + value = user?.[mapping.userField]; + break; + } + + if (value !== undefined && value !== null && value !== "") { + insertData[mapping.targetColumn] = value; + } + } + + // 2. 필수값 검증 + if (Object.keys(insertData).length === 0) { + toast.error("저장할 데이터가 없습니다."); + return; + } + + // 3. 중복 체크 (설정된 경우) + if (config.duplicateCheck?.enabled) { + const isDuplicate = await checkDuplicate( + config.targetTable, + config.duplicateCheck.columns, + insertData + ); + if (isDuplicate) { + toast.error(config.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다."); + return; + } + } + + // 4. API 호출 + try { + await tableTypeApi.addTableData(config.targetTable, insertData); + + // 5. 성공 후 동작 + if (config.afterInsert?.showSuccessMessage) { + toast.success(config.afterInsert.successMessage || "저장되었습니다."); + } + + if (config.afterInsert?.refreshRightPanel) { + // 우측 패널 새로고침 트리거 + onRefresh?.(); + } + + if (config.afterInsert?.clearComponents) { + // 지정된 컴포넌트 초기화 + for (const componentId of config.afterInsert.clearComponents) { + clearComponentValue(componentId); + } + } + + } catch (error) { + toast.error("저장에 실패했습니다."); + } +}; +``` + +--- + +## 5. 컴포넌트 간 통신 방안 + +### 5.1 문제점 +- 버튼 컴포넌트에서 같은 화면의 entity 선택박스 값을 가져와야 함 +- 현재는 각 컴포넌트가 독립적으로 동작 + +### 5.2 해결 방안: formData 활용 + +현재 `InteractiveScreenViewerDynamic`에서 `formData` 상태로 모든 입력값을 관리하고 있음. + +```typescript +// InteractiveScreenViewerDynamic.tsx +const [localFormData, setLocalFormData] = useState>({}); + +// entity 선택박스에서 값 변경 시 +const handleFormDataChange = (fieldName: string, value: any) => { + setLocalFormData(prev => ({ ...prev, [fieldName]: value })); +}; + +// 버튼 클릭 시 formData에서 값 가져오기 +const getComponentValue = (componentId: string) => { + // componentId로 컴포넌트의 columnName 찾기 + const component = allComponents.find(c => c.id === componentId); + if (component?.columnName) { + return formData[component.columnName]; + } + return undefined; +}; +``` + +--- + +## 6. 테스트 시나리오 + +### 6.1 정상 케이스 +1. 좌측 테이블에서 공정 "PRC-001" 선택 +2. 우측 설비 선택박스에서 "MCT-01" 선택 +3. "설비 추가" 버튼 클릭 +4. `process_equipment` 테이블에 데이터 저장 확인 +5. 우측 카드/테이블에 새 항목 표시 확인 + +### 6.2 에러 케이스 +1. 좌측 미선택 상태에서 버튼 클릭 → "좌측에서 항목을 선택해주세요" 메시지 +2. 설비 미선택 상태에서 버튼 클릭 → "설비를 선택해주세요" 메시지 +3. 중복 데이터 저장 시도 → "이미 등록된 설비입니다" 메시지 + +### 6.3 엣지 케이스 +1. 동일 설비 연속 추가 시도 +2. 네트워크 오류 시 재시도 +3. 권한 없는 사용자의 저장 시도 + +--- + +## 7. 일정 + +| Phase | 작업 | 예상 시간 | +|-------|------|----------| +| Phase 1 | 타입 정의 및 설정 UI | 1시간 | +| Phase 2 | 버튼 액션 핸들러 구현 | 1시간 | +| Phase 3 | 테스트 및 검증 | 30분 | +| **합계** | | **2시간 30분** | + +--- + +## 8. 향후 확장 가능성 + +1. **다중 행 추가**: 여러 설비를 한 번에 선택하여 추가 +2. **수정 모드**: 기존 데이터 수정 기능 +3. **조건부 저장**: 특정 조건 만족 시에만 저장 +4. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장 + diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 8510d627..dffbd75b 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -18,10 +18,11 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보 import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지 -import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션 -import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리 -import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신 -import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 🆕 분할 패널 리사이즈 +import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 테이블 옵션 +import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 높이 관리 +import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신 +import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈 +import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리 function ScreenViewPage() { const params = useParams(); @@ -307,7 +308,8 @@ function ScreenViewPage() { return ( - + +
{/* 레이아웃 준비 중 로딩 표시 */} {!layoutReady && ( @@ -786,7 +788,8 @@ function ScreenViewPage() { }} />
-
+
+
); } diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index fbf55750..194f7210 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -148,9 +148,19 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { switch (format) { case "date": - return new Date(value).toLocaleDateString("ko-KR"); + try { + const dateVal = new Date(value); + return dateVal.toLocaleDateString("ko-KR", { timeZone: "Asia/Seoul" }); + } catch { + return String(value); + } case "datetime": - return new Date(value).toLocaleString("ko-KR"); + try { + const dateVal = new Date(value); + return dateVal.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" }); + } catch { + return String(value); + } case "number": return Number(value).toLocaleString("ko-KR"); case "currency": diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index b99b58af..f511a7b1 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -2,7 +2,19 @@ import { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check, ParkingCircle } from "lucide-react"; +import { + ArrowLeft, + Save, + Loader2, + Grid3x3, + Move, + Box, + Package, + Truck, + Check, + ParkingCircle, + RefreshCw, +} from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -78,7 +90,7 @@ const DebouncedInput = ({ const handleBlur = (e: React.FocusEvent) => { setIsEditing(false); if (onCommit && debounce === 0) { - // 값이 변경되었을 때만 커밋하도록 하면 좋겠지만, + // 값이 변경되었을 때만 커밋하도록 하면 좋겠지만, // 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨) onCommit(type === "number" ? parseFloat(localValue as string) : localValue); } @@ -545,150 +557,170 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi // 레이아웃 데이터 로드 const [layoutData, setLayoutData] = useState<{ layout: any; objects: any[] } | null>(null); + const [isRefreshing, setIsRefreshing] = useState(false); - useEffect(() => { - const loadLayout = async () => { - try { - setIsLoading(true); - const response = await getLayoutById(layoutId); + // 레이아웃 로드 함수 + const loadLayout = async () => { + try { + setIsLoading(true); + const response = await getLayoutById(layoutId); - if (response.success && response.data) { - const { layout, objects } = response.data; - setLayoutData({ layout, objects }); // 레이아웃 데이터 저장 + if (response.success && response.data) { + const { layout, objects } = response.data; + setLayoutData({ layout, objects }); // 레이아웃 데이터 저장 - // 외부 DB 연결 ID 복원 - if (layout.external_db_connection_id) { - setSelectedDbConnection(layout.external_db_connection_id); - } - - // 계층 구조 설정 로드 - if (layout.hierarchy_config) { - try { - // hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용 - const config = - typeof layout.hierarchy_config === "string" - ? JSON.parse(layout.hierarchy_config) - : layout.hierarchy_config; - setHierarchyConfig(config); - - // 선택된 테이블 정보도 복원 - const newSelectedTables: any = { - warehouse: config.warehouse?.tableName || "", - area: "", - location: "", - material: "", - }; - - if (config.levels && config.levels.length > 0) { - // 레벨 1 = Area - if (config.levels[0]?.tableName) { - newSelectedTables.area = config.levels[0].tableName; - } - // 레벨 2 = Location - if (config.levels[1]?.tableName) { - newSelectedTables.location = config.levels[1].tableName; - } - } - - // 자재 테이블 정보 - if (config.material?.tableName) { - newSelectedTables.material = config.material.tableName; - } - - setSelectedTables(newSelectedTables); - } catch (e) { - console.error("계층 구조 설정 파싱 실패:", e); - } - } - - // 객체 데이터 변환 (DB -> PlacedObject) - const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({ - id: obj.id, - type: obj.object_type, - name: obj.object_name, - position: { - x: parseFloat(obj.position_x), - y: parseFloat(obj.position_y), - z: parseFloat(obj.position_z), - }, - size: { - x: parseFloat(obj.size_x), - y: parseFloat(obj.size_y), - z: parseFloat(obj.size_z), - }, - rotation: obj.rotation ? parseFloat(obj.rotation) : 0, - color: obj.color, - areaKey: obj.area_key, - locaKey: obj.loca_key, - locType: obj.loc_type, - materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, - materialPreview: - obj.loc_type === "STP" || !obj.material_preview_height - ? undefined - : { height: parseFloat(obj.material_preview_height) }, - parentId: obj.parent_id, - displayOrder: obj.display_order, - locked: obj.locked, - visible: obj.visible !== false, - hierarchyLevel: obj.hierarchy_level || 1, - parentKey: obj.parent_key, - externalKey: obj.external_key, - })); - - setPlacedObjects(loadedObjects); - - // 다음 임시 ID 설정 (기존 ID 중 최소값 - 1) - const minId = Math.min(...loadedObjects.map((o) => o.id), 0); - setNextObjectId(minId - 1); - - setHasUnsavedChanges(false); - - toast({ - title: "레이아웃 불러오기 완료", - description: `${loadedObjects.length}개의 객체를 불러왔습니다.`, - }); - - // Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달) - const dbConnectionId = layout.external_db_connection_id; - const hierarchyConfigParsed = - typeof layout.hierarchy_config === "string" - ? JSON.parse(layout.hierarchy_config) - : layout.hierarchy_config; - const materialTableName = hierarchyConfigParsed?.material?.tableName; - - const locationObjects = loadedObjects.filter( - (obj) => - (obj.type === "location-bed" || - obj.type === "location-stp" || - obj.type === "location-temp" || - obj.type === "location-dest") && - obj.locaKey, - ); - if (locationObjects.length > 0 && dbConnectionId && materialTableName) { - const locaKeys = locationObjects.map((obj) => obj.locaKey!); - setTimeout(() => { - loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName); - }, 100); - } - } else { - throw new Error(response.error || "레이아웃 조회 실패"); + // 외부 DB 연결 ID 복원 + if (layout.external_db_connection_id) { + setSelectedDbConnection(layout.external_db_connection_id); } - } catch (error) { - console.error("레이아웃 로드 실패:", error); - const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; - toast({ - variant: "destructive", - title: "오류", - description: errorMessage, - }); - } finally { - setIsLoading(false); - } - }; + // 계층 구조 설정 로드 + if (layout.hierarchy_config) { + try { + // hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용 + const config = + typeof layout.hierarchy_config === "string" + ? JSON.parse(layout.hierarchy_config) + : layout.hierarchy_config; + setHierarchyConfig(config); + + // 선택된 테이블 정보도 복원 + const newSelectedTables: any = { + warehouse: config.warehouse?.tableName || "", + area: "", + location: "", + material: "", + }; + + if (config.levels && config.levels.length > 0) { + // 레벨 1 = Area + if (config.levels[0]?.tableName) { + newSelectedTables.area = config.levels[0].tableName; + } + // 레벨 2 = Location + if (config.levels[1]?.tableName) { + newSelectedTables.location = config.levels[1].tableName; + } + } + + // 자재 테이블 정보 + if (config.material?.tableName) { + newSelectedTables.material = config.material.tableName; + } + + setSelectedTables(newSelectedTables); + } catch (e) { + console.error("계층 구조 설정 파싱 실패:", e); + } + } + + // 객체 데이터 변환 (DB -> PlacedObject) + const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({ + id: obj.id, + type: obj.object_type, + name: obj.object_name, + position: { + x: parseFloat(obj.position_x), + y: parseFloat(obj.position_y), + z: parseFloat(obj.position_z), + }, + size: { + x: parseFloat(obj.size_x), + y: parseFloat(obj.size_y), + z: parseFloat(obj.size_z), + }, + rotation: obj.rotation ? parseFloat(obj.rotation) : 0, + color: obj.color, + areaKey: obj.area_key, + locaKey: obj.loca_key, + locType: obj.loc_type, + materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, + materialPreview: + obj.loc_type === "STP" || !obj.material_preview_height + ? undefined + : { height: parseFloat(obj.material_preview_height) }, + parentId: obj.parent_id, + displayOrder: obj.display_order, + locked: obj.locked, + visible: obj.visible !== false, + hierarchyLevel: obj.hierarchy_level || 1, + parentKey: obj.parent_key, + externalKey: obj.external_key, + })); + + setPlacedObjects(loadedObjects); + + // 다음 임시 ID 설정 (기존 ID 중 최소값 - 1) + const minId = Math.min(...loadedObjects.map((o) => o.id), 0); + setNextObjectId(minId - 1); + + setHasUnsavedChanges(false); + + toast({ + title: "레이아웃 불러오기 완료", + description: `${loadedObjects.length}개의 객체를 불러왔습니다.`, + }); + + // Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달) + const dbConnectionId = layout.external_db_connection_id; + const hierarchyConfigParsed = + typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config; + const materialTableName = hierarchyConfigParsed?.material?.tableName; + + const locationObjects = loadedObjects.filter( + (obj) => + (obj.type === "location-bed" || + obj.type === "location-stp" || + obj.type === "location-temp" || + obj.type === "location-dest") && + obj.locaKey, + ); + if (locationObjects.length > 0 && dbConnectionId && materialTableName) { + const locaKeys = locationObjects.map((obj) => obj.locaKey!); + setTimeout(() => { + loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName); + }, 100); + } + } else { + throw new Error(response.error || "레이아웃 조회 실패"); + } + } catch (error) { + console.error("레이아웃 로드 실패:", error); + const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; + toast({ + variant: "destructive", + title: "오류", + description: errorMessage, + }); + } finally { + setIsLoading(false); + } + }; + + // 위젯 새로고침 핸들러 + const handleRefresh = async () => { + if (hasUnsavedChanges) { + const confirmed = window.confirm( + "저장되지 않은 변경사항이 있습니다. 새로고침하면 변경사항이 사라집니다. 계속하시겠습니까?", + ); + if (!confirmed) return; + } + setIsRefreshing(true); + setSelectedObject(null); + setMaterials([]); + await loadLayout(); + setIsRefreshing(false); + toast({ + title: "새로고침 완료", + description: "데이터가 갱신되었습니다.", + }); + }; + + // 초기 로드 + useEffect(() => { loadLayout(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layoutId]); // toast 제거 + }, [layoutId]); // 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시) useEffect(() => { @@ -1052,7 +1084,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi }; // Location별 자재 개수 로드 (locaKeys를 직접 받음) - const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => { + const loadMaterialCountsForLocations = async ( + locaKeys: string[], + dbConnectionId?: number, + materialTableName?: string, + ) => { const connectionId = dbConnectionId || selectedDbConnection; const tableName = materialTableName || selectedTables.material; if (!connectionId || locaKeys.length === 0) return; @@ -1060,7 +1096,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi try { const response = await getMaterialCounts(connectionId, tableName, locaKeys); console.log("📊 자재 개수 API 응답:", response); - + if (response.success && response.data) { // 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외) setPlacedObjects((prev) => @@ -1073,10 +1109,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi } // 백엔드 응답 필드명: location_key, count (대소문자 모두 체크) const materialCount = response.data?.find( - (mc: any) => - mc.LOCAKEY === obj.locaKey || - mc.location_key === obj.locaKey || - mc.locakey === obj.locaKey + (mc: any) => mc.LOCAKEY === obj.locaKey || mc.location_key === obj.locaKey || mc.locakey === obj.locaKey, ); if (materialCount) { // count 또는 material_count 필드 사용 @@ -1527,6 +1560,16 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
{hasUnsavedChanges && 미저장 변경사항 있음} +
- setSelectedTemplateId(val)}> {mappingTemplates.length === 0 ? ( -
- 사용 가능한 템플릿이 없습니다 -
+
사용 가능한 템플릿이 없습니다
) : ( mappingTemplates.map((tpl) => (
{tpl.name} {tpl.description && ( - - {tpl.description} - + {tpl.description} )}
@@ -1704,17 +1740,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi }} onLoadColumns={async (tableName: string) => { try { - const response = await ExternalDbConnectionAPI.getTableColumns( - selectedDbConnection, - tableName, - ); + const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName); if (response.success && response.data) { // 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그) return response.data.map((col: any) => ({ - column_name: - typeof col === "string" - ? col - : col.column_name || col.COLUMN_NAME || String(col), + column_name: typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col), data_type: col.data_type || col.DATA_TYPE, description: col.description || col.COLUMN_COMMENT || undefined, is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY, @@ -2354,10 +2384,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi > 취소 - diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index 91804987..71462ebe 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useMemo } from "react"; -import { Loader2, Search, X, Grid3x3, Package, ParkingCircle } from "lucide-react"; +import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; @@ -41,130 +41,144 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) // 검색 및 필터 const [searchQuery, setSearchQuery] = useState(""); const [filterType, setFilterType] = useState("all"); + const [isRefreshing, setIsRefreshing] = useState(false); - // 레이아웃 데이터 로드 - useEffect(() => { - const loadLayout = async () => { - try { - setIsLoading(true); - const response = await getLayoutById(layoutId); + // 레이아웃 데이터 로드 함수 + const loadLayout = async () => { + try { + setIsLoading(true); + const response = await getLayoutById(layoutId); - if (response.success && response.data) { - const { layout, objects } = response.data; + if (response.success && response.data) { + const { layout, objects } = response.data; - // 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원) - setLayoutName(layout.layout_name || layout.layoutName); - const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId; - setExternalDbConnectionId(dbConnectionId); + // 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원) + setLayoutName(layout.layout_name || layout.layoutName); + const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId; + setExternalDbConnectionId(dbConnectionId); - // hierarchy_config 저장 - let hierarchyConfigData: any = null; - if (layout.hierarchy_config) { - hierarchyConfigData = - typeof layout.hierarchy_config === "string" - ? JSON.parse(layout.hierarchy_config) - : layout.hierarchy_config; - setHierarchyConfig(hierarchyConfigData); - } + // hierarchy_config 저장 + let hierarchyConfigData: any = null; + if (layout.hierarchy_config) { + hierarchyConfigData = + typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config; + setHierarchyConfig(hierarchyConfigData); + } - // 객체 데이터 변환 - const loadedObjects: PlacedObject[] = objects.map((obj: any) => { - const objectType = obj.object_type; - return { - id: obj.id, - type: objectType, - name: obj.object_name, - position: { - x: parseFloat(obj.position_x), - y: parseFloat(obj.position_y), - z: parseFloat(obj.position_z), - }, - size: { - x: parseFloat(obj.size_x), - y: parseFloat(obj.size_y), - z: parseFloat(obj.size_z), - }, - rotation: obj.rotation ? parseFloat(obj.rotation) : 0, - color: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상 - areaKey: obj.area_key, - locaKey: obj.loca_key, - locType: obj.loc_type, - materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, - materialPreview: - obj.loc_type === "STP" || !obj.material_preview_height - ? undefined - : { height: parseFloat(obj.material_preview_height) }, - parentId: obj.parent_id, - displayOrder: obj.display_order, - locked: obj.locked, - visible: obj.visible !== false, - hierarchyLevel: obj.hierarchy_level, - parentKey: obj.parent_key, - externalKey: obj.external_key, - }; + // 객체 데이터 변환 + const loadedObjects: PlacedObject[] = objects.map((obj: any) => { + const objectType = obj.object_type; + return { + id: obj.id, + type: objectType, + name: obj.object_name, + position: { + x: parseFloat(obj.position_x), + y: parseFloat(obj.position_y), + z: parseFloat(obj.position_z), + }, + size: { + x: parseFloat(obj.size_x), + y: parseFloat(obj.size_y), + z: parseFloat(obj.size_z), + }, + rotation: obj.rotation ? parseFloat(obj.rotation) : 0, + color: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상 + areaKey: obj.area_key, + locaKey: obj.loca_key, + locType: obj.loc_type, + materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, + materialPreview: + obj.loc_type === "STP" || !obj.material_preview_height + ? undefined + : { height: parseFloat(obj.material_preview_height) }, + parentId: obj.parent_id, + displayOrder: obj.display_order, + locked: obj.locked, + visible: obj.visible !== false, + hierarchyLevel: obj.hierarchy_level, + parentKey: obj.parent_key, + externalKey: obj.external_key, + }; + }); + + setPlacedObjects(loadedObjects); + + // 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회 + if (dbConnectionId && hierarchyConfigData?.material) { + const locationObjects = loadedObjects.filter( + (obj) => + (obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") && + obj.locaKey, + ); + + // 각 Location에 대해 자재 개수 조회 (병렬 처리) + const materialCountPromises = locationObjects.map(async (obj) => { + try { + const matResponse = await getMaterials(dbConnectionId, { + tableName: hierarchyConfigData.material.tableName, + keyColumn: hierarchyConfigData.material.keyColumn, + locationKeyColumn: hierarchyConfigData.material.locationKeyColumn, + layerColumn: hierarchyConfigData.material.layerColumn, + locaKey: obj.locaKey!, + }); + if (matResponse.success && matResponse.data) { + return { id: obj.id, count: matResponse.data.length }; + } + } catch (e) { + console.warn(`자재 개수 조회 실패 (${obj.locaKey}):`, e); + } + return { id: obj.id, count: 0 }; }); - setPlacedObjects(loadedObjects); + const materialCounts = await Promise.all(materialCountPromises); - // 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회 - if (dbConnectionId && hierarchyConfigData?.material) { - const locationObjects = loadedObjects.filter( - (obj) => - (obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") && - obj.locaKey - ); - - // 각 Location에 대해 자재 개수 조회 (병렬 처리) - const materialCountPromises = locationObjects.map(async (obj) => { - try { - const matResponse = await getMaterials(dbConnectionId, { - tableName: hierarchyConfigData.material.tableName, - keyColumn: hierarchyConfigData.material.keyColumn, - locationKeyColumn: hierarchyConfigData.material.locationKeyColumn, - layerColumn: hierarchyConfigData.material.layerColumn, - locaKey: obj.locaKey!, - }); - if (matResponse.success && matResponse.data) { - return { id: obj.id, count: matResponse.data.length }; - } - } catch (e) { - console.warn(`자재 개수 조회 실패 (${obj.locaKey}):`, e); + // materialCount 업데이트 + setPlacedObjects((prev) => + prev.map((obj) => { + const countData = materialCounts.find((m) => m.id === obj.id); + if (countData && countData.count > 0) { + return { ...obj, materialCount: countData.count }; } - return { id: obj.id, count: 0 }; - }); - - const materialCounts = await Promise.all(materialCountPromises); - - // materialCount 업데이트 - setPlacedObjects((prev) => - prev.map((obj) => { - const countData = materialCounts.find((m) => m.id === obj.id); - if (countData && countData.count > 0) { - return { ...obj, materialCount: countData.count }; - } - return obj; - }) - ); - } - } else { - throw new Error(response.error || "레이아웃 조회 실패"); + return obj; + }), + ); } - } catch (error) { - console.error("레이아웃 로드 실패:", error); - const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; - toast({ - variant: "destructive", - title: "오류", - description: errorMessage, - }); - } finally { - setIsLoading(false); + } else { + throw new Error(response.error || "레이아웃 조회 실패"); } - }; + } catch (error) { + console.error("레이아웃 로드 실패:", error); + const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; + toast({ + variant: "destructive", + title: "오류", + description: errorMessage, + }); + } finally { + setIsLoading(false); + } + }; + // 위젯 새로고침 핸들러 + const handleRefresh = async () => { + setIsRefreshing(true); + setSelectedObject(null); + setMaterials([]); + setShowInfoPanel(false); + await loadLayout(); + setIsRefreshing(false); + toast({ + title: "새로고침 완료", + description: "데이터가 갱신되었습니다.", + }); + }; + + // 초기 로드 + useEffect(() => { loadLayout(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layoutId]); // toast 제거 - 무한 루프 방지 + }, [layoutId]); // Location의 자재 목록 로드 const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => { @@ -322,6 +336,16 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)

{layoutName || "디지털 트윈 야드"}

읽기 전용 뷰

+ {/* 메인 영역 */} @@ -404,59 +428,59 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) // Area가 없으면 기존 평면 리스트 유지 if (areaObjects.length === 0) { return ( -
- {filteredObjects.map((obj) => { - let typeLabel = obj.type; - if (obj.type === "location-bed") typeLabel = "베드(BED)"; - else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; - else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; - else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; - else if (obj.type === "crane-mobile") typeLabel = "크레인"; - else if (obj.type === "area") typeLabel = "Area"; - else if (obj.type === "rack") typeLabel = "랙"; +
+ {filteredObjects.map((obj) => { + let typeLabel = obj.type; + if (obj.type === "location-bed") typeLabel = "베드(BED)"; + else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; + else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; + else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; + else if (obj.type === "crane-mobile") typeLabel = "크레인"; + else if (obj.type === "area") typeLabel = "Area"; + else if (obj.type === "rack") typeLabel = "랙"; - return ( -
handleObjectClick(obj.id)} - className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ - selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm" - }`} - > -
-
-

{obj.name}

-
- - {typeLabel} + return ( +
handleObjectClick(obj.id)} + className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ + selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm" + }`} + > +
+
+

{obj.name}

+
+ + {typeLabel} +
+
+
+
+ {obj.areaKey && ( +

+ Area: {obj.areaKey} +

+ )} + {obj.locaKey && ( +

+ Location: {obj.locaKey} +

+ )} + {obj.materialCount !== undefined && obj.materialCount > 0 && ( +

+ 자재: {obj.materialCount}개 +

+ )} +
-
-
-
- {obj.areaKey && ( -

- Area: {obj.areaKey} -

- )} - {obj.locaKey && ( -

- Location: {obj.locaKey} -

- )} - {obj.materialCount !== undefined && obj.materialCount > 0 && ( -

- 자재: {obj.materialCount}개 -

- )} -
+ ); + })}
); - })} -
- ); } // Area가 있는 경우: Area → Location 계층 아코디언 @@ -525,8 +549,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) />

- 위치: ({locationObj.position.x.toFixed(1)},{" "} - {locationObj.position.z.toFixed(1)}) + 위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)})

{locationObj.locaKey && (

diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 0f080bcc..a4a17274 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -29,7 +29,6 @@ import { Plus, Minus, ArrowRight, - Save, Zap, } from "lucide-react"; import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport"; @@ -52,12 +51,6 @@ interface ColumnMapping { systemColumn: string | null; } -interface UploadConfig { - name: string; - type: string; - mappings: ColumnMapping[]; -} - export const ExcelUploadModal: React.FC = ({ open, onOpenChange, @@ -88,8 +81,6 @@ export const ExcelUploadModal: React.FC = ({ const [excelColumns, setExcelColumns] = useState([]); const [systemColumns, setSystemColumns] = useState([]); const [columnMappings, setColumnMappings] = useState([]); - const [configName, setConfigName] = useState(""); - const [configType, setConfigType] = useState(""); // 4단계: 확인 const [isUploading, setIsUploading] = useState(false); @@ -114,7 +105,7 @@ export const ExcelUploadModal: React.FC = ({ const data = await importFromExcel(selectedFile, sheets[0]); setAllData(data); - setDisplayData(data.slice(0, 10)); + setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능) if (data.length > 0) { const columns = Object.keys(data[0]); @@ -139,7 +130,7 @@ export const ExcelUploadModal: React.FC = ({ try { const data = await importFromExcel(file, sheetName); setAllData(data); - setDisplayData(data.slice(0, 10)); + setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능) if (data.length > 0) { const columns = Object.keys(data[0]); @@ -236,13 +227,23 @@ export const ExcelUploadModal: React.FC = ({ } }; - // 자동 매핑 + // 자동 매핑 - 컬럼명과 라벨 모두 비교 const handleAutoMapping = () => { const newMappings = excelColumns.map((excelCol) => { - const matchedSystemCol = systemColumns.find( - (sysCol) => sysCol.name.toLowerCase() === excelCol.toLowerCase() + const normalizedExcelCol = excelCol.toLowerCase().trim(); + + // 1. 먼저 라벨로 매칭 시도 + let matchedSystemCol = systemColumns.find( + (sysCol) => sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol ); + // 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도 + if (!matchedSystemCol) { + matchedSystemCol = systemColumns.find( + (sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol + ); + } + return { excelColumn: excelCol, systemColumn: matchedSystemCol ? matchedSystemCol.name : null, @@ -265,28 +266,6 @@ export const ExcelUploadModal: React.FC = ({ ); }; - // 설정 저장 - const handleSaveConfig = () => { - if (!configName.trim()) { - toast.error("거래처명을 입력해주세요."); - return; - } - - const config: UploadConfig = { - name: configName, - type: configType, - mappings: columnMappings, - }; - - const savedConfigs = JSON.parse( - localStorage.getItem("excelUploadConfigs") || "[]" - ); - savedConfigs.push(config); - localStorage.setItem("excelUploadConfigs", JSON.stringify(savedConfigs)); - - toast.success("설정이 저장되었습니다."); - }; - // 다음 단계 const handleNext = () => { if (currentStep === 1 && !file) { @@ -317,7 +296,8 @@ export const ExcelUploadModal: React.FC = ({ setIsUploading(true); try { - const mappedData = displayData.map((row) => { + // allData를 사용하여 전체 데이터 업로드 (displayData는 미리보기용 10개만) + const mappedData = allData.map((row) => { const mappedRow: Record = {}; columnMappings.forEach((mapping) => { if (mapping.systemColumn) { @@ -379,8 +359,6 @@ export const ExcelUploadModal: React.FC = ({ setExcelColumns([]); setSystemColumns([]); setColumnMappings([]); - setConfigName(""); - setConfigType(""); } }, [open]); @@ -689,27 +667,25 @@ export const ExcelUploadModal: React.FC = ({

)} - {/* 3단계: 컬럼 매핑 - 3단 레이아웃 */} + {/* 3단계: 컬럼 매핑 */} {currentStep === 3 && ( -
- {/* 왼쪽: 컬럼 매핑 설정 제목 + 자동 매핑 버튼 */} -
-
-

컬럼 매핑 설정

- -
+
+ {/* 상단: 제목 + 자동 매핑 버튼 */} +
+

컬럼 매핑 설정

+
- {/* 중앙: 매핑 리스트 */} + {/* 매핑 리스트 */}
엑셀 컬럼
@@ -734,7 +710,14 @@ export const ExcelUploadModal: React.FC = ({ } > - + + {mapping.systemColumn + ? (() => { + const col = systemColumns.find(c => c.name === mapping.systemColumn); + return col?.label || mapping.systemColumn; + })() + : "매핑 안함"} + @@ -746,7 +729,7 @@ export const ExcelUploadModal: React.FC = ({ value={col.name} className="text-xs sm:text-sm" > - {col.name} ({col.type}) + {col.label || col.name} ({col.type}) ))} @@ -755,50 +738,6 @@ export const ExcelUploadModal: React.FC = ({ ))}
- - {/* 오른쪽: 현재 설정 저장 */} -
-
- -

현재 설정 저장

-
-
-
- - setConfigName(e.target.value)} - placeholder="거래처 선택" - className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" - /> -
-
- - setConfigType(e.target.value)} - placeholder="유형을 입력하세요 (예: 원자재)" - className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" - /> -
- -
-
)} @@ -815,7 +754,7 @@ export const ExcelUploadModal: React.FC = ({ 시트: {selectedSheet}

- 데이터 행: {displayData.length}개 + 데이터 행: {allData.length}개

테이블: {tableName} diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 811249a7..cceadae9 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -12,6 +12,7 @@ import { useAuth } from "@/hooks/useAuth"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext"; import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; +import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; interface ScreenModalState { isOpen: boolean; @@ -666,6 +667,7 @@ export const ScreenModal: React.FC = ({ className }) => {

) : screenData ? ( +
= ({ className }) => { })}
+
) : (

화면 데이터가 없습니다.

diff --git a/frontend/components/dashboard/widgets/ListTestWidget.tsx b/frontend/components/dashboard/widgets/ListTestWidget.tsx index bc6b3299..d1303d10 100644 --- a/frontend/components/dashboard/widgets/ListTestWidget.tsx +++ b/frontend/components/dashboard/widgets/ListTestWidget.tsx @@ -180,9 +180,19 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { switch (format) { case "date": - return new Date(value).toLocaleDateString("ko-KR"); + try { + const dateVal = new Date(value); + return dateVal.toLocaleDateString("ko-KR", { timeZone: "Asia/Seoul" }); + } catch { + return String(value); + } case "datetime": - return new Date(value).toLocaleString("ko-KR"); + try { + const dateVal = new Date(value); + return dateVal.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" }); + } catch { + return String(value); + } case "number": return Number(value).toLocaleString("ko-KR"); case "currency": diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index b3c9e2fb..151c7eff 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -203,14 +203,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { setTripInfoLoading(identifier); try { - // user_id 또는 vehicle_number로 조회 (시간은 KST로 변환) + // user_id 또는 vehicle_number로 조회 (TIMESTAMPTZ는 변환 불필요) const query = `SELECT id, vehicle_number, user_id, - (last_trip_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_start, - (last_trip_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_end, + last_trip_start, + last_trip_end, last_trip_distance, last_trip_time, - (last_empty_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_start, - (last_empty_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_end, + last_empty_start, + last_empty_end, last_empty_distance, last_empty_time, departure, arrival, status FROM vehicles @@ -281,15 +281,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { if (identifiers.length === 0) return; try { - // 모든 마커의 운행/공차 정보를 한 번에 조회 (시간은 KST로 변환) + // 모든 마커의 운행/공차 정보를 한 번에 조회 (TIMESTAMPTZ는 변환 불필요) const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", "); const query = `SELECT id, vehicle_number, user_id, - (last_trip_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_start, - (last_trip_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_end, + last_trip_start, + last_trip_end, last_trip_distance, last_trip_time, - (last_empty_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_start, - (last_empty_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_end, + last_empty_start, + last_empty_end, last_empty_distance, last_empty_time, departure, arrival, status FROM vehicles diff --git a/frontend/components/screen-embedding/EmbeddedScreen.tsx b/frontend/components/screen-embedding/EmbeddedScreen.tsx index d8e62c00..12496310 100644 --- a/frontend/components/screen-embedding/EmbeddedScreen.tsx +++ b/frontend/components/screen-embedding/EmbeddedScreen.tsx @@ -40,38 +40,42 @@ export const EmbeddedScreen = forwardRef(null); const [screenInfo, setScreenInfo] = useState(null); const [formData, setFormData] = useState>(initialFormData || {}); // 🆕 초기 데이터로 시작 + const [formDataVersion, setFormDataVersion] = useState(0); // 🆕 폼 데이터 버전 (강제 리렌더링용) // 컴포넌트 참조 맵 const componentRefs = useRef>(new Map()); - + // 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용) const splitPanelContext = useSplitPanelContext(); - + + // 🆕 selectedLeftData 참조 안정화 (실제 값이 바뀔 때만 업데이트) + const selectedLeftData = splitPanelContext?.selectedLeftData; + const prevSelectedLeftDataRef = useRef(""); + // 🆕 사용자 정보 가져오기 (저장 액션에 필요) const { userId, userName, companyCode } = useAuth(); // 컴포넌트들의 실제 영역 계산 (가로폭 맞춤을 위해) const contentBounds = React.useMemo(() => { if (layout.length === 0) return { width: 0, height: 0 }; - + let maxRight = 0; let maxBottom = 0; - + layout.forEach((component) => { const { position: compPosition = { x: 0, y: 0 }, size = { width: 200, height: 40 } } = component; const right = (compPosition.x || 0) + (size.width || 200); const bottom = (compPosition.y || 0) + (size.height || 40); - + if (right > maxRight) maxRight = right; if (bottom > maxBottom) maxBottom = bottom; }); - + return { width: maxRight, height: maxBottom }; }, [layout]); // 필드 값 변경 핸들러 const handleFieldChange = useCallback((fieldName: string, value: any) => { - console.log("📝 [EmbeddedScreen] 필드 값 변경:", { fieldName, value }); setFormData((prev) => ({ ...prev, [fieldName]: value, @@ -83,35 +87,55 @@ export const EmbeddedScreen = forwardRef { if (initialFormData && Object.keys(initialFormData).length > 0) { - console.log("📝 [EmbeddedScreen] 초기 폼 데이터 설정:", initialFormData); setFormData(initialFormData); } }, [initialFormData]); // 🆕 분할 패널에서 좌측 선택 데이터가 변경되면 우측 화면의 formData에 자동 반영 - // disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) + // 🆕 좌측 선택 데이터가 변경되면 우측 formData를 업데이트 useEffect(() => { // 우측 화면인 경우에만 적용 - if (position !== "right" || !splitPanelContext) return; - - // 자동 데이터 전달이 비활성화된 경우 스킵 - if (splitPanelContext.disableAutoDataTransfer) { - console.log("🔗 [EmbeddedScreen] 자동 데이터 전달 비활성화됨 - 버튼 클릭으로만 전달"); + if (position !== "right" || !splitPanelContext) { return; } - - const mappedData = splitPanelContext.getMappedParentData(); - if (Object.keys(mappedData).length > 0) { - console.log("🔗 [EmbeddedScreen] 분할 패널 부모 데이터 자동 반영:", mappedData); - setFormData((prev) => ({ - ...prev, - ...mappedData, - })); + + // 자동 데이터 전달이 비활성화된 경우 스킵 + if (splitPanelContext.disableAutoDataTransfer) { + return; } - }, [position, splitPanelContext, splitPanelContext?.selectedLeftData]); + + // 🆕 값 비교로 실제 변경 여부 확인 (불필요한 리렌더링 방지) + const currentDataStr = JSON.stringify(selectedLeftData || {}); + if (prevSelectedLeftDataRef.current === currentDataStr) { + return; // 실제 값이 같으면 스킵 + } + prevSelectedLeftDataRef.current = currentDataStr; + + // 🆕 현재 화면의 모든 컴포넌트에서 columnName 수집 + const allColumnNames = layout.filter((comp) => comp.columnName).map((comp) => comp.columnName as string); + + // 🆕 모든 필드를 빈 값으로 초기화한 후, selectedLeftData로 덮어쓰기 + const initializedFormData: Record = {}; + + // 먼저 모든 컬럼을 빈 문자열로 초기화 + allColumnNames.forEach((colName) => { + initializedFormData[colName] = ""; + }); + + // selectedLeftData가 있으면 해당 값으로 덮어쓰기 + if (selectedLeftData && Object.keys(selectedLeftData).length > 0) { + Object.keys(selectedLeftData).forEach((key) => { + // null/undefined는 빈 문자열로, 나머지는 그대로 + initializedFormData[key] = selectedLeftData[key] ?? ""; + }); + } + + setFormData(initializedFormData); + setFormDataVersion((v) => v + 1); // 🆕 버전 증가로 컴포넌트 강제 리렌더링 + }, [position, splitPanelContext, selectedLeftData, layout]); // 선택 변경 이벤트 전파 useEffect(() => { @@ -128,13 +152,6 @@ export const EmbeddedScreen = forwardRef화면에 컴포넌트가 없습니다.

) : ( -
{layout.map((component) => { const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component; - + // 컴포넌트가 컨테이너 너비를 초과하지 않도록 너비 조정 // 부모 컨테이너의 100%를 기준으로 계산 const componentStyle: React.CSSProperties = { @@ -397,13 +414,9 @@ export const EmbeddedScreen = forwardRef +
{ - console.log("📐 [ScreenSplitPanel] splitRatio 동기화:", { configSplitRatio, currentSplitRatio: splitRatio }); setSplitRatio(configSplitRatio); }, [configSplitRatio]); diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 85e502a0..1b54d3b9 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -26,12 +26,56 @@ interface EditModalState { onSave?: () => void; groupByColumns?: string[]; // 🆕 그룹핑 컬럼 (예: ["order_no"]) tableName?: string; // 🆕 테이블명 (그룹 조회용) + buttonConfig?: any; // 🆕 버튼 설정 (제어로직 실행용) + buttonContext?: any; // 🆕 버튼 컨텍스트 (screenId, userId 등) + saveButtonConfig?: { + enableDataflowControl?: boolean; + dataflowConfig?: any; + dataflowTiming?: string; + }; // 🆕 모달 내부 저장 버튼의 제어로직 설정 } interface EditModalProps { className?: string; } +/** + * 모달 내부에서 저장 버튼 찾기 (재귀적으로 탐색) + * action.type이 "save"인 button-primary 컴포넌트를 찾음 + */ +const findSaveButtonInComponents = (components: any[]): any | null => { + if (!components || !Array.isArray(components)) return null; + + for (const comp of components) { + // button-primary이고 action.type이 save인 경우 + if ( + comp.componentType === "button-primary" && + comp.componentConfig?.action?.type === "save" + ) { + return comp; + } + + // conditional-container의 sections 내부 탐색 + if (comp.componentType === "conditional-container" && comp.componentConfig?.sections) { + for (const section of comp.componentConfig.sections) { + if (section.screenId) { + // 조건부 컨테이너의 내부 화면은 별도로 로드해야 함 + // 여기서는 null 반환하고, loadSaveButtonConfig에서 처리 + continue; + } + } + } + + // 자식 컴포넌트가 있으면 재귀 탐색 + if (comp.children && Array.isArray(comp.children)) { + const found = findSaveButtonInComponents(comp.children); + if (found) return found; + } + } + + return null; +}; + export const EditModal: React.FC = ({ className }) => { const { user } = useAuth(); const [modalState, setModalState] = useState({ @@ -44,6 +88,9 @@ export const EditModal: React.FC = ({ className }) => { onSave: undefined, groupByColumns: undefined, tableName: undefined, + buttonConfig: undefined, + buttonContext: undefined, + saveButtonConfig: undefined, }); const [screenData, setScreenData] = useState<{ @@ -115,11 +162,88 @@ export const EditModal: React.FC = ({ className }) => { }; }; + // 🆕 모달 내부 저장 버튼의 제어로직 설정 조회 + const loadSaveButtonConfig = async (targetScreenId: number): Promise<{ + enableDataflowControl?: boolean; + dataflowConfig?: any; + dataflowTiming?: string; + } | null> => { + try { + // 1. 대상 화면의 레이아웃 조회 + const layoutData = await screenApi.getLayout(targetScreenId); + + if (!layoutData?.components) { + console.log("[EditModal] 레이아웃 컴포넌트 없음:", targetScreenId); + return null; + } + + // 2. 저장 버튼 찾기 + let saveButton = findSaveButtonInComponents(layoutData.components); + + // 3. conditional-container가 있는 경우 내부 화면도 탐색 + if (!saveButton) { + for (const comp of layoutData.components) { + if (comp.componentType === "conditional-container" && comp.componentConfig?.sections) { + for (const section of comp.componentConfig.sections) { + if (section.screenId) { + try { + const innerLayoutData = await screenApi.getLayout(section.screenId); + saveButton = findSaveButtonInComponents(innerLayoutData?.components || []); + if (saveButton) { + console.log("[EditModal] 조건부 컨테이너 내부에서 저장 버튼 발견:", { + sectionScreenId: section.screenId, + sectionLabel: section.label, + }); + break; + } + } catch (innerError) { + console.warn("[EditModal] 내부 화면 레이아웃 조회 실패:", section.screenId); + } + } + } + if (saveButton) break; + } + } + } + + if (!saveButton) { + console.log("[EditModal] 저장 버튼을 찾을 수 없음:", targetScreenId); + return null; + } + + // 4. webTypeConfig에서 제어로직 설정 추출 + const webTypeConfig = saveButton.webTypeConfig; + if (webTypeConfig?.enableDataflowControl) { + const config = { + enableDataflowControl: webTypeConfig.enableDataflowControl, + dataflowConfig: webTypeConfig.dataflowConfig, + dataflowTiming: webTypeConfig.dataflowConfig?.flowConfig?.executionTiming || "after", + }; + console.log("[EditModal] 저장 버튼 제어로직 설정 발견:", config); + return config; + } + + console.log("[EditModal] 저장 버튼에 제어로직 설정 없음"); + return null; + } catch (error) { + console.warn("[EditModal] 저장 버튼 설정 조회 실패:", error); + return null; + } + }; + // 전역 모달 이벤트 리스너 useEffect(() => { - const handleOpenEditModal = (event: CustomEvent) => { - const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode } = - event.detail; + const handleOpenEditModal = async (event: CustomEvent) => { + const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext } = event.detail; + + // 🆕 모달 내부 저장 버튼의 제어로직 설정 조회 + let saveButtonConfig: EditModalState["saveButtonConfig"] = undefined; + if (screenId) { + const config = await loadSaveButtonConfig(screenId); + if (config) { + saveButtonConfig = config; + } + } setModalState({ isOpen: true, @@ -131,6 +255,9 @@ export const EditModal: React.FC = ({ className }) => { onSave, groupByColumns, // 🆕 그룹핑 컬럼 tableName, // 🆕 테이블명 + buttonConfig, // 🆕 버튼 설정 + buttonContext, // 🆕 버튼 컨텍스트 + saveButtonConfig, // 🆕 모달 내부 저장 버튼의 제어로직 설정 }); // 편집 데이터로 폼 데이터 초기화 @@ -578,6 +705,46 @@ export const EditModal: React.FC = ({ className }) => { } } + // 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어) + // 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig) + try { + const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig; + + console.log("[EditModal] 그룹 저장 완료 후 제어로직 실행 시도", { + hasSaveButtonConfig: !!modalState.saveButtonConfig, + hasButtonConfig: !!modalState.buttonConfig, + controlConfig, + }); + + if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") { + console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig); + + // buttonActions의 executeAfterSaveControl 동적 import + const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions"); + + // 제어로직 실행 + await ButtonActionExecutor.executeAfterSaveControl( + controlConfig, + { + formData: modalState.editData, + screenId: modalState.buttonContext?.screenId || modalState.screenId, + tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName, + userId: user?.userId, + companyCode: user?.companyCode, + onRefresh: modalState.onSave, + } + ); + + console.log("✅ [EditModal] 제어로직 실행 완료"); + } else { + console.log("ℹ️ [EditModal] 저장 후 실행할 제어로직 없음"); + } + } catch (controlError) { + console.error("❌ [EditModal] 제어로직 실행 오류:", controlError); + // 제어로직 오류는 저장 성공을 방해하지 않음 (경고만 표시) + toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); + } + handleClose(); } else { toast.info("변경된 내용이 없습니다."); @@ -612,6 +779,37 @@ export const EditModal: React.FC = ({ className }) => { } } + // 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어) + // 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig) + try { + const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig; + + console.log("[EditModal] INSERT 완료 후 제어로직 실행 시도", { controlConfig }); + + if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") { + console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig); + + const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions"); + + await ButtonActionExecutor.executeAfterSaveControl( + controlConfig, + { + formData, + screenId: modalState.buttonContext?.screenId || modalState.screenId, + tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName, + userId: user?.userId, + companyCode: user?.companyCode, + onRefresh: modalState.onSave, + } + ); + + console.log("✅ [EditModal] 제어로직 실행 완료"); + } + } catch (controlError) { + console.error("❌ [EditModal] 제어로직 실행 오류:", controlError); + toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); + } + handleClose(); } else { throw new Error(response.message || "생성에 실패했습니다."); @@ -654,6 +852,37 @@ export const EditModal: React.FC = ({ className }) => { } } + // 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어) + // 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig) + try { + const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig; + + console.log("[EditModal] UPDATE 완료 후 제어로직 실행 시도", { controlConfig }); + + if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") { + console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig); + + const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions"); + + await ButtonActionExecutor.executeAfterSaveControl( + controlConfig, + { + formData, + screenId: modalState.buttonContext?.screenId || modalState.screenId, + tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName, + userId: user?.userId, + companyCode: user?.companyCode, + onRefresh: modalState.onSave, + } + ); + + console.log("✅ [EditModal] 제어로직 실행 완료"); + } + } catch (controlError) { + console.error("❌ [EditModal] 제어로직 실행 오류:", controlError); + toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); + } + handleClose(); } else { throw new Error(response.message || "수정에 실패했습니다."); diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 1abc44bc..a1015ac6 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -55,6 +55,7 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility } from "@/types/table-options"; import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; +import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useCascadingDropdown } from "@/hooks/useCascadingDropdown"; import { CascadingDropdownConfig } from "@/types/screen-management"; @@ -184,6 +185,8 @@ export const InteractiveDataTable: React.FC = ({ const { user } = useAuth(); // 사용자 정보 가져오기 const { registerTable, unregisterTable } = useTableOptions(); // Context 훅 const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트 + const screenContext = useScreenContextOptional(); // 화면 컨텍스트 (좌측/우측 위치 확인용) + const splitPanelPosition = screenContext?.splitPanelPosition; // 분할 패널 내 위치 const [data, setData] = useState[]>([]); const [loading, setLoading] = useState(false); @@ -308,6 +311,41 @@ export const InteractiveDataTable: React.FC = ({ }; }, [currentPage, searchValues, loadData, component.tableName]); + // 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링) + const [relatedButtonFilter, setRelatedButtonFilter] = useState<{ + filterColumn: string; + filterValue: any; + } | null>(null); + + useEffect(() => { + const handleRelatedButtonSelect = (event: CustomEvent) => { + const { targetTable, filterColumn, filterValue } = event.detail || {}; + + // 이 테이블이 대상 테이블인지 확인 + if (targetTable === component.tableName) { + console.log("📌 [InteractiveDataTable] RelatedDataButtons 필터 적용:", { + tableName: component.tableName, + filterColumn, + filterValue, + }); + setRelatedButtonFilter({ filterColumn, filterValue }); + } + }; + + window.addEventListener("related-button-select" as any, handleRelatedButtonSelect); + + return () => { + window.removeEventListener("related-button-select" as any, handleRelatedButtonSelect); + }; + }, [component.tableName]); + + // relatedButtonFilter 변경 시 데이터 다시 로드 + useEffect(() => { + if (relatedButtonFilter) { + loadData(1, searchValues); + } + }, [relatedButtonFilter]); + // 카테고리 타입 컬럼의 값 매핑 로드 useEffect(() => { const loadCategoryMappings = async () => { @@ -702,10 +740,17 @@ export const InteractiveDataTable: React.FC = ({ return; } + // 🆕 RelatedDataButtons 필터 적용 + let relatedButtonFilterValues: Record = {}; + if (relatedButtonFilter) { + relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue; + } + // 검색 파라미터와 연결 필터 병합 const mergedSearchParams = { ...searchParams, ...linkedFilterValues, + ...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가 }; console.log("🔍 데이터 조회 시작:", { @@ -713,6 +758,7 @@ export const InteractiveDataTable: React.FC = ({ page, pageSize, linkedFilterValues, + relatedButtonFilterValues, mergedSearchParams, }); @@ -819,7 +865,7 @@ export const InteractiveDataTable: React.FC = ({ setLoading(false); } }, - [component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData], // 🆕 autoFilter, 연결필터 추가 + [component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData, relatedButtonFilter], // 🆕 autoFilter, 연결필터, RelatedDataButtons 필터 추가 ); // 현재 사용자 정보 로드 @@ -947,7 +993,18 @@ export const InteractiveDataTable: React.FC = ({ } return newSet; }); - }, []); + + // 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용) + if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { + if (isSelected && data[rowIndex]) { + splitPanelContext.setSelectedLeftData(data[rowIndex]); + console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]); + } else if (!isSelected) { + splitPanelContext.setSelectedLeftData(null); + console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화"); + } + } + }, [data, splitPanelContext, splitPanelPosition]); // 전체 선택/해제 핸들러 const handleSelectAll = useCallback( diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 480b3ddd..376f9953 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -51,6 +51,7 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar"; import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; +import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; /** * 🔗 연쇄 드롭다운 래퍼 컴포넌트 @@ -2103,7 +2104,8 @@ export const InteractiveScreenViewer: React.FC = ( return ( - + +
{/* 테이블 옵션 툴바 */} @@ -2210,7 +2212,8 @@ export const InteractiveScreenViewer: React.FC = (
-
+
+
); }; diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 97dc0734..4763507e 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -39,22 +39,25 @@ interface InteractiveScreenViewerProps { id: number; tableName?: string; }; - menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용) + menuObjid?: number; // 메뉴 OBJID (코드 스코프용) onSave?: () => Promise; onRefresh?: () => void; onFlowRefresh?: () => void; - // 🆕 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용) + // 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용) userId?: string; userName?: string; companyCode?: string; - // 🆕 그룹 데이터 (EditModal에서 전달) + // 그룹 데이터 (EditModal에서 전달) groupedData?: Record[]; - // 🆕 비활성화할 필드 목록 (EditModal에서 전달) + // 비활성화할 필드 목록 (EditModal에서 전달) disabledFields?: string[]; - // 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록) + // EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록) isInModal?: boolean; - // 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용) + // 원본 데이터 (수정 모드에서 UPDATE 판단용) originalData?: Record | null; + // 탭 관련 정보 (탭 내부의 컴포넌트에서 사용) + parentTabId?: string; // 부모 탭 ID + parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID } export const InteractiveScreenViewerDynamic: React.FC = ({ @@ -74,7 +77,9 @@ export const InteractiveScreenViewerDynamic: React.FC { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { userName: authUserName, user: authUser } = useAuth(); @@ -359,43 +364,43 @@ export const InteractiveScreenViewerDynamic: React.FC { - console.log("🔍 테이블에서 선택된 행 데이터:", selectedData); + console.log("테이블에서 선택된 행 데이터:", selectedData); setSelectedRowsData(selectedData); }} - // 🆕 그룹 데이터 전달 (EditModal → ModalRepeaterTable) groupedData={groupedData} - // 🆕 비활성화 필드 전달 (EditModal → 각 컴포넌트) disabledFields={disabledFields} flowSelectedData={flowSelectedData} flowSelectedStepId={flowSelectedStepId} onFlowSelectedDataChange={(selectedData, stepId) => { - console.log("🔍 플로우에서 선택된 데이터:", { selectedData, stepId }); + console.log("플로우에서 선택된 데이터:", { selectedData, stepId }); setFlowSelectedData(selectedData); setFlowSelectedStepId(stepId); }} onRefresh={ onRefresh || (() => { - // 부모로부터 전달받은 onRefresh 또는 기본 동작 - console.log("🔄 InteractiveScreenViewerDynamic onRefresh 호출"); + console.log("InteractiveScreenViewerDynamic onRefresh 호출"); }) } onFlowRefresh={onFlowRefresh} onClose={() => { // buttonActions.ts가 이미 처리함 }} + // 탭 관련 정보 전달 + parentTabId={parentTabId} + parentTabsComponentId={parentTabsComponentId} /> ); } @@ -584,6 +589,219 @@ export const InteractiveScreenViewerDynamic: React.FC { + // componentConfig에서 quickInsertConfig 가져오기 + const quickInsertConfig = (comp as any).componentConfig?.action?.quickInsertConfig; + + if (!quickInsertConfig?.targetTable) { + toast.error("대상 테이블이 설정되지 않았습니다."); + return; + } + + // 1. 대상 테이블의 컬럼 목록 조회 (자동 매핑용) + let targetTableColumns: string[] = []; + try { + const { default: apiClient } = await import("@/lib/api/client"); + const columnsResponse = await apiClient.get( + `/table-management/tables/${quickInsertConfig.targetTable}/columns` + ); + if (columnsResponse.data?.success && columnsResponse.data?.data) { + const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data; + targetTableColumns = columnsData.map((col: any) => col.columnName || col.column_name || col.name); + console.log("📍 대상 테이블 컬럼 목록:", targetTableColumns); + } + } catch (error) { + console.error("대상 테이블 컬럼 조회 실패:", error); + } + + // 2. 컬럼 매핑에서 값 수집 + const insertData: Record = {}; + const columnMappings = quickInsertConfig.columnMappings || []; + + for (const mapping of columnMappings) { + let value: any; + + switch (mapping.sourceType) { + case "component": + // 같은 화면의 컴포넌트에서 값 가져오기 + // 방법1: sourceColumnName 사용 + if (mapping.sourceColumnName && formData[mapping.sourceColumnName] !== undefined) { + value = formData[mapping.sourceColumnName]; + console.log(`📍 컴포넌트 값 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`); + } + // 방법2: sourceComponentId로 컴포넌트 찾아서 columnName 사용 + else if (mapping.sourceComponentId) { + const sourceComp = allComponents.find((c: any) => c.id === mapping.sourceComponentId); + if (sourceComp) { + const fieldName = (sourceComp as any).columnName || sourceComp.id; + value = formData[fieldName]; + console.log(`📍 컴포넌트 값 (컴포넌트 조회): ${fieldName} = ${value}`); + } + } + break; + + case "leftPanel": + // 분할 패널 좌측 선택 데이터에서 값 가져오기 + if (mapping.sourceColumn && splitPanelContext?.selectedLeftData) { + value = splitPanelContext.selectedLeftData[mapping.sourceColumn]; + } + break; + + case "fixed": + value = mapping.fixedValue; + break; + + case "currentUser": + if (mapping.userField) { + switch (mapping.userField) { + case "userId": + value = user?.userId; + break; + case "userName": + value = userName; + break; + case "companyCode": + value = user?.companyCode; + break; + case "deptCode": + value = authUser?.deptCode; + break; + } + } + break; + } + + if (value !== undefined && value !== null && value !== "") { + insertData[mapping.targetColumn] = value; + } + } + + // 3. 좌측 패널 선택 데이터에서 자동 매핑 (컬럼명이 같고 대상 테이블에 있는 경우) + if (splitPanelContext?.selectedLeftData && targetTableColumns.length > 0) { + const leftData = splitPanelContext.selectedLeftData; + console.log("📍 좌측 패널 자동 매핑 시작:", leftData); + + for (const [key, val] of Object.entries(leftData)) { + // 이미 매핑된 컬럼은 스킵 + if (insertData[key] !== undefined) { + continue; + } + + // 대상 테이블에 해당 컬럼이 없으면 스킵 + if (!targetTableColumns.includes(key)) { + continue; + } + + // 시스템 컬럼 제외 + const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name']; + if (systemColumns.includes(key)) { + continue; + } + + // _label, _name 으로 끝나는 표시용 컬럼 제외 + if (key.endsWith('_label') || key.endsWith('_name')) { + continue; + } + + // 값이 있으면 자동 추가 + if (val !== undefined && val !== null && val !== '') { + insertData[key] = val; + console.log(`📍 자동 매핑 추가: ${key} = ${val}`); + } + } + } + + console.log("🚀 quickInsert 최종 데이터:", insertData); + + // 4. 필수값 검증 + if (Object.keys(insertData).length === 0) { + toast.error("저장할 데이터가 없습니다. 값을 선택해주세요."); + return; + } + + // 5. 중복 체크 (설정된 경우) + if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) { + try { + const { default: apiClient } = await import("@/lib/api/client"); + + // 중복 체크를 위한 검색 조건 구성 + const searchConditions: Record = {}; + for (const col of quickInsertConfig.duplicateCheck.columns) { + if (insertData[col] !== undefined) { + searchConditions[col] = { value: insertData[col], operator: "equals" }; + } + } + + console.log("📍 중복 체크 조건:", searchConditions); + + // 기존 데이터 조회 + const checkResponse = await apiClient.post( + `/table-management/tables/${quickInsertConfig.targetTable}/data`, + { + page: 1, + pageSize: 1, + search: searchConditions, + } + ); + + console.log("📍 중복 체크 응답:", checkResponse.data); + + // data 배열이 있고 길이가 0보다 크면 중복 + const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || []; + if (Array.isArray(existingData) && existingData.length > 0) { + toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다."); + return; + } + } catch (error) { + console.error("중복 체크 오류:", error); + // 중복 체크 실패 시 계속 진행 + } + } + + // 6. API 호출 + try { + const { default: apiClient } = await import("@/lib/api/client"); + + const response = await apiClient.post( + `/table-management/tables/${quickInsertConfig.targetTable}/add`, + insertData + ); + + if (response.data?.success) { + // 7. 성공 후 동작 + if (quickInsertConfig.afterInsert?.showSuccessMessage !== false) { + toast.success(quickInsertConfig.afterInsert?.successMessage || "저장되었습니다."); + } + + // 데이터 새로고침 (테이블리스트, 카드 디스플레이) + if (quickInsertConfig.afterInsert?.refreshData !== false) { + console.log("📍 데이터 새로고침 이벤트 발송"); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("refreshTable")); + window.dispatchEvent(new CustomEvent("refreshCardDisplay")); + } + } + + // 지정된 컴포넌트 초기화 + if (quickInsertConfig.afterInsert?.clearComponents?.length > 0) { + for (const componentId of quickInsertConfig.afterInsert.clearComponents) { + const targetComp = allComponents.find((c: any) => c.id === componentId); + if (targetComp) { + const fieldName = (targetComp as any).columnName || targetComp.id; + onFormDataChange?.(fieldName, ""); + } + } + } + } else { + toast.error(response.data?.message || "저장에 실패했습니다."); + } + } catch (error: any) { + console.error("quickInsert 오류:", error); + toast.error(error.response?.data?.message || error.message || "저장 중 오류가 발생했습니다."); + } + }; + const handleClick = async () => { try { const actionType = config?.actionType || "save"; @@ -604,6 +822,9 @@ export const InteractiveScreenViewerDynamic: React.FC = ({ }; }, [onClose]); + // 필수 항목 검증 + const validateRequiredFields = (): { isValid: boolean; missingFields: string[] } => { + const missingFields: string[] = []; + + components.forEach((component) => { + // 컴포넌트의 required 속성 확인 (여러 위치에서 체크) + const isRequired = + component.required === true || + component.style?.required === true || + component.componentConfig?.required === true; + + const columnName = component.columnName || component.style?.columnName; + const label = component.label || component.style?.label || columnName; + + console.log("🔍 필수 항목 검증:", { + componentId: component.id, + columnName, + label, + isRequired, + "component.required": component.required, + "style.required": component.style?.required, + "componentConfig.required": component.componentConfig?.required, + value: formData[columnName || ""], + }); + + if (isRequired && columnName) { + const value = formData[columnName]; + // 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열) + if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) { + missingFields.push(label || columnName); + } + } + }); + + return { + isValid: missingFields.length === 0, + missingFields, + }; + }; + // 저장 핸들러 const handleSave = async () => { if (!screenData || !screenId) return; @@ -111,6 +151,13 @@ export const SaveModal: React.FC = ({ return; } + // ✅ 필수 항목 검증 + const validation = validateRequiredFields(); + if (!validation.isValid) { + toast.error(`필수 항목을 입력해주세요: ${validation.missingFields.join(", ")}`); + return; + } + try { setIsSaving(true); diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 3a440f07..d4cab3cf 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -958,6 +958,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, codeCategory: col.codeCategory || col.code_category, codeValue: col.codeValue || col.code_value, + // 엔티티 타입용 참조 테이블 정보 + referenceTable: col.referenceTable || col.reference_table, + referenceColumn: col.referenceColumn || col.reference_column, + displayColumn: col.displayColumn || col.display_column, }; }); diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 39f32a73..3a126c29 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -16,6 +16,7 @@ import { apiClient } from "@/lib/api/client"; import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel"; import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel"; import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel"; +import { QuickInsertConfigSection } from "./QuickInsertConfigSection"; // 🆕 제목 블록 타입 interface TitleBlock { @@ -333,22 +334,72 @@ export const ButtonConfigPanel: React.FC = ({ const loadModalMappingColumns = async () => { // 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지 - // allComponents에서 split-panel-layout 또는 table-list 찾기 let sourceTableName: string | null = null; + console.log("[openModalWithData] 컬럼 로드 시작:", { + allComponentsCount: allComponents.length, + currentTableName, + targetScreenId: config.action?.targetScreenId, + }); + + // 모든 컴포넌트 타입 로그 + allComponents.forEach((comp, idx) => { + const compType = comp.componentType || (comp as any).componentConfig?.type; + console.log(` [${idx}] componentType: ${compType}, tableName: ${(comp as any).componentConfig?.tableName || (comp as any).componentConfig?.leftPanel?.tableName || 'N/A'}`); + }); + 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 = (comp as any).componentConfig?.leftPanel?.tableName || - (comp as any).componentConfig?.leftTableName; - break; + sourceTableName = compConfig?.leftPanel?.tableName || + compConfig?.leftTableName || + compConfig?.tableName; + if (sourceTableName) { + console.log(`✅ [openModalWithData] split-panel-layout에서 소스 테이블 감지: ${sourceTableName}`); + break; + } } + + // split-panel-layout2 타입 (새로운 분할 패널) + if (compType === "split-panel-layout2") { + sourceTableName = compConfig?.leftPanel?.tableName || + compConfig?.tableName || + compConfig?.leftTableName; + if (sourceTableName) { + console.log(`✅ [openModalWithData] split-panel-layout2에서 소스 테이블 감지: ${sourceTableName}`); + break; + } + } + + // 테이블 리스트 타입 if (compType === "table-list") { - sourceTableName = (comp as any).componentConfig?.tableName; + sourceTableName = compConfig?.tableName; + if (sourceTableName) { + console.log(`✅ [openModalWithData] table-list에서 소스 테이블 감지: ${sourceTableName}`); + break; + } + } + + // 🆕 모든 컴포넌트에서 tableName 찾기 (폴백) + if (!sourceTableName && compConfig?.tableName) { + sourceTableName = compConfig.tableName; + console.log(`✅ [openModalWithData] ${compType}에서 소스 테이블 감지 (폴백): ${sourceTableName}`); break; } } + + // 여전히 없으면 currentTableName 사용 (화면 레벨 테이블명) + if (!sourceTableName && currentTableName) { + sourceTableName = currentTableName; + console.log(`✅ [openModalWithData] currentTableName에서 소스 테이블 사용: ${sourceTableName}`); + } + + if (!sourceTableName) { + console.warn("[openModalWithData] 소스 테이블을 찾을 수 없습니다."); + } // 소스 테이블 컬럼 로드 if (sourceTableName) { @@ -361,11 +412,11 @@ export const ButtonConfigPanel: React.FC = ({ if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ - name: col.name || col.columnName, - label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + name: col.name || col.columnName || col.column_name, + label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name, })); setModalSourceColumns(columns); - console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드:`, columns.length); + console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드 완료:`, columns.length); } } } catch (error) { @@ -379,8 +430,12 @@ export const ButtonConfigPanel: React.FC = ({ try { // 타겟 화면 정보 가져오기 const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`); + console.log("[openModalWithData] 타겟 화면 응답:", screenResponse.data); + if (screenResponse.data.success && screenResponse.data.data) { const targetTableName = screenResponse.data.data.tableName; + console.log("[openModalWithData] 타겟 화면 테이블명:", targetTableName); + if (targetTableName) { const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`); if (columnResponse.data.success) { @@ -390,23 +445,27 @@ export const ButtonConfigPanel: React.FC = ({ if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ - name: col.name || col.columnName, - label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + name: col.name || col.columnName || col.column_name, + label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name, })); setModalTargetColumns(columns); - console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드:`, columns.length); + console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드 완료:`, columns.length); } } + } else { + console.warn("[openModalWithData] 타겟 화면에 테이블명이 없습니다."); } } } catch (error) { console.error("타겟 화면 테이블 컬럼 로드 실패:", error); } + } else { + console.warn("[openModalWithData] 타겟 화면 ID가 없습니다."); } }; loadModalMappingColumns(); - }, [config.action?.type, config.action?.targetScreenId, allComponents]); + }, [config.action?.type, config.action?.targetScreenId, allComponents, currentTableName]); // 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준) useEffect(() => { @@ -584,9 +643,11 @@ export const ButtonConfigPanel: React.FC = ({ 편집 복사 (품목코드 초기화) 페이지 이동 - 📦 데이터 전달 - 데이터 전달 + 모달 열기 🆕 + 데이터 전달 + 데이터 전달 + 모달 열기 + 연관 데이터 버튼 모달 열기 모달 열기 + 즉시 저장 제어 흐름 테이블 이력 보기 엑셀 다운로드 @@ -1158,11 +1219,12 @@ export const ButtonConfigPanel: React.FC = ({

) : ( -
+
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => ( -
- {/* 소스 필드 선택 (Combobox) */} -
+
+ {/* 소스 필드 선택 (Combobox) - 세로 배치 */} +
+ setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))} @@ -1171,15 +1233,17 @@ export const ButtonConfigPanel: React.FC = ({ - + = ({ value={modalSourceSearch[index] || ""} onValueChange={(value) => setModalSourceSearch((prev) => ({ ...prev, [index]: value }))} /> - + 컬럼을 찾을 수 없습니다 {modalSourceColumns.map((col) => ( @@ -1208,9 +1272,9 @@ export const ButtonConfigPanel: React.FC = ({ mapping.sourceField === col.name ? "opacity-100" : "opacity-0" )} /> - {col.label} + {col.label} {col.label !== col.name && ( - ({col.name}) + ({col.name}) )} ))} @@ -1221,10 +1285,14 @@ export const ButtonConfigPanel: React.FC = ({
- + {/* 화살표 표시 */} +
+ +
- {/* 타겟 필드 선택 (Combobox) */} -
+ {/* 타겟 필드 선택 (Combobox) - 세로 배치 */} +
+ setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))} @@ -1233,15 +1301,17 @@ export const ButtonConfigPanel: React.FC = ({ - + = ({ value={modalTargetSearch[index] || ""} onValueChange={(value) => setModalTargetSearch((prev) => ({ ...prev, [index]: value }))} /> - + 컬럼을 찾을 수 없습니다 {modalTargetColumns.map((col) => ( @@ -1270,9 +1340,9 @@ export const ButtonConfigPanel: React.FC = ({ mapping.targetField === col.name ? "opacity-100" : "opacity-0" )} /> - {col.label} + {col.label} {col.label !== col.name && ( - ({col.name}) + ({col.name}) )} ))} @@ -1284,19 +1354,22 @@ export const ButtonConfigPanel: React.FC = ({
{/* 삭제 버튼 */} - +
+ +
))}
@@ -2998,6 +3071,16 @@ export const ButtonConfigPanel: React.FC = ({
)} + {/* 🆕 즉시 저장(quickInsert) 액션 설정 */} + {component.componentConfig?.action?.type === "quickInsert" && ( + + )} + {/* 제어 기능 섹션 */}
diff --git a/frontend/components/screen/config-panels/EntityConfigPanel.tsx b/frontend/components/screen/config-panels/EntityConfigPanel.tsx index 7c1b74eb..83773500 100644 --- a/frontend/components/screen/config-panels/EntityConfigPanel.tsx +++ b/frontend/components/screen/config-panels/EntityConfigPanel.tsx @@ -6,18 +6,10 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; -import { Database, Search, Plus, Trash2 } from "lucide-react"; +import { Database, Search, Info } from "lucide-react"; import { WebTypeConfigPanelProps } from "@/lib/registry/types"; import { WidgetComponent, EntityTypeConfig } from "@/types/screen"; - -interface EntityField { - name: string; - label: string; - type: string; - visible: boolean; -} +import { tableTypeApi } from "@/lib/api/screen"; export const EntityConfigPanel: React.FC = ({ component, @@ -27,16 +19,31 @@ export const EntityConfigPanel: React.FC = ({ const widget = component as WidgetComponent; const config = (widget.webTypeConfig as EntityTypeConfig) || {}; - // 로컬 상태 + // 테이블 타입 관리에서 설정된 참조 테이블 정보 + const [referenceInfo, setReferenceInfo] = useState<{ + referenceTable: string; + referenceColumn: string; + displayColumn: string; + isLoading: boolean; + error: string | null; + }>({ + referenceTable: "", + referenceColumn: "", + displayColumn: "", + isLoading: true, + error: null, + }); + + // 로컬 상태 (UI 관련 설정만) const [localConfig, setLocalConfig] = useState({ entityType: config.entityType || "", displayFields: config.displayFields || [], searchFields: config.searchFields || [], - valueField: config.valueField || "id", - labelField: config.labelField || "name", + valueField: config.valueField || "", + labelField: config.labelField || "", multiple: config.multiple || false, - searchable: config.searchable !== false, // 기본값 true - placeholder: config.placeholder || "엔티티를 선택하세요", + searchable: config.searchable !== false, + placeholder: config.placeholder || "항목을 선택하세요", emptyMessage: config.emptyMessage || "검색 결과가 없습니다", pageSize: config.pageSize || 20, minSearchLength: config.minSearchLength || 1, @@ -47,10 +54,95 @@ export const EntityConfigPanel: React.FC = ({ filters: config.filters || {}, }); - // 새 필드 추가용 상태 - const [newFieldName, setNewFieldName] = useState(""); - const [newFieldLabel, setNewFieldLabel] = useState(""); - const [newFieldType, setNewFieldType] = useState("string"); + // 테이블 타입 관리에서 설정된 참조 테이블 정보 로드 + useEffect(() => { + const loadReferenceInfo = async () => { + // 컴포넌트의 테이블명과 컬럼명이 있는 경우에만 조회 + const tableName = widget.tableName; + const columnName = widget.columnName; + + if (!tableName || !columnName) { + setReferenceInfo({ + referenceTable: "", + referenceColumn: "", + displayColumn: "", + isLoading: false, + error: "테이블 또는 컬럼 정보가 없습니다.", + }); + return; + } + + try { + // 테이블 타입 관리에서 컬럼 정보 조회 + const columns = await tableTypeApi.getColumns(tableName); + const columnInfo = columns.find((col: any) => + (col.columnName || col.column_name) === columnName + ); + + if (columnInfo) { + const refTable = columnInfo.referenceTable || columnInfo.reference_table || ""; + const refColumn = columnInfo.referenceColumn || columnInfo.reference_column || ""; + const dispColumn = columnInfo.displayColumn || columnInfo.display_column || ""; + + // detailSettings에서도 정보 확인 (JSON 파싱) + let detailSettings: any = {}; + if (columnInfo.detailSettings) { + try { + if (typeof columnInfo.detailSettings === 'string') { + detailSettings = JSON.parse(columnInfo.detailSettings); + } else { + detailSettings = columnInfo.detailSettings; + } + } catch { + // JSON 파싱 실패 시 무시 + } + } + + const finalRefTable = refTable || detailSettings.referenceTable || ""; + const finalRefColumn = refColumn || detailSettings.referenceColumn || ""; + const finalDispColumn = dispColumn || detailSettings.displayColumn || ""; + + setReferenceInfo({ + referenceTable: finalRefTable, + referenceColumn: finalRefColumn, + displayColumn: finalDispColumn, + isLoading: false, + error: null, + }); + + // webTypeConfig에 참조 테이블 정보 자동 설정 + if (finalRefTable) { + const newConfig = { + ...localConfig, + valueField: finalRefColumn || "id", + labelField: finalDispColumn || "name", + }; + setLocalConfig(newConfig); + onUpdateProperty("webTypeConfig", newConfig); + } + } else { + setReferenceInfo({ + referenceTable: "", + referenceColumn: "", + displayColumn: "", + isLoading: false, + error: "컬럼 정보를 찾을 수 없습니다.", + }); + } + } catch (error) { + console.error("참조 테이블 정보 로드 실패:", error); + setReferenceInfo({ + referenceTable: "", + referenceColumn: "", + displayColumn: "", + isLoading: false, + error: "참조 테이블 정보 로드 실패", + }); + } + }; + + loadReferenceInfo(); + }, [widget.tableName, widget.columnName]); // 컴포넌트 변경 시 로컬 상태 동기화 useEffect(() => { @@ -59,11 +151,11 @@ export const EntityConfigPanel: React.FC = ({ entityType: currentConfig.entityType || "", displayFields: currentConfig.displayFields || [], searchFields: currentConfig.searchFields || [], - valueField: currentConfig.valueField || "id", - labelField: currentConfig.labelField || "name", + valueField: currentConfig.valueField || referenceInfo.referenceColumn || "", + labelField: currentConfig.labelField || referenceInfo.displayColumn || "", multiple: currentConfig.multiple || false, searchable: currentConfig.searchable !== false, - placeholder: currentConfig.placeholder || "엔티티를 선택하세요", + placeholder: currentConfig.placeholder || "항목을 선택하세요", emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다", pageSize: currentConfig.pageSize || 20, minSearchLength: currentConfig.minSearchLength || 1, @@ -73,7 +165,7 @@ export const EntityConfigPanel: React.FC = ({ apiEndpoint: currentConfig.apiEndpoint || "", filters: currentConfig.filters || {}, }); - }, [widget.webTypeConfig]); + }, [widget.webTypeConfig, referenceInfo.referenceColumn, referenceInfo.displayColumn]); // 설정 업데이트 핸들러 (즉시 부모에게 전달 - 드롭다운, 체크박스 등) const updateConfig = (field: keyof EntityTypeConfig, value: any) => { @@ -92,89 +184,6 @@ export const EntityConfigPanel: React.FC = ({ onUpdateProperty("webTypeConfig", localConfig); }; - // 필드 추가 - const addDisplayField = () => { - if (!newFieldName.trim() || !newFieldLabel.trim()) return; - - const newField: EntityField = { - name: newFieldName.trim(), - label: newFieldLabel.trim(), - type: newFieldType, - visible: true, - }; - - const newFields = [...localConfig.displayFields, newField]; - updateConfig("displayFields", newFields); - setNewFieldName(""); - setNewFieldLabel(""); - setNewFieldType("string"); - }; - - // 필드 제거 - const removeDisplayField = (index: number) => { - const newFields = localConfig.displayFields.filter((_, i) => i !== index); - updateConfig("displayFields", newFields); - }; - - // 필드 업데이트 (입력 중) - 로컬 상태만 업데이트 - const updateDisplayField = (index: number, field: keyof EntityField, value: any) => { - const newFields = [...localConfig.displayFields]; - newFields[index] = { ...newFields[index], [field]: value }; - setLocalConfig({ ...localConfig, displayFields: newFields }); - }; - - // 필드 업데이트 완료 (onBlur) - 부모에게 전달 - const handleFieldBlur = () => { - onUpdateProperty("webTypeConfig", localConfig); - }; - - // 검색 필드 토글 - const toggleSearchField = (fieldName: string) => { - const currentSearchFields = localConfig.searchFields || []; - const newSearchFields = currentSearchFields.includes(fieldName) - ? currentSearchFields.filter((f) => f !== fieldName) - : [...currentSearchFields, fieldName]; - updateConfig("searchFields", newSearchFields); - }; - - // 기본 엔티티 타입들 - const commonEntityTypes = [ - { value: "user", label: "사용자", fields: ["id", "name", "email", "department"] }, - { value: "department", label: "부서", fields: ["id", "name", "code", "parentId"] }, - { value: "product", label: "제품", fields: ["id", "name", "code", "category", "price"] }, - { value: "customer", label: "고객", fields: ["id", "name", "company", "contact"] }, - { value: "project", label: "프로젝트", fields: ["id", "name", "status", "manager", "startDate"] }, - ]; - - // 기본 엔티티 타입 적용 - const applyEntityType = (entityType: string) => { - const entityConfig = commonEntityTypes.find((e) => e.value === entityType); - if (!entityConfig) return; - - updateConfig("entityType", entityType); - updateConfig("apiEndpoint", `/api/entities/${entityType}`); - - const defaultFields: EntityField[] = entityConfig.fields.map((field) => ({ - name: field, - label: field.charAt(0).toUpperCase() + field.slice(1), - type: field.includes("Date") ? "date" : field.includes("price") || field.includes("Id") ? "number" : "string", - visible: true, - })); - - updateConfig("displayFields", defaultFields); - updateConfig("searchFields", [entityConfig.fields[1] || "name"]); // 두 번째 필드를 기본 검색 필드로 - }; - - // 필드 타입 옵션 - const fieldTypes = [ - { value: "string", label: "문자열" }, - { value: "number", label: "숫자" }, - { value: "date", label: "날짜" }, - { value: "boolean", label: "불린" }, - { value: "email", label: "이메일" }, - { value: "url", label: "URL" }, - ]; - return ( @@ -182,214 +191,97 @@ export const EntityConfigPanel: React.FC = ({ 엔티티 설정 - 데이터베이스 엔티티 선택 필드의 설정을 관리합니다. + + 데이터베이스 엔티티 선택 필드의 설정을 관리합니다. + - {/* 기본 설정 */} + {/* 참조 테이블 정보 (테이블 타입 관리에서 설정된 값 - 읽기 전용) */}
-

기본 설정

+

+ 참조 테이블 정보 + + 테이블 타입 관리에서 설정 + +

-
- - updateConfigLocal("entityType", e.target.value)} - onBlur={handleInputBlur} - placeholder="user, product, department..." - className="text-xs" - /> -
- -
- -
- {commonEntityTypes.map((entity) => ( - - ))} + {referenceInfo.isLoading ? ( +
+

참조 테이블 정보 로딩 중...

-
- -
- - updateConfigLocal("apiEndpoint", e.target.value)} - onBlur={handleInputBlur} - placeholder="/api/entities/user" - className="text-xs" - /> -
-
- - {/* 필드 매핑 */} -
-

필드 매핑

- -
-
- - updateConfigLocal("valueField", e.target.value)} - onBlur={handleInputBlur} - placeholder="id" - className="text-xs" - /> + ) : referenceInfo.error ? ( +
+

+ + {referenceInfo.error} +

+

+ 테이블 타입 관리에서 이 컬럼의 참조 테이블을 먼저 설정해주세요. +

- -
- - updateConfigLocal("labelField", e.target.value)} - onBlur={handleInputBlur} - placeholder="name" - className="text-xs" - /> + ) : !referenceInfo.referenceTable ? ( +
+

+ + 참조 테이블이 설정되지 않았습니다. +

+

+ 테이블 타입 관리에서 이 컬럼의 참조 테이블을 먼저 설정해주세요. +

-
-
- - {/* 표시 필드 관리 */} -
-

표시 필드

- - {/* 새 필드 추가 */} -
- -
- setNewFieldName(e.target.value)} - placeholder="필드명" - className="flex-1 text-xs" - /> - setNewFieldLabel(e.target.value)} - placeholder="라벨" - className="flex-1 text-xs" - /> - - -
-
- - {/* 현재 필드 목록 */} -
- -
- {localConfig.displayFields.map((field, index) => ( -
- { - const newFields = [...localConfig.displayFields]; - newFields[index] = { ...newFields[index], visible: checked }; - const newConfig = { ...localConfig, displayFields: newFields }; - setLocalConfig(newConfig); - onUpdateProperty("webTypeConfig", newConfig); - }} - /> - updateDisplayField(index, "name", e.target.value)} - onBlur={handleFieldBlur} - placeholder="필드명" - className="flex-1 text-xs" - /> - updateDisplayField(index, "label", e.target.value)} - onBlur={handleFieldBlur} - placeholder="라벨" - className="flex-1 text-xs" - /> - - - + ) : ( +
+
+
+ 참조 테이블: +
{referenceInfo.referenceTable}
- ))} +
+ 참조 컬럼: +
{referenceInfo.referenceColumn || "-"}
+
+
+ 표시 컬럼: +
{referenceInfo.displayColumn || "-"}
+
+
+

+ 이 정보는 테이블 타입 관리에서 변경할 수 있습니다. +

-
+ )}
- {/* 검색 설정 */} + {/* UI 모드 설정 */}
-

검색 설정

+

UI 설정

+ + {/* UI 모드 선택 */} +
+ + +

+ {(localConfig as any).uiMode === "select" && "검색 가능한 드롭다운 형태로 표시됩니다."} + {(localConfig as any).uiMode === "modal" && "모달 팝업에서 데이터를 검색하고 선택합니다."} + {((localConfig as any).uiMode === "combo" || !(localConfig as any).uiMode) && "입력 필드와 검색 버튼이 함께 표시됩니다."} + {(localConfig as any).uiMode === "autocomplete" && "입력하면서 자동완성 목록이 표시됩니다."} +

+
@@ -418,6 +310,11 @@ export const EntityConfigPanel: React.FC = ({ className="text-xs" />
+
+ + {/* 검색 설정 */} +
+

검색 설정

@@ -456,7 +353,7 @@ export const EntityConfigPanel: React.FC = ({ -

엔티티를 검색할 수 있습니다.

+

항목을 검색할 수 있습니다.

= ({ -

여러 엔티티를 선택할 수 있습니다.

+

여러 항목을 선택할 수 있습니다.

= ({
- {/* 필터 설정 */} -
-

추가 필터

- -
- -