From 86313c5e89d7a7ec07630160f4544c511b17e7de Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 15:07:26 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20SelectedItemsDetailInput=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=20null=20?= =?UTF-8?q?=EB=A0=88=EC=BD=94=EB=93=9C=20=EC=82=BD=EC=9E=85=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buttonActions.ts: formData가 배열인 경우 일반 저장 건너뜀 - SelectedItemsDetailInput이 UPSERT를 완료한 후 일반 저장이 실행되어 null 레코드가 삽입되던 문제 해결 - ScreenModal에서 그룹 레코드를 배열로 전달하는 경우 감지하여 처리 - skipDefaultSave 플래그가 제대로 작동하지 않던 문제 근본 해결 --- backend-node/src/services/dataService.ts | 6 + .../src/services/entityJoinService.ts | 74 +- frontend/components/common/ScreenModal.tsx | 11 +- .../SelectedItemsDetailInputComponent.tsx | 1720 +++++++++-------- frontend/lib/utils/buttonActions.ts | 9 + 5 files changed, 1011 insertions(+), 809 deletions(-) diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index d9b13475..fd85248d 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -1227,18 +1227,24 @@ class DataService { // 새 레코드 처리 (INSERT or UPDATE) for (const newRecord of records) { + console.log(`🔍 처리할 새 레코드:`, newRecord); + // 날짜 필드 정규화 const normalizedRecord: Record = {}; for (const [key, value] of Object.entries(newRecord)) { normalizedRecord[key] = normalizeDateValue(value); } + console.log(`🔄 정규화된 레코드:`, normalizedRecord); + // 전체 레코드 데이터 (parentKeys + normalizedRecord) const fullRecord = { ...parentKeys, ...normalizedRecord }; // 고유 키: parentKeys 제외한 나머지 필드들 const uniqueFields = Object.keys(normalizedRecord); + console.log(`🔑 고유 필드들:`, uniqueFields); + // 기존 레코드에서 일치하는 것 찾기 const existingRecord = existingRecords.rows.find((existing) => { return uniqueFields.every((field) => { diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 3283ea09..a8f6c482 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -134,23 +134,32 @@ export class EntityJoinService { `🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}` ); } else { - // display_column이 "none"이거나 없는 경우 기본 표시 컬럼 설정 - let defaultDisplayColumn = referenceColumn; - if (referenceTable === "dept_info") { - defaultDisplayColumn = "dept_name"; - } else if (referenceTable === "company_info") { - defaultDisplayColumn = "company_name"; - } else if (referenceTable === "user_info") { - defaultDisplayColumn = "user_name"; - } else if (referenceTable === "category_values") { - defaultDisplayColumn = "category_name"; - } + // display_column이 "none"이거나 없는 경우 참조 테이블의 모든 컬럼 가져오기 + logger.info(`🔍 ${referenceTable}의 모든 컬럼 조회 중...`); - displayColumns = [defaultDisplayColumn]; - logger.info( - `🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name} → ${defaultDisplayColumn} (${referenceTable})` + // 참조 테이블의 모든 컬럼 이름 가져오기 + const tableColumnsResult = await query<{ column_name: string }>( + `SELECT column_name + FROM information_schema.columns + WHERE table_name = $1 + AND table_schema = 'public' + ORDER BY ordinal_position`, + [referenceTable] ); - logger.info(`🔍 생성된 displayColumns 배열:`, displayColumns); + + if (tableColumnsResult.length > 0) { + displayColumns = tableColumnsResult.map((col) => col.column_name); + logger.info( + `✅ ${referenceTable}의 모든 컬럼 자동 포함 (${displayColumns.length}개):`, + displayColumns.join(", ") + ); + } else { + // 테이블 컬럼을 못 찾으면 기본값 사용 + displayColumns = [referenceColumn]; + logger.warn( + `⚠️ ${referenceTable}의 컬럼 조회 실패, 기본값 사용: ${referenceColumn}` + ); + } } // 별칭 컬럼명 생성 (writer -> writer_name) @@ -346,25 +355,26 @@ export class EntityJoinService { ); } } else { - // 여러 컬럼인 경우 CONCAT으로 연결 - // 기본 테이블과 조인 테이블의 컬럼을 구분해서 처리 - const concatParts = displayColumns - .map((col) => { - // ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴 - const isJoinTableColumn = - config.referenceTable && config.referenceTable !== tableName; + // 🆕 여러 컬럼인 경우 각 컬럼을 개별 alias로 반환 (합치지 않음) + // 예: item_info.standard_price → sourceColumn_standard_price (item_id_standard_price) + displayColumns.forEach((col) => { + const isJoinTableColumn = + config.referenceTable && config.referenceTable !== tableName; - if (isJoinTableColumn) { - // 조인 테이블 컬럼은 조인 별칭 사용 - return `COALESCE(${alias}.${col}::TEXT, '')`; - } else { - // 기본 테이블 컬럼은 main 별칭 사용 - return `COALESCE(main.${col}::TEXT, '')`; - } - }) - .join(` || '${separator}' || `); + const individualAlias = `${config.sourceColumn}_${col}`; - resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`); + if (isJoinTableColumn) { + // 조인 테이블 컬럼은 조인 별칭 사용 + resultColumns.push( + `COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}` + ); + } else { + // 기본 테이블 컬럼은 main 별칭 사용 + resultColumns.push( + `COALESCE(main.${col}::TEXT, '') AS ${individualAlias}` + ); + } + }); // 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용) const isJoinTableColumn = diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index cf0a5edb..8cf53d5c 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -262,7 +262,7 @@ export const ScreenModal: React.FC = ({ className }) => { // 🆕 apiClient를 named import로 가져오기 const { apiClient } = await import("@/lib/api/client"); const params: any = { - enableEntityJoin: true, + enableEntityJoin: true, // 엔티티 조인 활성화 (모든 엔티티 컬럼 자동 포함) }; if (groupByColumns.length > 0) { params.groupByColumns = JSON.stringify(groupByColumns); @@ -325,7 +325,14 @@ export const ScreenModal: React.FC = ({ className }) => { console.log("📥 [ScreenModal] API 응답 원본:", JSON.stringify(response.data, null, 2)); const normalizedData = normalizeDates(response.data); console.log("📥 [ScreenModal] 정규화 후:", JSON.stringify(normalizedData, null, 2)); - setFormData(normalizedData); + + // 🔧 배열 데이터는 formData로 설정하지 않음 (SelectedItemsDetailInput만 사용) + if (Array.isArray(normalizedData)) { + console.log("⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다."); + setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용 + } else { + setFormData(normalizedData); + } // setFormData 직후 확인 console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)"); diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index ddb35db6..925ca174 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -45,35 +45,36 @@ export const SelectedItemsDetailInputComponent: React.FC ({ - dataSourceId: component.id || "default", - displayColumns: [], - additionalFields: [], - layout: "grid", - inputMode: "inline", // 🆕 기본값 - showIndex: true, - allowRemove: false, - emptyMessage: "전달받은 데이터가 없습니다.", - targetTable: "", - ...config, - ...component.config, - } as SelectedItemsDetailInputConfig), [config, component.config, component.id]); + const componentConfig = useMemo( + () => + ({ + dataSourceId: component.id || "default", + displayColumns: [], + additionalFields: [], + layout: "grid", + inputMode: "inline", // 🆕 기본값 + showIndex: true, + allowRemove: false, + emptyMessage: "전달받은 데이터가 없습니다.", + targetTable: "", + ...config, + ...component.config, + }) as SelectedItemsDetailInputConfig, + [config, component.config, component.id], + ); // 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id const dataSourceId = useMemo( () => urlDataSourceId || componentConfig.dataSourceId || component.id || "default", - [urlDataSourceId, componentConfig.dataSourceId, component.id] + [urlDataSourceId, componentConfig.dataSourceId, component.id], ); - + // 전체 레지스트리를 가져와서 컴포넌트 내부에서 필터링 (캐싱 문제 회피) const dataRegistry = useModalDataStore((state) => state.dataRegistry); - const modalData = useMemo( - () => dataRegistry[dataSourceId] || [], - [dataRegistry, dataSourceId] - ); - + const modalData = useMemo(() => dataRegistry[dataSourceId] || [], [dataRegistry, dataSourceId]); + // 전체 dataRegistry를 사용 (모든 누적 데이터에 접근 가능) console.log("📦 [SelectedItemsDetailInput] 사용 가능한 모든 데이터:", { keys: Object.keys(dataRegistry), @@ -82,21 +83,21 @@ export const SelectedItemsDetailInputComponent: React.FC state.updateItemData); // 🆕 새로운 데이터 구조: 품목별로 여러 개의 상세 데이터 const [items, setItems] = useState([]); - + // 🆕 입력 모드 상태 (modal 모드일 때 사용) const [isEditing, setIsEditing] = useState(false); const [editingItemId, setEditingItemId] = useState(null); // 현재 편집 중인 품목 ID const [editingGroupId, setEditingGroupId] = useState(null); // 현재 편집 중인 그룹 ID const [editingDetailId, setEditingDetailId] = useState(null); // 현재 편집 중인 항목 ID - + // 🆕 코드 카테고리별 옵션 캐싱 const [codeOptions, setCodeOptions] = useState>>({}); - + // 디버깅 로그 useEffect(() => { console.log("📍 [SelectedItemsDetailInput] 설정 확인:", { @@ -108,8 +109,16 @@ export const SelectedItemsDetailInputComponent: React.FC { const loadCodeOptions = async () => { @@ -120,22 +129,22 @@ export const SelectedItemsDetailInputComponent: React.FC field.inputType === "code" || field.inputType === "category" + (field) => field.inputType === "code" || field.inputType === "category", ); - + console.log("🔍 [loadCodeOptions] code/category 필드:", codeFields); - + if (!codeFields || codeFields.length === 0) { console.log("⚠️ [loadCodeOptions] code/category 타입 필드가 없습니다"); return; } - + const newOptions: Record> = { ...codeOptions }; - + // 🆕 대상 테이블의 컬럼 메타데이터에서 codeCategory 가져오기 const targetTable = componentConfig.targetTable; let targetTableColumns: any[] = []; - + if (targetTable) { try { const { tableTypeApi } = await import("@/lib/api/screen"); @@ -145,24 +154,24 @@ export const SelectedItemsDetailInputComponent: React.FC ({ label: item.value_label || item.valueLabel, @@ -176,23 +185,23 @@ export const SelectedItemsDetailInputComponent: React.FC 0) { const columnMeta = targetTableColumns.find( - (col: any) => (col.columnName || col.column_name) === field.name + (col: any) => (col.columnName || col.column_name) === field.name, ); if (columnMeta) { codeCategory = columnMeta.codeCategory || columnMeta.code_category; console.log(`🔍 필드 "${field.name}"의 codeCategory를 메타데이터에서 찾음:`, codeCategory); } } - + if (!codeCategory) { console.warn(`⚠️ 필드 "${field.name}"의 codeCategory를 찾을 수 없습니다`); continue; } - + const response = await commonCodeApi.options.getOptions(codeCategory); if (response.success && response.data) { newOptions[field.name] = response.data.map((opt) => ({ @@ -206,10 +215,10 @@ export const SelectedItemsDetailInputComponent: React.FC = {}; - + // 🔧 각 그룹별로 고유한 엔트리만 수집 (중복 제거) groups.forEach((group) => { const groupFields = additionalFields.filter((field: any) => field.groupId === group.id); - + if (groupFields.length === 0) { mainFieldGroups[group.id] = []; return; } - + // 🆕 각 레코드에서 그룹 데이터 추출 const entriesMap = new Map(); - + dataArray.forEach((record) => { const entryData: Record = {}; - + groupFields.forEach((field: any) => { let fieldValue = record[field.name]; - + + // 🆕 값이 없으면 autoFillFrom 로직 적용 + if ((fieldValue === undefined || fieldValue === null) && field.autoFillFrom) { + let sourceData: any = null; + + if (field.autoFillFromTable) { + // 특정 테이블에서 가져오기 + const tableData = dataRegistry[field.autoFillFromTable]; + if (tableData && tableData.length > 0) { + sourceData = tableData[0].originalData || tableData[0]; + console.log( + `✅ [수정모드 autoFill] ${field.name} ← ${field.autoFillFrom} (테이블: ${field.autoFillFromTable}):`, + sourceData?.[field.autoFillFrom], + ); + } else { + // 🆕 dataRegistry에 없으면 record에서 직접 찾기 (Entity Join된 경우) + sourceData = record; + console.log( + `⚠️ [수정모드 autoFill] dataRegistry에 ${field.autoFillFromTable} 없음, record에서 직접 찾기`, + ); + } + } else { + // record 자체에서 가져오기 + sourceData = record; + console.log( + `✅ [수정모드 autoFill] ${field.name} ← ${field.autoFillFrom} (레코드):`, + sourceData?.[field.autoFillFrom], + ); + } + + if (sourceData && sourceData[field.autoFillFrom] !== undefined) { + fieldValue = sourceData[field.autoFillFrom]; + console.log(`✅ [수정모드 autoFill] ${field.name} 값 설정:`, fieldValue); + } else { + // 🆕 Entity Join의 경우 sourceColumn_fieldName 형식으로도 찾기 + // 예: item_id_standard_price, customer_id_customer_name + // autoFillFromTable에서 어떤 sourceColumn인지 추론 + const possibleKeys = Object.keys(sourceData || {}).filter((key) => + key.endsWith(`_${field.autoFillFrom}`), + ); + + if (possibleKeys.length > 0) { + fieldValue = sourceData[possibleKeys[0]]; + console.log( + `✅ [수정모드 autoFill] ${field.name} Entity Join 키로 찾음 (${possibleKeys[0]}):`, + fieldValue, + ); + } else { + console.warn( + `⚠️ [수정모드 autoFill] ${field.name} ← ${field.autoFillFrom} 실패 (시도한 키들: ${field.autoFillFrom}, *_${field.autoFillFrom})`, + ); + } + } + } + // 🔧 값이 없으면 기본값 사용 (false, 0, "" 등 falsy 값도 유효한 값으로 처리) if (fieldValue === undefined || fieldValue === null) { // 기본값이 있으면 사용, 없으면 필드 타입에 따라 기본값 설정 @@ -270,7 +335,7 @@ export const SelectedItemsDetailInputComponent: React.FC 0) { console.log("📦 [SelectedItemsDetailInput] 데이터 수신:", modalData); - + // 🆕 각 품목마다 빈 fieldGroups 객체를 가진 ItemData 생성 const groups = componentConfig.fieldGroups || []; const newItems: ItemData[] = modalData.map((item) => { const fieldGroups: Record = {}; - + // 각 그룹에 대해 빈 배열 초기화 groups.forEach((group) => { fieldGroups[group.id] = []; }); - + // 그룹이 없으면 기본 그룹 생성 if (groups.length === 0) { fieldGroups["default"] = []; } - + // 🔧 modalData의 구조 확인: item.originalData가 있으면 그것을 사용, 없으면 item 자체를 사용 const actualData = (item as any).originalData || item; - + return { id: String(item.id), - originalData: actualData, // 🔧 실제 데이터 추출 + originalData: actualData, // 🔧 실제 데이터 추출 fieldGroups, }; }); - + setItems(newItems); - + console.log("✅ [SelectedItemsDetailInput] items 설정 완료:", { itemsLength: newItems.length, - groups: groups.map(g => g.id), + groups: groups.map((g) => g.id), firstItem: newItems[0], }); } @@ -361,64 +426,67 @@ export const SelectedItemsDetailInputComponent: React.FC[] => { - const allRecords: Record[] = []; - const groups = componentConfig.fieldGroups || []; - const additionalFields = componentConfig.additionalFields || []; + const generateCartesianProduct = useCallback( + (itemsList: ItemData[]): Record[] => { + const allRecords: Record[] = []; + const groups = componentConfig.fieldGroups || []; + const additionalFields = componentConfig.additionalFields || []; - itemsList.forEach((item) => { - // 각 그룹의 엔트리 배열들을 준비 - const groupEntriesArrays: GroupEntry[][] = groups.map(group => item.fieldGroups[group.id] || []); + itemsList.forEach((item) => { + // 각 그룹의 엔트리 배열들을 준비 + const groupEntriesArrays: GroupEntry[][] = groups.map((group) => item.fieldGroups[group.id] || []); - // Cartesian Product 재귀 함수 - const cartesian = (arrays: GroupEntry[][], currentIndex: number, currentCombination: Record) => { - if (currentIndex === arrays.length) { - // 모든 그룹을 순회했으면 조합 완성 - allRecords.push({ ...currentCombination }); - return; - } + // Cartesian Product 재귀 함수 + const cartesian = (arrays: GroupEntry[][], currentIndex: number, currentCombination: Record) => { + if (currentIndex === arrays.length) { + // 모든 그룹을 순회했으면 조합 완성 + allRecords.push({ ...currentCombination }); + return; + } - const currentGroupEntries = arrays[currentIndex]; - if (currentGroupEntries.length === 0) { - // 현재 그룹에 데이터가 없으면 빈 조합으로 다음 그룹 진행 - cartesian(arrays, currentIndex + 1, currentCombination); - return; - } + const currentGroupEntries = arrays[currentIndex]; + if (currentGroupEntries.length === 0) { + // 현재 그룹에 데이터가 없으면 빈 조합으로 다음 그룹 진행 + cartesian(arrays, currentIndex + 1, currentCombination); + return; + } - // 현재 그룹의 각 엔트리마다 재귀 - currentGroupEntries.forEach(entry => { - const newCombination = { ...currentCombination }; - - // 현재 그룹의 필드들을 조합에 추가 - const groupFields = additionalFields.filter(f => f.groupId === groups[currentIndex].id); - groupFields.forEach(field => { - if (entry[field.name] !== undefined) { - newCombination[field.name] = entry[field.name]; - } + // 현재 그룹의 각 엔트리마다 재귀 + currentGroupEntries.forEach((entry) => { + const newCombination = { ...currentCombination }; + + // 현재 그룹의 필드들을 조합에 추가 + const groupFields = additionalFields.filter((f) => f.groupId === groups[currentIndex].id); + groupFields.forEach((field) => { + if (entry[field.name] !== undefined) { + newCombination[field.name] = entry[field.name]; + } + }); + + cartesian(arrays, currentIndex + 1, newCombination); }); - - cartesian(arrays, currentIndex + 1, newCombination); - }); - }; + }; - // 재귀 시작 - cartesian(groupEntriesArrays, 0, {}); - }); + // 재귀 시작 + cartesian(groupEntriesArrays, 0, {}); + }); - console.log("🔀 [generateCartesianProduct] 생성된 레코드:", { - count: allRecords.length, - records: allRecords, - }); + console.log("🔀 [generateCartesianProduct] 생성된 레코드:", { + count: allRecords.length, + records: allRecords, + }); - return allRecords; - }, [componentConfig.fieldGroups, componentConfig.additionalFields]); + return allRecords; + }, + [componentConfig.fieldGroups, componentConfig.additionalFields], + ); // 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식) useEffect(() => { const handleSaveRequest = async (event: Event) => { // component.id를 문자열로 안전하게 변환 const componentKey = String(component.id || "selected_items"); - + console.log("🔔 [SelectedItemsDetailInput] beforeFormSave 이벤트 수신!", { itemsCount: items.length, hasOnFormDataChange: !!onFormDataChange, @@ -426,7 +494,7 @@ export const SelectedItemsDetailInputComponent: React.FC = {}; - + // formData 또는 items[0].originalData에서 부모 데이터 가져오기 // formData가 배열이면 첫 번째 항목 사용 let sourceData: any = formData; @@ -461,22 +529,29 @@ export const SelectedItemsDetailInputComponent: React.FC { - const value = sourceData[mapping.sourceField]; + // 🆕 Entity Join 필드도 처리 (예: customer_code -> customer_id_customer_code) + const value = getFieldValue(sourceData, mapping.sourceField); if (value !== undefined && value !== null) { parentKeys[mapping.targetField] = value; + console.log(`✅ [parentKeys] ${mapping.sourceField} → ${mapping.targetField}:`, value); } else { - console.warn(`⚠️ [SelectedItemsDetailInput] 부모 키 누락: ${mapping.sourceField} → ${mapping.targetField}`); + console.warn( + `⚠️ [SelectedItemsDetailInput] 부모 키 누락: ${mapping.sourceField} → ${mapping.targetField}`, + ); } }); @@ -484,7 +559,7 @@ export const SelectedItemsDetailInputComponent: React.FC { window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener); }; @@ -601,123 +697,129 @@ export const SelectedItemsDetailInputComponent: React.FC { - // 자동 계산 설정이 없으면 계산하지 않음 - if (!componentConfig.autoCalculation) return 0; - - const { inputFields, valueMapping } = componentConfig.autoCalculation; - - // 기본 단가 - const basePrice = parseFloat(entry[inputFields.basePrice] || "0"); - if (basePrice === 0) return 0; - - let price = basePrice; - - // 1단계: 할인 적용 - const discountTypeValue = entry[inputFields.discountType]; - const discountValue = parseFloat(entry[inputFields.discountValue] || "0"); - - // 매핑을 통해 실제 연산 타입 결정 - const discountOperation = valueMapping?.discountType?.[discountTypeValue] || "none"; - - if (discountOperation === "rate") { - price = price * (1 - discountValue / 100); - } else if (discountOperation === "amount") { - price = price - discountValue; - } - - // 2단계: 반올림 적용 - const roundingTypeValue = entry[inputFields.roundingType]; - const roundingUnitValue = entry[inputFields.roundingUnit]; - - // 매핑을 통해 실제 연산 타입 결정 - const roundingOperation = valueMapping?.roundingType?.[roundingTypeValue] || "none"; - const unit = valueMapping?.roundingUnit?.[roundingUnitValue] || parseFloat(roundingUnitValue) || 1; - - if (roundingOperation === "round") { - price = Math.round(price / unit) * unit; - } else if (roundingOperation === "floor") { - price = Math.floor(price / unit) * unit; - } else if (roundingOperation === "ceil") { - price = Math.ceil(price / unit) * unit; - } - - return price; - }, [componentConfig.autoCalculation]); + const calculatePrice = useCallback( + (entry: GroupEntry): number => { + // 자동 계산 설정이 없으면 계산하지 않음 + if (!componentConfig.autoCalculation) return 0; + + const { inputFields, valueMapping } = componentConfig.autoCalculation; + + // 기본 단가 + const basePrice = parseFloat(entry[inputFields.basePrice] || "0"); + if (basePrice === 0) return 0; + + let price = basePrice; + + // 1단계: 할인 적용 + const discountTypeValue = entry[inputFields.discountType]; + const discountValue = parseFloat(entry[inputFields.discountValue] || "0"); + + // 매핑을 통해 실제 연산 타입 결정 + const discountOperation = valueMapping?.discountType?.[discountTypeValue] || "none"; + + if (discountOperation === "rate") { + price = price * (1 - discountValue / 100); + } else if (discountOperation === "amount") { + price = price - discountValue; + } + + // 2단계: 반올림 적용 + const roundingTypeValue = entry[inputFields.roundingType]; + const roundingUnitValue = entry[inputFields.roundingUnit]; + + // 매핑을 통해 실제 연산 타입 결정 + const roundingOperation = valueMapping?.roundingType?.[roundingTypeValue] || "none"; + const unit = valueMapping?.roundingUnit?.[roundingUnitValue] || parseFloat(roundingUnitValue) || 1; + + if (roundingOperation === "round") { + price = Math.round(price / unit) * unit; + } else if (roundingOperation === "floor") { + price = Math.floor(price / unit) * unit; + } else if (roundingOperation === "ceil") { + price = Math.ceil(price / unit) * unit; + } + + return price; + }, + [componentConfig.autoCalculation], + ); // 🆕 그룹별 필드 변경 핸들러: itemId + groupId + entryId + fieldName - const handleFieldChange = useCallback((itemId: string, groupId: string, entryId: string, fieldName: string, value: any) => { - console.log("📝 [handleFieldChange] 필드 값 변경:", { - itemId, - groupId, - entryId, - fieldName, - value, - }); - - setItems((prevItems) => { - return prevItems.map((item) => { - if (item.id !== itemId) return item; - - const groupEntries = item.fieldGroups[groupId] || []; - const existingEntryIndex = groupEntries.findIndex((e) => e.id === entryId); - - if (existingEntryIndex >= 0) { - // 기존 entry 업데이트 (항상 이 경로로만 진입) - const updatedEntries = [...groupEntries]; - const updatedEntry = { - ...updatedEntries[existingEntryIndex], - [fieldName]: value, - }; - - console.log("✅ [handleFieldChange] Entry 업데이트:", { - beforeKeys: Object.keys(updatedEntries[existingEntryIndex]), - afterKeys: Object.keys(updatedEntry), - updatedEntry, - }); - - // 🆕 가격 관련 필드가 변경되면 자동 계산 - if (componentConfig.autoCalculation) { - const { inputFields, targetField } = componentConfig.autoCalculation; - const priceRelatedFields = [ - inputFields.basePrice, - inputFields.discountType, - inputFields.discountValue, - inputFields.roundingType, - inputFields.roundingUnit, - ]; - - if (priceRelatedFields.includes(fieldName)) { - const calculatedPrice = calculatePrice(updatedEntry); - updatedEntry[targetField] = calculatedPrice; - console.log("💰 [자동 계산]", { - basePrice: updatedEntry[inputFields.basePrice], - discountType: updatedEntry[inputFields.discountType], - discountValue: updatedEntry[inputFields.discountValue], - roundingType: updatedEntry[inputFields.roundingType], - roundingUnit: updatedEntry[inputFields.roundingUnit], - calculatedPrice, - targetField, - }); - } - } - - updatedEntries[existingEntryIndex] = updatedEntry; - return { - ...item, - fieldGroups: { - ...item.fieldGroups, - [groupId]: updatedEntries, - }, - }; - } else { - // 이 경로는 발생하면 안 됨 (handleAddGroupEntry에서 미리 추가함) - console.warn("⚠️ entry가 없는데 handleFieldChange 호출됨:", { itemId, groupId, entryId }); - return item; - } + const handleFieldChange = useCallback( + (itemId: string, groupId: string, entryId: string, fieldName: string, value: any) => { + console.log("📝 [handleFieldChange] 필드 값 변경:", { + itemId, + groupId, + entryId, + fieldName, + value, }); - }); - }, [calculatePrice]); + + setItems((prevItems) => { + return prevItems.map((item) => { + if (item.id !== itemId) return item; + + const groupEntries = item.fieldGroups[groupId] || []; + const existingEntryIndex = groupEntries.findIndex((e) => e.id === entryId); + + if (existingEntryIndex >= 0) { + // 기존 entry 업데이트 (항상 이 경로로만 진입) + const updatedEntries = [...groupEntries]; + const updatedEntry = { + ...updatedEntries[existingEntryIndex], + [fieldName]: value, + }; + + console.log("✅ [handleFieldChange] Entry 업데이트:", { + beforeKeys: Object.keys(updatedEntries[existingEntryIndex]), + afterKeys: Object.keys(updatedEntry), + updatedEntry, + }); + + // 🆕 가격 관련 필드가 변경되면 자동 계산 + if (componentConfig.autoCalculation) { + const { inputFields, targetField } = componentConfig.autoCalculation; + const priceRelatedFields = [ + inputFields.basePrice, + inputFields.discountType, + inputFields.discountValue, + inputFields.roundingType, + inputFields.roundingUnit, + ]; + + if (priceRelatedFields.includes(fieldName)) { + const calculatedPrice = calculatePrice(updatedEntry); + updatedEntry[targetField] = calculatedPrice; + console.log("💰 [자동 계산]", { + basePrice: updatedEntry[inputFields.basePrice], + discountType: updatedEntry[inputFields.discountType], + discountValue: updatedEntry[inputFields.discountValue], + roundingType: updatedEntry[inputFields.roundingType], + roundingUnit: updatedEntry[inputFields.roundingUnit], + calculatedPrice, + targetField, + }); + } + } + + updatedEntries[existingEntryIndex] = updatedEntry; + return { + ...item, + fieldGroups: { + ...item.fieldGroups, + [groupId]: updatedEntries, + }, + }; + } else { + // 이 경로는 발생하면 안 됨 (handleAddGroupEntry에서 미리 추가함) + console.warn("⚠️ entry가 없는데 handleFieldChange 호출됨:", { itemId, groupId, entryId }); + return item; + } + }); + }); + }, + [calculatePrice], + ); // 🆕 품목 제거 핸들러 const handleRemoveItem = (itemId: string) => { @@ -727,45 +829,60 @@ export const SelectedItemsDetailInputComponent: React.FC { const newEntryId = `entry-${Date.now()}`; - + // 🔧 미리 빈 entry를 추가하여 리렌더링 방지 (autoFillFrom 처리) setItems((prevItems) => { return prevItems.map((item) => { if (item.id !== itemId) return item; - + const groupEntries = item.fieldGroups[groupId] || []; const newEntry: GroupEntry = { id: newEntryId }; - + // 🆕 autoFillFrom 필드 자동 채우기 (tableName으로 직접 접근) - const groupFields = (componentConfig.additionalFields || []).filter( - (f) => f.groupId === groupId - ); - + const groupFields = (componentConfig.additionalFields || []).filter((f) => f.groupId === groupId); + groupFields.forEach((field) => { if (!field.autoFillFrom) return; - + // 데이터 소스 결정 let sourceData: any = null; - + if (field.autoFillFromTable) { // 특정 테이블에서 가져오기 const tableData = dataRegistry[field.autoFillFromTable]; if (tableData && tableData.length > 0) { // 첫 번째 항목 사용 (또는 매칭 로직 추가 가능) sourceData = tableData[0].originalData || tableData[0]; - console.log(`✅ [autoFill] ${field.name} ← ${field.autoFillFrom} (테이블: ${field.autoFillFromTable}):`, sourceData[field.autoFillFrom]); + console.log( + `✅ [autoFill 추가] ${field.name} ← ${field.autoFillFrom} (테이블: ${field.autoFillFromTable}):`, + sourceData?.[field.autoFillFrom], + ); + } else { + // 🆕 dataRegistry에 없으면 item.originalData에서 찾기 (수정 모드) + sourceData = item.originalData; + console.log(`⚠️ [autoFill 추가] dataRegistry에 ${field.autoFillFromTable} 없음, originalData에서 찾기`); } } else { // 주 데이터 소스 (item.originalData) 사용 sourceData = item.originalData; - console.log(`✅ [autoFill] ${field.name} ← ${field.autoFillFrom} (주 소스):`, sourceData[field.autoFillFrom]); + console.log( + `✅ [autoFill 추가] ${field.name} ← ${field.autoFillFrom} (주 소스):`, + sourceData?.[field.autoFillFrom], + ); } - - if (sourceData && sourceData[field.autoFillFrom] !== undefined) { - newEntry[field.name] = sourceData[field.autoFillFrom]; + + // 🆕 getFieldValue 사용하여 Entity Join 필드도 찾기 + if (sourceData) { + const fieldValue = getFieldValue(sourceData, field.autoFillFrom); + if (fieldValue !== undefined && fieldValue !== null) { + newEntry[field.name] = fieldValue; + console.log(`✅ [autoFill 추가] ${field.name} 값 설정:`, fieldValue); + } else { + console.warn(`⚠️ [autoFill 추가] ${field.name} ← ${field.autoFillFrom} 실패`); + } } }); - + return { ...item, fieldGroups: { @@ -775,7 +892,7 @@ export const SelectedItemsDetailInputComponent: React.FC e.id !== entryId), }, }; - }) + }), ); }; @@ -829,7 +946,13 @@ export const SelectedItemsDetailInputComponent: React.FC { + const renderField = ( + field: AdditionalFieldDefinition, + itemId: string, + groupId: string, + entryId: string, + entry: GroupEntry, + ) => { const value = entry[field.name] || field.defaultValue || ""; // 🆕 계산된 필드는 읽기 전용 (자동 계산 설정 기반) @@ -871,7 +994,7 @@ export const SelectedItemsDetailInputComponent: React.FC -
- 자동 계산 -
+
자동 계산
); } - + return ( ); @@ -947,16 +1068,16 @@ export const SelectedItemsDetailInputComponent: React.FC )) ) : ( -
- 옵션 로딩 중... -
+
옵션 로딩 중...
)} @@ -1006,11 +1125,13 @@ export const SelectedItemsDetailInputComponent: React.FC - {field.options?.filter((option) => option.value !== "").map((option) => ( - - {option.label} - - ))} + {field.options + ?.filter((option) => option.value !== "") + .map((option) => ( + + {option.label} + + ))} ); @@ -1029,215 +1150,242 @@ export const SelectedItemsDetailInputComponent: React.FC { - // 🆕 해당 그룹의 displayItems 가져오기 - const group = (componentConfig.fieldGroups || []).find(g => g.id === groupId); - const displayItems = group?.displayItems || []; - - if (displayItems.length === 0) { - // displayItems가 없으면 기본 방식 (해당 그룹의 필드만 나열) - const fields = (componentConfig.additionalFields || []).filter(f => - componentConfig.fieldGroups && componentConfig.fieldGroups.length > 0 - ? f.groupId === groupId - : true - ); - return fields.map((f) => { - const value = entry[f.name]; - if (!value) return "-"; - - const strValue = String(value); - - // 🔧 ISO 날짜 형식 자동 감지 및 포맷팅 (필드 타입 무관) - // ISO 8601 형식: YYYY-MM-DDTHH:mm:ss.sssZ 또는 YYYY-MM-DD - const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/); - if (isoDateMatch) { - const [, year, month, day] = isoDateMatch; - return `${year}.${month}.${day}`; - } - - return strValue; - }).join(" / "); + // 🆕 Entity Join된 필드명도 찾는 헬퍼 함수 + const getFieldValue = useCallback((data: Record, fieldName: string) => { + // 1. Entity Join 형식으로 먼저 찾기 (*_fieldName) - 우선순위! + // 예: item_id_item_name (품목의 품명) vs customer_item_name (거래처 품명) + const possibleKeys = Object.keys(data).filter( + (key) => key.endsWith(`_${fieldName}`) && key !== fieldName, // 자기 자신은 제외 + ); + + if (possibleKeys.length > 0) { + // 🆕 여러 개 있으면 가장 긴 키 선택 (더 구체적인 것) + // 예: item_id_item_name (18자) vs customer_item_name (18자) 중 정렬 순서로 선택 + // 실제로는 item_id로 시작하는 것을 우선 + const entityJoinKey = possibleKeys.find((key) => key.includes("_id_")) || possibleKeys[0]; + console.log(`🔍 [getFieldValue] "${fieldName}" → "${entityJoinKey}" =`, data[entityJoinKey]); + return data[entityJoinKey]; } - // displayItems 설정대로 렌더링 - return ( - <> - {displayItems.map((displayItem) => { - const styleClasses = cn( - displayItem.bold && "font-bold", - displayItem.underline && "underline", - displayItem.italic && "italic" - ); - - const inlineStyle: React.CSSProperties = { - color: displayItem.color, - backgroundColor: displayItem.backgroundColor, - }; + // 2. 직접 필드명으로 찾기 (Entity Join이 없을 때만) + if (data[fieldName] !== undefined) { + console.log(`🔍 [getFieldValue] "${fieldName}" → 직접 =`, data[fieldName]); + return data[fieldName]; + } - switch (displayItem.type) { - case "icon": { - if (!displayItem.icon) return null; - const IconComponent = (LucideIcons as any)[displayItem.icon]; - if (!IconComponent) return null; - return ( - - ); + console.warn(`⚠️ [getFieldValue] "${fieldName}" 못 찾음`); + return null; + }, []); + + // 🆕 displayItems를 렌더링하는 헬퍼 함수 (그룹별) + const renderDisplayItems = useCallback( + (entry: GroupEntry, item: ItemData, groupId: string) => { + // 🆕 해당 그룹의 displayItems 가져오기 + const group = (componentConfig.fieldGroups || []).find((g) => g.id === groupId); + const displayItems = group?.displayItems || []; + + if (displayItems.length === 0) { + // displayItems가 없으면 기본 방식 (해당 그룹의 필드만 나열) + const fields = (componentConfig.additionalFields || []).filter((f) => + componentConfig.fieldGroups && componentConfig.fieldGroups.length > 0 ? f.groupId === groupId : true, + ); + return fields + .map((f) => { + const value = entry[f.name]; + if (!value) return "-"; + + const strValue = String(value); + + // 🔧 ISO 날짜 형식 자동 감지 및 포맷팅 (필드 타입 무관) + // ISO 8601 형식: YYYY-MM-DDTHH:mm:ss.sssZ 또는 YYYY-MM-DD + const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/); + if (isoDateMatch) { + const [, year, month, day] = isoDateMatch; + return `${year}.${month}.${day}`; } - case "text": - return ( - - {displayItem.value} - - ); + return strValue; + }) + .join(" / "); + } - case "field": { - const fieldValue = entry[displayItem.fieldName || ""]; - const isEmpty = fieldValue === null || fieldValue === undefined || fieldValue === ""; - - // 🆕 빈 값 처리 - if (isEmpty) { - switch (displayItem.emptyBehavior) { - case "hide": - return null; // 항목 숨김 - case "default": - // 기본값 표시 - const defaultValue = displayItem.defaultValue || "-"; - return ( - - {displayItem.label}{defaultValue} - - ); - case "blank": - default: - // 빈 칸으로 표시 - return ( - - {displayItem.label} - - ); + // displayItems 설정대로 렌더링 + return ( + <> + {displayItems.map((displayItem) => { + const styleClasses = cn( + displayItem.bold && "font-bold", + displayItem.underline && "underline", + displayItem.italic && "italic", + ); + + const inlineStyle: React.CSSProperties = { + color: displayItem.color, + backgroundColor: displayItem.backgroundColor, + }; + + switch (displayItem.type) { + case "icon": { + if (!displayItem.icon) return null; + const IconComponent = (LucideIcons as any)[displayItem.icon]; + if (!IconComponent) return null; + return ; + } + + case "text": + return ( + + {displayItem.value} + + ); + + case "field": { + const fieldValue = entry[displayItem.fieldName || ""]; + const isEmpty = fieldValue === null || fieldValue === undefined || fieldValue === ""; + + // 🆕 빈 값 처리 + if (isEmpty) { + switch (displayItem.emptyBehavior) { + case "hide": + return null; // 항목 숨김 + case "default": + // 기본값 표시 + const defaultValue = displayItem.defaultValue || "-"; + return ( + + {displayItem.label} + {defaultValue} + + ); + case "blank": + default: + // 빈 칸으로 표시 + return ( + + {displayItem.label} + + ); + } } - } - // 값이 있는 경우, 형식에 맞게 표시 - let formattedValue = fieldValue; - - // 🔧 자동 날짜 감지 (format 설정 없어도 ISO 날짜 자동 변환) - const strValue = String(fieldValue); - const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/); - if (isoDateMatch && !displayItem.format) { - const [, year, month, day] = isoDateMatch; - formattedValue = `${year}.${month}.${day}`; - } - - switch (displayItem.format) { - case "currency": - // 천 단위 구분 - formattedValue = new Intl.NumberFormat("ko-KR").format(Number(fieldValue) || 0); - break; - case "number": - formattedValue = new Intl.NumberFormat("ko-KR").format(Number(fieldValue) || 0); - break; - case "date": - // YYYY.MM.DD 형식 - if (fieldValue) { - // 날짜 문자열을 직접 파싱 (타임존 문제 방지) - const dateStr = String(fieldValue); - const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/); - if (match) { - const [, year, month, day] = match; - formattedValue = `${year}.${month}.${day}`; - } else { - // Date 객체로 변환 시도 (fallback) - const date = new Date(fieldValue); - if (!isNaN(date.getTime())) { - formattedValue = date.toLocaleDateString("ko-KR", { - year: "numeric", - month: "2-digit", - day: "2-digit", - }).replace(/\. /g, ".").replace(/\.$/, ""); + // 값이 있는 경우, 형식에 맞게 표시 + let formattedValue = fieldValue; + + // 🔧 자동 날짜 감지 (format 설정 없어도 ISO 날짜 자동 변환) + const strValue = String(fieldValue); + const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/); + if (isoDateMatch && !displayItem.format) { + const [, year, month, day] = isoDateMatch; + formattedValue = `${year}.${month}.${day}`; + } + + switch (displayItem.format) { + case "currency": + // 천 단위 구분 + formattedValue = new Intl.NumberFormat("ko-KR").format(Number(fieldValue) || 0); + break; + case "number": + formattedValue = new Intl.NumberFormat("ko-KR").format(Number(fieldValue) || 0); + break; + case "date": + // YYYY.MM.DD 형식 + if (fieldValue) { + // 날짜 문자열을 직접 파싱 (타임존 문제 방지) + const dateStr = String(fieldValue); + const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (match) { + const [, year, month, day] = match; + formattedValue = `${year}.${month}.${day}`; + } else { + // Date 객체로 변환 시도 (fallback) + const date = new Date(fieldValue); + if (!isNaN(date.getTime())) { + formattedValue = date + .toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + .replace(/\. /g, ".") + .replace(/\.$/, ""); + } } } - } - break; - case "badge": - // 배지로 표시 - return ( - - {displayItem.label}{formattedValue} - - ); - case "text": - default: - // 일반 텍스트 - break; - } - - // 🔧 마지막 안전장치: formattedValue가 여전히 ISO 형식이면 한번 더 변환 - let finalValue = formattedValue; - if (typeof formattedValue === 'string') { - const isoCheck = formattedValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/); - if (isoCheck) { - const [, year, month, day] = isoCheck; - finalValue = `${year}.${month}.${day}`; + break; + case "badge": + // 배지로 표시 + return ( + + {displayItem.label} + {formattedValue} + + ); + case "text": + default: + // 일반 텍스트 + break; } + + // 🔧 마지막 안전장치: formattedValue가 여전히 ISO 형식이면 한번 더 변환 + let finalValue = formattedValue; + if (typeof formattedValue === "string") { + const isoCheck = formattedValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/); + if (isoCheck) { + const [, year, month, day] = isoCheck; + finalValue = `${year}.${month}.${day}`; + } + } + + return ( + + {displayItem.label} + {finalValue} + + ); } - - return ( - - {displayItem.label}{finalValue} - - ); - } - case "badge": { - const fieldValue = displayItem.fieldName ? entry[displayItem.fieldName] : displayItem.value; - return ( - - {displayItem.label}{fieldValue} - - ); - } + case "badge": { + const fieldValue = displayItem.fieldName ? entry[displayItem.fieldName] : displayItem.value; + return ( + + {displayItem.label} + {fieldValue} + + ); + } - default: - return null; - } - })} - - ); - }, [componentConfig.fieldGroups, componentConfig.additionalFields]); + default: + return null; + } + })} + + ); + }, + [componentConfig.fieldGroups, componentConfig.additionalFields], + ); // 빈 상태 렌더링 if (items.length === 0) { return (
-
-

{componentConfig.emptyMessage}

+
+

{componentConfig.emptyMessage}

{isDesignMode && ( -

+

💡 이전 모달에서 "다음" 버튼으로 데이터를 전달하면 여기에 표시됩니다.

)} @@ -1252,19 +1400,15 @@ export const SelectedItemsDetailInputComponent: React.FC 0 - ? groups - : [{ id: "default", title: "입력 정보", order: 0 }]; + const effectiveGroups = groups.length > 0 ? groups : [{ id: "default", title: "입력 정보", order: 0 }]; const sortedGroups = [...effectiveGroups].sort((a, b) => (a.order || 0) - (b.order || 0)); return (
{sortedGroups.map((group) => { - const groupFields = fields.filter((f) => - groups.length === 0 ? true : f.groupId === group.id - ); - + const groupFields = fields.filter((f) => (groups.length === 0 ? true : f.groupId === group.id)); + if (groupFields.length === 0) return null; const groupEntries = item.fieldGroups[group.id] || []; @@ -1273,7 +1417,7 @@ export const SelectedItemsDetailInputComponent: React.FC - + {group.title}
{/* 🆕 가로 Grid 배치 (2~3열) */} -
+
{groupFields.map((field) => (
{renderField(field, item.id, group.id, entry.id, entry)}
@@ -1338,7 +1480,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleEditGroupEntry(item.id, group.id, entry.id)} > @@ -1362,16 +1504,14 @@ export const SelectedItemsDetailInputComponent: React.FC ) : ( -

- 아직 입력된 항목이 없습니다. -

+

아직 입력된 항목이 없습니다.

)} {/* 새 항목 입력 중 */} - {isEditingThisGroup && editingDetailId && !groupEntries.find(e => e.id === editingDetailId) && ( - - -
+ {isEditingThisGroup && editingDetailId && !groupEntries.find((e) => e.id === editingDetailId) && ( + + +
새 항목
@@ -1417,16 +1557,16 @@ export const SelectedItemsDetailInputComponent: React.FC +
{items.map((item, index) => { // 제목용 첫 번째 컬럼 값 - const titleValue = componentConfig.displayColumns?.[0]?.name - ? item.originalData[componentConfig.displayColumns[0].name] + const titleValue = componentConfig.displayColumns?.[0]?.name + ? getFieldValue(item.originalData, componentConfig.displayColumns[0].name) : null; - + // 요약용 모든 컬럼 값들 const summaryValues = componentConfig.displayColumns - ?.map((col) => item.originalData[col.name]) + ?.map((col) => getFieldValue(item.originalData, col.name)) .filter(Boolean); console.log("🔍 [renderGridLayout] 항목 렌더링:", { @@ -1442,8 +1582,10 @@ export const SelectedItemsDetailInputComponent: React.FC - - {index + 1}. {titleValue || "항목"} + + + {index + 1}. {titleValue || "항목"} +
)} {/* Modal 모드: 편집 중인 항목 (입력창 표시) */} - {isModalMode && isEditing && editingItemId && (() => { - const editingItem = items.find(item => item.id === editingItemId); - console.log("🔍 [Modal Mode] 편집 항목 찾기:", { - editingItemId, - itemsLength: items.length, - itemIds: items.map(i => i.id), - editingItem: editingItem ? "찾음" : "못 찾음", - editingDetailId, - }); - if (!editingItem) { - console.warn("⚠️ [Modal Mode] 편집할 항목을 찾을 수 없습니다!"); - return null; - } + {isModalMode && + isEditing && + editingItemId && + (() => { + const editingItem = items.find((item) => item.id === editingItemId); + console.log("🔍 [Modal Mode] 편집 항목 찾기:", { + editingItemId, + itemsLength: items.length, + itemIds: items.map((i) => i.id), + editingItem: editingItem ? "찾음" : "못 찾음", + editingDetailId, + }); + if (!editingItem) { + console.warn("⚠️ [Modal Mode] 편집할 항목을 찾을 수 없습니다!"); + return null; + } - return ( - - - - 품목: {editingItem.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"} - - - - - {/* 원본 데이터 요약 */} -
- {componentConfig.displayColumns?.map((col) => editingItem.originalData[col.name]).filter(Boolean).join(" | ")} -
- - {/* 🆕 이미 입력된 상세 항목들 표시 */} - {editingItem.details.length > 0 && ( -
-
입력된 항목 ({editingItem.details.length}개)
- {editingItem.details.map((detail, idx) => ( -
- {idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"} - -
- ))} + return ( + + + + + 품목:{" "} + {getFieldValue(editingItem.originalData, componentConfig.displayColumns?.[0]?.name || "") || + "항목"} + + + + + + {/* 원본 데이터 요약 */} +
+ {componentConfig.displayColumns + ?.map((col) => editingItem.originalData[col.name]) + .filter(Boolean) + .join(" | ")}
- )} - {/* 추가 입력 필드 */} - {componentConfig.additionalFields && componentConfig.additionalFields.length > 0 && editingDetailId && (() => { - // 현재 편집 중인 detail 찾기 (없으면 빈 객체) - const currentDetail = editingItem.details.find(d => d.id === editingDetailId) || { id: editingDetailId }; - return renderFieldsByGroup(editingItem.id, editingDetailId, currentDetail); - })()} + {/* 🆕 이미 입력된 상세 항목들 표시 */} + {editingItem.details.length > 0 && ( +
+
입력된 항목 ({editingItem.details.length}개)
+ {editingItem.details.map((detail, idx) => ( +
+ + {idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"} + + +
+ ))} +
+ )} - {/* 액션 버튼들 */} -
- - -
-
-
- ); - })()} + {/* 추가 입력 필드 */} + {componentConfig.additionalFields && + componentConfig.additionalFields.length > 0 && + editingDetailId && + (() => { + // 현재 편집 중인 detail 찾기 (없으면 빈 객체) + const currentDetail = editingItem.details.find((d) => d.id === editingDetailId) || { + id: editingDetailId, + }; + return renderFieldsByGroup(editingItem.id, editingDetailId, currentDetail); + })()} + + {/* 액션 버튼들 */} +
+ + +
+ + + ); + })()} {/* 저장된 항목들 (inline 모드 또는 modal 모드에서 편집 완료된 항목) */} {items.map((item, index) => { @@ -1596,15 +1745,19 @@ export const SelectedItemsDetailInputComponent: React.FC + -
+
-
- {index + 1}. {item.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"} +
+ {index + 1}.{" "} + {getFieldValue(item.originalData, componentConfig.displayColumns?.[0]?.name || "") || "항목"}
-
- {componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")} +
+ {componentConfig.displayColumns + ?.map((col) => getFieldValue(item.originalData, col.name)) + .filter(Boolean) + .join(" | ")}
@@ -1637,9 +1790,9 @@ export const SelectedItemsDetailInputComponent: React.FC {/* 🆕 입력된 상세 항목들 표시 */} {item.details && item.details.length > 0 && ( -
+
{item.details.map((detail, detailIdx) => ( -
+
{detailIdx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"}
))} @@ -1657,7 +1810,8 @@ export const SelectedItemsDetailInputComponent: React.FC
- {index + 1}. {item.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"} + {index + 1}.{" "} + {getFieldValue(item.originalData, componentConfig.displayColumns?.[0]?.name || "") || "항목"}
- + {/* 원본 데이터 요약 (작은 텍스트, | 구분자) */} -
- {componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")} +
+ {componentConfig.displayColumns + ?.map((col) => getFieldValue(item.originalData, col.name)) + .filter(Boolean) + .join(" | ")}
{/* 🆕 각 상세 항목 표시 */} {item.details && item.details.length > 0 ? ( -
+
{item.details.map((detail, detailIdx) => ( - +
상세 항목 {detailIdx + 1}
@@ -1735,29 +1890,29 @@ export const SelectedItemsDetailInputComponent: React.FC { return ( -
+
{componentConfig.showIndex && ( # )} - + {/* 원본 데이터 컬럼 */} {componentConfig.displayColumns?.map((col) => ( {col.label || col.name} ))} - + {/* 추가 입력 필드 컬럼 */} {componentConfig.additionalFields?.map((field) => ( {field.label} - {field.required && *} + {field.required && *} ))} - + {componentConfig.allowRemove && ( 작업 )} @@ -1765,28 +1920,28 @@ export const SelectedItemsDetailInputComponent: React.FC {items.map((item, index) => ( - + {/* 인덱스 번호 */} {componentConfig.showIndex && ( {index + 1} )} - + {/* 원본 데이터 표시 */} {componentConfig.displayColumns?.map((col) => ( - {item.originalData[col.name] || "-"} + {getFieldValue(item.originalData, col.name) || "-"} ))} - + {/* 추가 입력 필드 */} {componentConfig.additionalFields?.map((field) => ( {renderField(field, item)} ))} - + {/* 삭제 버튼 */} {componentConfig.allowRemove && ( @@ -1796,7 +1951,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleRemoveItem(item.id)} - className="h-7 w-7 text-destructive hover:bg-destructive/10 hover:text-destructive sm:h-8 sm:w-8" + className="text-destructive hover:bg-destructive/10 hover:text-destructive h-7 w-7 sm:h-8 sm:w-8" title="항목 제거" > @@ -1829,116 +1984,125 @@ export const SelectedItemsDetailInputComponent: React.FC +
{/* Modal 모드: 추가 버튼 */} {isModalMode && !isEditing && items.length === 0 && ( -
-

항목을 추가하려면 추가 버튼을 클릭하세요

-
)} {/* Modal 모드: 편집 중인 항목 (입력창 표시) */} - {isModalMode && isEditing && editingItemId && (() => { - const editingItem = items.find(item => item.id === editingItemId); - console.log("🔍 [Modal Mode - Card] 편집 항목 찾기:", { - editingItemId, - itemsLength: items.length, - itemIds: items.map(i => i.id), - editingItem: editingItem ? "찾음" : "못 찾음", - editingDetailId, - }); - if (!editingItem) { - console.warn("⚠️ [Modal Mode - Card] 편집할 항목을 찾을 수 없습니다!"); - return null; - } + {isModalMode && + isEditing && + editingItemId && + (() => { + const editingItem = items.find((item) => item.id === editingItemId); + console.log("🔍 [Modal Mode - Card] 편집 항목 찾기:", { + editingItemId, + itemsLength: items.length, + itemIds: items.map((i) => i.id), + editingItem: editingItem ? "찾음" : "못 찾음", + editingDetailId, + }); + if (!editingItem) { + console.warn("⚠️ [Modal Mode - Card] 편집할 항목을 찾을 수 없습니다!"); + return null; + } - return ( - - - - 품목: {editingItem.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"} - - - - - {/* 원본 데이터 요약 */} -
- {componentConfig.displayColumns?.map((col) => editingItem.originalData[col.name]).filter(Boolean).join(" | ")} -
- - {/* 🆕 이미 입력된 상세 항목들 표시 */} - {editingItem.details.length > 0 && ( -
-
입력된 항목 ({editingItem.details.length}개)
- {editingItem.details.map((detail, idx) => ( -
- {idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"} - -
- ))} + return ( + + + + + 품목:{" "} + {getFieldValue(editingItem.originalData, componentConfig.displayColumns?.[0]?.name || "") || + "항목"} + + + + + + {/* 원본 데이터 요약 */} +
+ {componentConfig.displayColumns + ?.map((col) => editingItem.originalData[col.name]) + .filter(Boolean) + .join(" | ")}
- )} - {/* 추가 입력 필드 */} - {componentConfig.additionalFields && componentConfig.additionalFields.length > 0 && editingDetailId && (() => { - // 현재 편집 중인 detail 찾기 (없으면 빈 객체) - const currentDetail = editingItem.details.find(d => d.id === editingDetailId) || { id: editingDetailId }; - return renderFieldsByGroup(editingItem.id, editingDetailId, currentDetail); - })()} + {/* 🆕 이미 입력된 상세 항목들 표시 */} + {editingItem.details.length > 0 && ( +
+
입력된 항목 ({editingItem.details.length}개)
+ {editingItem.details.map((detail, idx) => ( +
+ + {idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"} + + +
+ ))} +
+ )} - {/* 액션 버튼들 */} -
- - -
-
-
- ); - })()} + {/* 추가 입력 필드 */} + {componentConfig.additionalFields && + componentConfig.additionalFields.length > 0 && + editingDetailId && + (() => { + // 현재 편집 중인 detail 찾기 (없으면 빈 객체) + const currentDetail = editingItem.details.find((d) => d.id === editingDetailId) || { + id: editingDetailId, + }; + return renderFieldsByGroup(editingItem.id, editingDetailId, currentDetail); + })()} + + {/* 액션 버튼들 */} +
+ + +
+ + + ); + })()} {/* 저장된 항목들 (inline 모드 또는 modal 모드에서 편집 완료된 항목) */} {items.map((item, index) => { @@ -1950,16 +2114,20 @@ export const SelectedItemsDetailInputComponent: React.FC - + +
-
- {index + 1}. {item.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"} +
+ {index + 1}.{" "} + {getFieldValue(item.originalData, componentConfig.displayColumns?.[0]?.name || "") || "항목"} +
+
+ {componentConfig.displayColumns + ?.map((col) => getFieldValue(item.originalData, col.name)) + .filter(Boolean) + .join(" | ")}
-
- {componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")}
-
@@ -1997,7 +2165,8 @@ export const SelectedItemsDetailInputComponent: React.FC
- {index + 1}. {item.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"} + {index + 1}.{" "} + {getFieldValue(item.originalData, componentConfig.displayColumns?.[0]?.name || "") || "항목"}
- + {/* 원본 데이터 요약 (작은 텍스트, | 구분자) */} -
- {componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")} +
+ {componentConfig.displayColumns + ?.map((col) => getFieldValue(item.originalData, col.name)) + .filter(Boolean) + .join(" | ")}
{/* 🆕 각 상세 항목 표시 */} {item.details && item.details.length > 0 ? ( -
+
{item.details.map((detail, detailIdx) => ( - +
상세 항목 {detailIdx + 1}
@@ -2082,9 +2252,9 @@ export const SelectedItemsDetailInputComponent: React.FC {/* 레이아웃에 따라 렌더링 */} {componentConfig.layout === "grid" ? renderGridLayout() : renderCardLayout()} - + {/* 항목 수 표시 */} -
+
총 {items.length}개 항목 {componentConfig.targetTable && 저장 대상: {componentConfig.targetTable}}
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 66375252..d6ddd96c 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -225,6 +225,7 @@ export class ButtonActionExecutor { // 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조) console.log("🔍 [handleSave] formData 구조 확인:", { + isFormDataArray: Array.isArray(context.formData), keys: Object.keys(context.formData), values: Object.entries(context.formData).map(([key, value]) => ({ key, @@ -238,6 +239,14 @@ export class ButtonActionExecutor { })) }); + // 🔧 formData 자체가 배열인 경우 (ScreenModal의 그룹 레코드 수정) + if (Array.isArray(context.formData)) { + console.log("⚠️ [handleSave] formData가 배열입니다 - SelectedItemsDetailInput이 이미 처리했으므로 일반 저장 건너뜀"); + console.log("⚠️ [handleSave] formData 배열:", context.formData); + // ✅ SelectedItemsDetailInput이 이미 UPSERT를 실행했으므로 일반 저장을 건너뜀 + return true; // 성공으로 반환 + } + const selectedItemsKeys = Object.keys(context.formData).filter(key => { const value = context.formData[key]; console.log(`🔍 [handleSave] 필터링 체크 - ${key}:`, { From 45ac39741776b151b50cda8d368234f00fddb830 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 15:30:00 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EC=88=98=EC=A3=BC=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/common/ScreenModal.tsx | 5 +- .../screen/InteractiveScreenViewerDynamic.tsx | 101 ++++++++++-------- frontend/components/screen/ScreenList.tsx | 43 +++++++- .../ConditionalSectionViewer.tsx | 5 + 4 files changed, 107 insertions(+), 47 deletions(-) diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 8cf53d5c..f44e2227 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -31,7 +31,7 @@ interface ScreenModalProps { } export const ScreenModal: React.FC = ({ className }) => { - const { userId } = useAuth(); + const { userId, userName, user } = useAuth(); const [modalState, setModalState] = useState({ isOpen: false, @@ -587,6 +587,9 @@ export const ScreenModal: React.FC = ({ className }) => { id: modalState.screenId!, tableName: screenData.screenInfo?.tableName, }} + userId={userId} + userName={userName} + companyCode={user?.companyCode} /> ); })} diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index ba27c94e..df134685 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -42,6 +42,10 @@ interface InteractiveScreenViewerProps { onSave?: () => Promise; onRefresh?: () => void; onFlowRefresh?: () => void; + // 🆕 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용) + userId?: string; + userName?: string; + companyCode?: string; } export const InteractiveScreenViewerDynamic: React.FC = ({ @@ -54,9 +58,24 @@ export const InteractiveScreenViewerDynamic: React.FC { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 - const { userName, user } = useAuth(); + const { userName: authUserName, user: authUser } = useAuth(); + + // 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (ScreenModal 등에서) + const userName = externalUserName || authUserName; + const user = + externalUserId && externalUserId !== authUser?.userId + ? { + userId: externalUserId, + userName: externalUserName || authUserName || "", + companyCode: externalCompanyCode || authUser?.companyCode || "", + isAdmin: authUser?.isAdmin || false, + } + : authUser; const [localFormData, setLocalFormData] = useState>({}); const [dateValues, setDateValues] = useState>({}); @@ -130,59 +149,55 @@ export const InteractiveScreenViewerDynamic: React.FC { if (e.key === "Enter" && !e.shiftKey) { const target = e.target as HTMLElement; - + // 한글 조합 중이면 무시 (한글 입력 문제 방지) if ((e as any).isComposing || e.keyCode === 229) { return; } - + // textarea는 제외 (여러 줄 입력) if (target.tagName === "TEXTAREA") { return; } - + // input, select 등의 폼 요소에서만 작동 - if ( - target.tagName === "INPUT" || - target.tagName === "SELECT" || - target.getAttribute("role") === "combobox" - ) { + if (target.tagName === "INPUT" || target.tagName === "SELECT" || target.getAttribute("role") === "combobox") { e.preventDefault(); - + // 모든 포커스 가능한 요소 찾기 const focusableElements = document.querySelectorAll( - 'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [role="combobox"]:not([disabled])' + 'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [role="combobox"]:not([disabled])', ); - + // 화면에 보이는 순서(Y 좌표 → X 좌표)대로 정렬 const focusableArray = Array.from(focusableElements).sort((a, b) => { const rectA = a.getBoundingClientRect(); const rectB = b.getBoundingClientRect(); - + // Y 좌표 차이가 10px 이상이면 Y 좌표로 정렬 (위에서 아래로) if (Math.abs(rectA.top - rectB.top) > 10) { return rectA.top - rectB.top; } - + // 같은 줄이면 X 좌표로 정렬 (왼쪽에서 오른쪽으로) return rectA.left - rectB.left; }); - + const currentIndex = focusableArray.indexOf(target); - + if (currentIndex !== -1 && currentIndex < focusableArray.length - 1) { // 다음 요소로 포커스 이동 const nextElement = focusableArray[currentIndex + 1]; nextElement.focus(); - + // select() 제거: 한글 입력 시 이전 필드의 마지막 글자가 복사되는 버그 방지 } } } }; - + document.addEventListener("keydown", handleEnterKey); - + return () => { document.removeEventListener("keydown", handleEnterKey); }; @@ -193,31 +208,26 @@ export const InteractiveScreenViewerDynamic: React.FC { for (const comp of allComponents) { // type: "component" 또는 type: "widget" 모두 처리 - if (comp.type === 'widget' || comp.type === 'component') { + if (comp.type === "widget" || comp.type === "component") { const widget = comp as any; const fieldName = widget.columnName || widget.id; - + // autoFill 처리 (테이블 조회 기반 자동 입력) if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) { const autoFillConfig = widget.autoFill || (comp as any).autoFill; const currentValue = formData[fieldName]; - - if (currentValue === undefined || currentValue === '') { + + if (currentValue === undefined || currentValue === "") { const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig; - + // 사용자 정보에서 필터 값 가져오기 const userValue = user?.[userField]; - + if (userValue && sourceTable && filterColumn && displayColumn) { try { const { tableTypeApi } = await import("@/lib/api/screen"); - const result = await tableTypeApi.getTableRecord( - sourceTable, - filterColumn, - userValue, - displayColumn - ); - + const result = await tableTypeApi.getTableRecord(sourceTable, filterColumn, userValue, displayColumn); + updateFormData(fieldName, result.value); } catch (error) { console.error(`autoFill 조회 실패: ${fieldName}`, error); @@ -329,10 +339,13 @@ export const InteractiveScreenViewerDynamic: React.FC { - // 부모로부터 전달받은 onRefresh 또는 기본 동작 - console.log("🔄 InteractiveScreenViewerDynamic onRefresh 호출"); - })} + onRefresh={ + onRefresh || + (() => { + // 부모로부터 전달받은 onRefresh 또는 기본 동작 + console.log("🔄 InteractiveScreenViewerDynamic onRefresh 호출"); + }) + } onFlowRefresh={onFlowRefresh} onClose={() => { // buttonActions.ts가 이미 처리함 @@ -357,7 +370,7 @@ export const InteractiveScreenViewerDynamic: React.FC {label || "버튼"} @@ -689,18 +702,18 @@ export const InteractiveScreenViewerDynamic: React.FC([]); + const [loadingTables, setLoadingTables] = useState(false); // 미리보기 관련 상태 const [previewDialogOpen, setPreviewDialogOpen] = useState(false); @@ -260,14 +263,31 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr onScreenSelect(screen); }; - const handleEdit = (screen: ScreenDefinition) => { + const handleEdit = async (screen: ScreenDefinition) => { setScreenToEdit(screen); setEditFormData({ screenName: screen.screenName, description: screen.description || "", isActive: screen.isActive, + tableName: screen.tableName || "", }); setEditDialogOpen(true); + + // 테이블 목록 로드 + try { + setLoadingTables(true); + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + // tableName만 추출 (camelCase) + const tableNames = response.data.map((table: any) => table.tableName); + setTables(tableNames); + } + } catch (error) { + console.error("테이블 목록 조회 실패:", error); + } finally { + setLoadingTables(false); + } }; const handleEditSave = async () => { @@ -1180,6 +1200,25 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr placeholder="화면명을 입력하세요" />
+
+ + +