From e8fc6643520dbd733fb20e18634169f964fb345f Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 21 Jan 2026 10:32:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=B6=84=ED=95=A0=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EB=B2=84=ED=8A=BC=20=ED=81=B4=EB=A6=AD?= =?UTF-8?q?=20=EC=8B=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Primary Key 컬럼명을 프론트엔드에서 백엔드로 전달하도록 개선 - 백엔드 자동 감지 실패 시에도 클라이언트 제공 값 우선 사용 - Primary Key 찾기 로직 개선 (설정값 > id > ID > non-null 필드) --- backend-node/src/routes/dataRoutes.ts | 11 ++++-- backend-node/src/services/dataService.ts | 35 ++++++++++++------- frontend/components/common/ScreenModal.tsx | 8 ++++- .../SplitPanelLayoutComponent.tsx | 35 +++++++++++++++---- 4 files changed, 66 insertions(+), 23 deletions(-) diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index 574f1cf8..a7757397 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -606,7 +606,7 @@ router.get( }); } - const { enableEntityJoin, groupByColumns } = req.query; + const { enableEntityJoin, groupByColumns, primaryKeyColumn } = req.query; const enableEntityJoinFlag = enableEntityJoin === "true" || (typeof enableEntityJoin === "boolean" && enableEntityJoin); @@ -626,17 +626,22 @@ router.get( } } + // 🆕 primaryKeyColumn 파싱 + const primaryKeyColumnStr = typeof primaryKeyColumn === "string" ? primaryKeyColumn : undefined; + console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, { enableEntityJoin: enableEntityJoinFlag, groupByColumns: groupByColumnsArray, + primaryKeyColumn: primaryKeyColumnStr, }); - // 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 포함) + // 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 + Primary Key 컬럼 포함) const result = await dataService.getRecordDetail( tableName, id, enableEntityJoinFlag, - groupByColumnsArray + groupByColumnsArray, + primaryKeyColumnStr // 🆕 Primary Key 컬럼명 전달 ); if (!result.success) { diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 8c6e63f0..60de20db 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -490,7 +490,8 @@ class DataService { tableName: string, id: string | number, enableEntityJoin: boolean = false, - groupByColumns: string[] = [] + groupByColumns: string[] = [], + primaryKeyColumn?: string // 🆕 클라이언트에서 전달한 Primary Key 컬럼명 ): Promise> { try { // 테이블 접근 검증 @@ -499,20 +500,30 @@ class DataService { return validation.error!; } - // Primary Key 컬럼 찾기 - const pkResult = await query<{ attname: string }>( - `SELECT a.attname - FROM pg_index i - JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) - WHERE i.indrelid = $1::regclass AND i.indisprimary`, - [tableName] - ); + // 🆕 클라이언트에서 전달한 Primary Key 컬럼이 있으면 우선 사용 + let pkColumn = primaryKeyColumn || ""; + + // Primary Key 컬럼이 없으면 자동 감지 + if (!pkColumn) { + const pkResult = await query<{ attname: string }>( + `SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = $1::regclass AND i.indisprimary`, + [tableName] + ); - let pkColumn = "id"; // 기본값 - if (pkResult.length > 0) { - pkColumn = pkResult[0].attname; + pkColumn = "id"; // 기본값 + if (pkResult.length > 0) { + pkColumn = pkResult[0].attname; + } + console.log(`🔑 [getRecordDetail] 자동 감지된 Primary Key:`, pkResult); + } else { + console.log(`🔑 [getRecordDetail] 클라이언트 제공 Primary Key: ${pkColumn}`); } + console.log(`🔑 [getRecordDetail] 테이블: ${tableName}, Primary Key 컬럼: ${pkColumn}, 조회 ID: ${id}`); + // 🆕 Entity Join이 활성화된 경우 if (enableEntityJoin) { const { EntityJoinService } = await import("./entityJoinService"); diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 44685dc0..fdd104df 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -374,8 +374,9 @@ export const ScreenModal: React.FC = ({ className }) => { const editId = urlParams.get("editId"); const tableName = urlParams.get("tableName") || screenInfo.tableName; const groupByColumnsParam = urlParams.get("groupByColumns"); + const primaryKeyColumn = urlParams.get("primaryKeyColumn"); // 🆕 Primary Key 컬럼명 - console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam }); + console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam, primaryKeyColumn }); // 수정 모드이고 editId가 있으면 해당 레코드 조회 if (mode === "edit" && editId && tableName) { @@ -414,6 +415,11 @@ export const ScreenModal: React.FC = ({ className }) => { params.groupByColumns = JSON.stringify(groupByColumns); console.log("✅ [ScreenModal] groupByColumns를 params에 추가:", params.groupByColumns); } + // 🆕 Primary Key 컬럼명 전달 (백엔드 자동 감지 실패 시 사용) + if (primaryKeyColumn) { + params.primaryKeyColumn = primaryKeyColumn; + console.log("✅ [ScreenModal] primaryKeyColumn을 params에 추가:", primaryKeyColumn); + } console.log("📡 [ScreenModal] 실제 API 요청:", { url: `/data/${tableName}/${editId}`, diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index ab387348..9b8e7cf0 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1590,21 +1590,40 @@ export const SplitPanelLayoutComponent: React.FC // 커스텀 모달 화면 열기 const rightTableName = componentConfig.rightPanel?.tableName || ""; - // Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드) + // Primary Key 찾기 (우선순위: 설정값 > id > ID > non-null 필드) + // 🔧 설정에서 primaryKeyColumn 지정 가능 + const configuredPrimaryKey = componentConfig.rightPanel?.editButton?.primaryKeyColumn; + let primaryKeyName = "id"; let primaryKeyValue: any; - if (item.id !== undefined && item.id !== null) { + if (configuredPrimaryKey && item[configuredPrimaryKey] !== undefined && item[configuredPrimaryKey] !== null) { + // 설정된 Primary Key 사용 + primaryKeyName = configuredPrimaryKey; + primaryKeyValue = item[configuredPrimaryKey]; + } else if (item.id !== undefined && item.id !== null) { primaryKeyName = "id"; primaryKeyValue = item.id; } else if (item.ID !== undefined && item.ID !== null) { primaryKeyName = "ID"; primaryKeyValue = item.ID; } else { - // 첫 번째 필드를 Primary Key로 간주 - const firstKey = Object.keys(item)[0]; - primaryKeyName = firstKey; - primaryKeyValue = item[firstKey]; + // 🔧 첫 번째 non-null 필드를 Primary Key로 간주 + const keys = Object.keys(item); + let found = false; + for (const key of keys) { + if (item[key] !== undefined && item[key] !== null) { + primaryKeyName = key; + primaryKeyValue = item[key]; + found = true; + break; + } + } + // 모든 필드가 null이면 첫 번째 필드 사용 + if (!found && keys.length > 0) { + primaryKeyName = keys[0]; + primaryKeyValue = item[keys[0]]; + } } console.log("✅ 수정 모달 열기:", { @@ -1629,7 +1648,7 @@ export const SplitPanelLayoutComponent: React.FC hasGroupByColumns: groupByColumns.length > 0, }); - // ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달) + // ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns + primaryKeyColumn 전달) window.dispatchEvent( new CustomEvent("openScreenModal", { detail: { @@ -1638,6 +1657,7 @@ export const SplitPanelLayoutComponent: React.FC mode: "edit", editId: primaryKeyValue, tableName: rightTableName, + primaryKeyColumn: primaryKeyName, // 🆕 Primary Key 컬럼명 전달 ...(groupByColumns.length > 0 && { groupByColumns: JSON.stringify(groupByColumns), }), @@ -1650,6 +1670,7 @@ export const SplitPanelLayoutComponent: React.FC screenId: modalScreenId, editId: primaryKeyValue, tableName: rightTableName, + primaryKeyColumn: primaryKeyName, groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", }); -- 2.43.0 From 29a4ab7b9df0bfaf7ea03bdfa9ef3d3adc1dc1f2 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 21 Jan 2026 11:40:47 +0900 Subject: [PATCH 2/2] =?UTF-8?q?entity-search-iniput=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EntitySearchInputComponent.tsx | 37 +++++- .../EntitySearchInputConfigPanel.tsx | 122 ++++++++++++++++-- .../EntitySearchInputWrapper.tsx | 5 + .../entity-search-input/EntitySearchModal.tsx | 11 +- .../components/entity-search-input/config.ts | 9 ++ 5 files changed, 166 insertions(+), 18 deletions(-) diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx index 5045a43b..f1604337 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx @@ -11,6 +11,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { dynamicFormApi } from "@/lib/api/dynamicForm"; import { cascadingRelationApi } from "@/lib/api/cascadingRelation"; +import { AutoFillMapping } from "./config"; export function EntitySearchInputComponent({ tableName, @@ -37,6 +38,8 @@ export function EntitySearchInputComponent({ formData, // 다중선택 props multiple: multipleProp, + // 자동 채움 매핑 props + autoFillMappings: autoFillMappingsProp, // 추가 props component, isInteractive, @@ -47,6 +50,7 @@ export function EntitySearchInputComponent({ isInteractive?: boolean; onFormDataChange?: (fieldName: string, value: any) => void; webTypeConfig?: any; // 웹타입 설정 (연쇄관계 등) + autoFillMappings?: AutoFillMapping[]; // 자동 채움 매핑 }) { // uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo" const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete"; @@ -54,6 +58,18 @@ export function EntitySearchInputComponent({ // 다중선택 및 연쇄관계 설정 (props > webTypeConfig > componentConfig 순서) const config = component?.componentConfig || component?.webTypeConfig || {}; const isMultiple = multipleProp ?? config.multiple ?? false; + + // 자동 채움 매핑 설정 (props > config) + const autoFillMappings: AutoFillMapping[] = autoFillMappingsProp ?? config.autoFillMappings ?? []; + + // 디버그: 자동 채움 매핑 설정 확인 + console.log("🔧 [EntitySearchInput] 자동 채움 매핑 설정:", { + autoFillMappingsProp, + configAutoFillMappings: config.autoFillMappings, + effectiveAutoFillMappings: autoFillMappings, + isInteractive, + hasOnFormDataChange: !!onFormDataChange, + }); // 연쇄관계 설정 추출 const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode; @@ -309,6 +325,23 @@ export function EntitySearchInputComponent({ console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue); } } + + // 🆕 자동 채움 매핑 적용 + if (autoFillMappings.length > 0 && isInteractive && onFormDataChange && fullData) { + console.log("🔄 자동 채움 매핑 적용:", { mappings: autoFillMappings, fullData }); + + for (const mapping of autoFillMappings) { + if (mapping.sourceField && mapping.targetField) { + const sourceValue = fullData[mapping.sourceField]; + if (sourceValue !== undefined) { + onFormDataChange(mapping.targetField, sourceValue); + console.log(` ✅ ${mapping.sourceField} → ${mapping.targetField}:`, sourceValue); + } else { + console.log(` ⚠️ ${mapping.sourceField} 값이 없음`); + } + } + } + } }; // 다중선택 모드에서 개별 항목 제거 @@ -436,7 +469,7 @@ export function EntitySearchInputComponent({ const isSelected = selectedValues.includes(String(option[valueField])); return ( handleSelectOption(option)} className="text-xs sm:text-sm" @@ -509,7 +542,7 @@ export function EntitySearchInputComponent({ {effectiveOptions.map((option, index) => ( handleSelectOption(option)} className="text-xs sm:text-sm" diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx index fb75daa4..22a52aab 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx @@ -10,7 +10,7 @@ import { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button"; import { Plus, X, Check, ChevronsUpDown, Database, Info, Link2, ExternalLink } from "lucide-react"; // allComponents는 현재 사용되지 않지만 향후 확장을 위해 props에 유지 -import { EntitySearchInputConfig } from "./config"; +import { EntitySearchInputConfig, AutoFillMapping } from "./config"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { tableTypeApi } from "@/lib/api/screen"; import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation"; @@ -236,6 +236,7 @@ export function EntitySearchInputConfigPanel({ const newConfig = { ...localConfig, ...updates }; setLocalConfig(newConfig); onConfigChange(newConfig); + console.log("📝 [EntitySearchInput] 설정 업데이트:", { updates, newConfig }); }; // 연쇄 드롭다운 활성화/비활성화 @@ -636,9 +637,9 @@ export function EntitySearchInputConfigPanel({ 필드를 찾을 수 없습니다. - {tableColumns.map((column) => ( + {tableColumns.map((column, idx) => ( { updateConfig({ displayField: column.columnName }); @@ -690,9 +691,9 @@ export function EntitySearchInputConfigPanel({ 필드를 찾을 수 없습니다. - {tableColumns.map((column) => ( + {tableColumns.map((column, idx) => ( { updateConfig({ valueField: column.columnName }); @@ -812,8 +813,8 @@ export function EntitySearchInputConfigPanel({ - {tableColumns.map((col) => ( - + {tableColumns.map((col, colIdx) => ( + {col.displayName || col.columnName} ))} @@ -860,8 +861,8 @@ export function EntitySearchInputConfigPanel({ - {tableColumns.map((col) => ( - + {tableColumns.map((col, colIdx) => ( + {col.displayName || col.columnName} ))} @@ -919,8 +920,8 @@ export function EntitySearchInputConfigPanel({ - {tableColumns.map((col) => ( - + {tableColumns.map((col, colIdx) => ( + {col.displayName || col.columnName} ))} @@ -939,6 +940,105 @@ export function EntitySearchInputConfigPanel({ )} + + {/* 자동 채움 매핑 설정 */} +
+
+
+ +

자동 채움 매핑

+
+ +
+

+ 엔티티를 선택하면 소스 필드의 값이 대상 필드에 자동으로 채워집니다. +

+ + {(localConfig.autoFillMappings || []).length > 0 && ( +
+ {(localConfig.autoFillMappings || []).map((mapping, index) => ( +
+ {/* 소스 필드 (선택된 엔티티) */} +
+ + +
+ + {/* 화살표 */} +
+ +
+ + {/* 대상 필드 (폼) */} +
+ + { + const mappings = [...(localConfig.autoFillMappings || [])]; + mappings[index] = { ...mappings[index], targetField: e.target.value }; + updateConfig({ autoFillMappings: mappings }); + }} + placeholder="폼 필드명" + className="h-8 text-xs" + /> +
+ + {/* 삭제 버튼 */} + +
+ ))} +
+ )} + + {(localConfig.autoFillMappings || []).length === 0 && ( +
+ 매핑이 없습니다. + 추가 버튼을 클릭하여 매핑을 추가하세요. +
+ )} +
); } diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchInputWrapper.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchInputWrapper.tsx index dd6ed5c4..f8a3a22e 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchInputWrapper.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchInputWrapper.tsx @@ -37,6 +37,9 @@ export const EntitySearchInputWrapper: React.FC = ({ // placeholder const placeholder = config.placeholder || widget?.placeholder || "항목을 선택하세요"; + + // 자동 채움 매핑 설정 + const autoFillMappings = config.autoFillMappings || []; console.log("🏢 EntitySearchInputWrapper 렌더링:", { tableName, @@ -44,6 +47,7 @@ export const EntitySearchInputWrapper: React.FC = ({ valueField, uiMode, multiple, + autoFillMappings, value, config, }); @@ -68,6 +72,7 @@ export const EntitySearchInputWrapper: React.FC = ({ value={value} onChange={onChange} multiple={multiple} + autoFillMappings={autoFillMappings} component={component} isInteractive={props.isInteractive} onFormDataChange={props.onFormDataChange} diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx index 555efe9b..422dfbfa 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx @@ -148,9 +148,9 @@ export function EntitySearchModal({ 선택 )} - {displayColumns.map((col) => ( + {displayColumns.map((col, colIdx) => ( {col} @@ -179,7 +179,8 @@ export function EntitySearchModal({ ) : ( results.map((item, index) => { - const uniqueKey = item[valueField] !== undefined ? `${item[valueField]}` : `row-${index}`; + // null과 undefined 모두 체크하여 유니크 키 생성 + const uniqueKey = item[valueField] != null ? `${item[valueField]}` : `row-${index}`; const isSelected = isItemSelected(item); return ( )} - {displayColumns.map((col) => ( - + {displayColumns.map((col, colIdx) => ( + {item[col] || "-"} ))} diff --git a/frontend/lib/registry/components/entity-search-input/config.ts b/frontend/lib/registry/components/entity-search-input/config.ts index fab81c9f..3dae8779 100644 --- a/frontend/lib/registry/components/entity-search-input/config.ts +++ b/frontend/lib/registry/components/entity-search-input/config.ts @@ -1,3 +1,9 @@ +// 자동 채움 매핑 타입 +export interface AutoFillMapping { + sourceField: string; // 선택된 엔티티의 필드 (예: customer_name) + targetField: string; // 폼의 필드 (예: partner_name) +} + export interface EntitySearchInputConfig { tableName: string; displayField: string; @@ -18,5 +24,8 @@ export interface EntitySearchInputConfig { cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등) cascadingRole?: "parent" | "child"; // 역할 (부모/자식) cascadingParentField?: string; // 부모 필드의 컬럼명 (자식 역할일 때만 사용) + + // 자동 채움 매핑 설정 + autoFillMappings?: AutoFillMapping[]; // 엔티티 선택 시 다른 필드에 자동으로 값 채우기 } -- 2.43.0