From f4e4ee13e2edaa695bdeac64e3f28af930993c37 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 19 Nov 2025 13:22:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B6=80=EB=AA=A8=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=A7=A4=ED=95=91=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(=EC=84=A0=ED=83=9D=ED=95=AD=EB=AA=A9=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=9E=85=EB=A0=A5=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 여러 테이블(거래처, 품목 등)에서 데이터를 가져와 자동 매핑 가능 - 각 매핑마다 소스 테이블, 원본 필드, 저장 필드를 독립적으로 설정 - 검색 가능한 Combobox로 테이블 및 컬럼 선택 UX 개선 - 소스 테이블 선택 시 해당 테이블의 컬럼 자동 로드 - 라벨, 컬럼명, 데이터 타입으로 검색 가능 - 세로 레이아웃으로 가독성 향상 기술적 변경사항: - ParentDataMapping 인터페이스 추가 (sourceTable, sourceField, targetField) - buttonActions.ts의 handleBatchSave에서 소스 테이블 기반 데이터 소스 자동 판단 - tableManagementApi.getColumnList() 사용하여 테이블 컬럼 동적 로드 - Command + Popover 조합으로 검색 가능한 Select 구현 - 각 매핑별 독립적인 컬럼 상태 관리 (mappingSourceColumns) --- .../controllers/screenManagementController.ts | 3 +- .../src/services/screenManagementService.ts | 13 +- frontend/components/screen/ScreenList.tsx | 160 +++++++-- .../SelectedItemsDetailInputComponent.tsx | 23 +- .../SelectedItemsDetailInputConfigPanel.tsx | 320 +++++++++++++++++- .../selected-items-detail-input/types.ts | 21 ++ frontend/lib/utils/buttonActions.ts | 205 +++++++++-- 7 files changed, 689 insertions(+), 56 deletions(-) diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index dd589fdd..be3a16a3 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -23,7 +23,8 @@ export const getScreens = async (req: AuthenticatedRequest, res: Response) => { const result = await screenManagementService.getScreensByCompany( targetCompanyCode, parseInt(page as string), - parseInt(size as string) + parseInt(size as string), + searchTerm as string // 검색어 전달 ); res.json({ diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index daf0ea26..6c3a3430 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -98,7 +98,8 @@ export class ScreenManagementService { async getScreensByCompany( companyCode: string, page: number = 1, - size: number = 20 + size: number = 20, + searchTerm?: string // 검색어 추가 ): Promise> { const offset = (page - 1) * size; @@ -111,6 +112,16 @@ export class ScreenManagementService { params.push(companyCode); } + // 검색어 필터링 추가 (화면명, 화면 코드, 테이블명 검색) + if (searchTerm && searchTerm.trim() !== "") { + whereConditions.push(`( + screen_name ILIKE $${params.length + 1} OR + screen_code ILIKE $${params.length + 1} OR + table_name ILIKE $${params.length + 1} + )`); + params.push(`%${searchTerm.trim()}%`); + } + const whereSQL = whereConditions.join(" AND "); // 페이징 쿼리 (Raw Query) diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index 63ec2210..116fa0df 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -1,12 +1,13 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Checkbox } from "@/components/ui/checkbox"; +import { useAuth } from "@/hooks/useAuth"; import { DropdownMenu, DropdownMenuContent, @@ -66,17 +67,31 @@ type DeletedScreenDefinition = ScreenDefinition & { }; export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: ScreenListProps) { + const { user } = useAuth(); + const isSuperAdmin = user?.userType === "SUPER_ADMIN" || user?.companyCode === "*"; + const [activeTab, setActiveTab] = useState("active"); const [screens, setScreens] = useState([]); const [deletedScreens, setDeletedScreens] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(true); // 초기 로딩 + const [isSearching, setIsSearching] = useState(false); // 검색 중 로딩 (포커스 유지) const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + const [selectedCompanyCode, setSelectedCompanyCode] = useState("all"); + const [companies, setCompanies] = useState([]); + const [loadingCompanies, setLoadingCompanies] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCopyOpen, setIsCopyOpen] = useState(false); const [screenToCopy, setScreenToCopy] = useState(null); + // 검색어 디바운스를 위한 타이머 ref + const debounceTimer = useRef(null); + + // 첫 로딩 여부를 추적 (한 번만 true) + const isFirstLoad = useRef(true); + // 삭제 관련 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [screenToDelete, setScreenToDelete] = useState(null); @@ -119,14 +134,75 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr const [isLoadingPreview, setIsLoadingPreview] = useState(false); const [previewFormData, setPreviewFormData] = useState>({}); - // 화면 목록 로드 (실제 API) + // 최고 관리자인 경우 회사 목록 로드 + useEffect(() => { + if (isSuperAdmin) { + loadCompanies(); + } + }, [isSuperAdmin]); + + const loadCompanies = async () => { + try { + setLoadingCompanies(true); + const { apiClient } = await import("@/lib/api/client"); // named export + const response = await apiClient.get("/admin/companies"); + const data = response.data.data || response.data || []; + setCompanies(data.map((c: any) => ({ + companyCode: c.company_code || c.companyCode, + companyName: c.company_name || c.companyName, + }))); + } catch (error) { + console.error("회사 목록 조회 실패:", error); + } finally { + setLoadingCompanies(false); + } + }; + + // 검색어 디바운스 처리 (150ms 지연 - 빠른 응답) + useEffect(() => { + // 이전 타이머 취소 + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + + // 새 타이머 설정 + debounceTimer.current = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 150); + + // 클린업 + return () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + }; + }, [searchTerm]); + + // 화면 목록 로드 (실제 API) - debouncedSearchTerm 사용 useEffect(() => { let abort = false; const load = async () => { try { - setLoading(true); + // 첫 로딩인 경우에만 loading=true, 그 외에는 isSearching=true + if (isFirstLoad.current) { + setLoading(true); + isFirstLoad.current = false; // 첫 로딩 완료 표시 + } else { + setIsSearching(true); + } + if (activeTab === "active") { - const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm }); + const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm }; + + // 최고 관리자이고 특정 회사를 선택한 경우 + if (isSuperAdmin && selectedCompanyCode !== "all") { + params.companyCode = selectedCompanyCode; + } + + console.log("🔍 화면 목록 API 호출:", params); // 디버깅용 + const resp = await screenApi.getScreens(params); + console.log("✅ 화면 목록 응답:", resp); // 디버깅용 + if (abort) return; setScreens(resp.data || []); setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20))); @@ -137,7 +213,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20))); } } catch (e) { - // console.error("화면 목록 조회 실패", e); + console.error("화면 목록 조회 실패", e); if (activeTab === "active") { setScreens([]); } else { @@ -145,28 +221,38 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr } setTotalPages(1); } finally { - if (!abort) setLoading(false); + if (!abort) { + setLoading(false); + setIsSearching(false); + } } }; load(); return () => { abort = true; }; - }, [currentPage, searchTerm, activeTab]); + }, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, isSuperAdmin]); const filteredScreens = screens; // 서버 필터 기준 사용 // 화면 목록 다시 로드 const reloadScreens = async () => { try { - setLoading(true); - const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm }); + setIsSearching(true); + const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm }; + + // 최고 관리자이고 특정 회사를 선택한 경우 + if (isSuperAdmin && selectedCompanyCode !== "all") { + params.companyCode = selectedCompanyCode; + } + + const resp = await screenApi.getScreens(params); setScreens(resp.data || []); setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20))); } catch (e) { - // console.error("화면 목록 조회 실패", e); + console.error("화면 목록 조회 실패", e); } finally { - setLoading(false); + setIsSearching(false); } }; @@ -405,18 +491,48 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
{/* 검색 및 필터 */}
-
-
- - setSearchTerm(e.target.value)} - className="h-10 pl-10 text-sm" - disabled={activeTab === "trash"} - /> +
+ {/* 최고 관리자 전용: 회사 필터 */} + {isSuperAdmin && ( +
+ +
+ )} + + {/* 검색 입력 */} +
+
+ + setSearchTerm(e.target.value)} + className="h-10 pl-10 text-sm" + disabled={activeTab === "trash"} + /> + {/* 검색 중 인디케이터 */} + {isSearching && ( +
+
+
+ )} +
+
+ {/* 🆕 부모 데이터 매핑 */} +
+
+ + +
+ +

+ 이전 화면(거래처 선택 등)에서 넘어온 데이터를 자동으로 매핑합니다. +

+ +
+ {(config.parentDataMapping || []).map((mapping, index) => ( + +
+ {/* 소스 테이블 선택 */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다. + + {allTables.map((table) => ( + { + const updated = [...(config.parentDataMapping || [])]; + updated[index] = { + ...updated[index], + sourceTable: currentValue, + sourceField: "", // 테이블 변경 시 필드 초기화 + }; + handleChange("parentDataMapping", updated); + + // 테이블 선택 시 컬럼 로드 + if (currentValue) { + loadMappingSourceColumns(currentValue, index); + } + }} + className="text-xs" + > + + {table.displayName || table.tableName} + + ))} + + + + + +

+ 품목, 거래처, 사용자 등 데이터를 가져올 테이블을 선택하세요 +

+
+ + {/* 원본 필드 */} +
+ + + + + + + + + + {!mapping.sourceTable ? ( + 소스 테이블을 먼저 선택하세요 + ) : !mappingSourceColumns[index] || mappingSourceColumns[index].length === 0 ? ( + 컬럼 로딩 중... + ) : ( + <> + 컬럼을 찾을 수 없습니다. + + {mappingSourceColumns[index].map((col) => { + const searchValue = `${col.columnLabel || col.columnName} ${col.columnName} ${col.dataType || ""}`.toLowerCase(); + return ( + { + const updated = [...(config.parentDataMapping || [])]; + updated[index] = { ...updated[index], sourceField: col.columnName }; + handleChange("parentDataMapping", updated); + }} + className="text-xs" + > + +
+ {col.columnLabel || col.columnName} + {col.dataType && ( + + {col.dataType} + + )} +
+
+ ); + })} +
+ + )} +
+
+
+
+
+ + {/* 저장 필드 (현재 화면 테이블 컬럼) */} +
+ + + + + + + + + + {targetTableColumns.length === 0 ? ( + 저장 테이블을 먼저 선택하세요 + ) : ( + <> + 컬럼을 찾을 수 없습니다. + + {targetTableColumns.map((col) => { + const searchValue = `${col.columnLabel || col.columnName} ${col.columnName} ${col.dataType || ""}`.toLowerCase(); + return ( + { + const updated = [...(config.parentDataMapping || [])]; + updated[index] = { ...updated[index], targetField: col.columnName }; + handleChange("parentDataMapping", updated); + }} + className="text-xs" + > + +
+ {col.columnLabel || col.columnName} + {col.dataType && ( + {col.dataType} + )} +
+
+ ); + })} +
+ + )} +
+
+
+
+
+ + {/* 기본값 (선택사항) */} +
+ + { + const updated = [...(config.parentDataMapping || [])]; + updated[index] = { ...updated[index], defaultValue: e.target.value }; + handleChange("parentDataMapping", updated); + }} + placeholder="값이 없을 때 사용할 기본값" + className="h-7 text-xs" + /> +
+ + {/* 삭제 버튼 */} + +
+
+ ))} +
+ + {(config.parentDataMapping || []).length === 0 && ( +

+ 매핑 설정이 없습니다. "추가" 버튼을 클릭하여 설정하세요. +

+ )} + + {/* 예시 */} +
+

💡 예시

+
+

매핑 1: 거래처 ID

+

• 소스 테이블: customer_mng

+

• 원본 필드: id → 저장 필드: customer_id

+ +

매핑 2: 품목 ID

+

• 소스 테이블: item_info

+

• 원본 필드: id → 저장 필드: item_id

+ +

매핑 3: 품목 기준단가

+

• 소스 테이블: item_info

+

• 원본 필드: standard_price → 저장 필드: base_price

+
+
+
+ {/* 사용 예시 */}

💡 사용 예시

diff --git a/frontend/lib/registry/components/selected-items-detail-input/types.ts b/frontend/lib/registry/components/selected-items-detail-input/types.ts index 4fd0ba10..88d02c8e 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/types.ts +++ b/frontend/lib/registry/components/selected-items-detail-input/types.ts @@ -121,6 +121,20 @@ export interface AutoCalculationConfig { calculationSteps?: CalculationStep[]; } +/** + * 🆕 부모 화면 데이터 매핑 설정 + */ +export interface ParentDataMapping { + /** 소스 테이블명 (필수) */ + sourceTable: string; + /** 소스 테이블의 필드명 */ + sourceField: string; + /** 저장할 테이블의 필드명 */ + targetField: string; + /** 부모 데이터가 없을 때 사용할 기본값 (선택사항) */ + defaultValue?: any; +} + /** * SelectedItemsDetailInput 컴포넌트 설정 타입 */ @@ -160,6 +174,13 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig { */ targetTable?: string; + /** + * 🆕 부모 화면 데이터 매핑 + * 이전 화면(예: 거래처 테이블)에서 넘어온 데이터를 저장 테이블의 필드에 자동 매핑 + * 예: { sourceField: "id", targetField: "customer_id" } + */ + parentDataMapping?: ParentDataMapping[]; + /** * 🆕 자동 계산 설정 * 특정 필드가 변경되면 다른 필드를 자동으로 계산 diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 84cc3626..ca390a8f 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -208,10 +208,17 @@ export class ButtonActionExecutor { console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId }); // 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집) - window.dispatchEvent(new CustomEvent("beforeFormSave")); + // context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함 + window.dispatchEvent(new CustomEvent("beforeFormSave", { + detail: { + formData: context.formData + } + })); // 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함 await new Promise(resolve => setTimeout(resolve, 100)); + + console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData); // 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조) console.log("🔍 [handleSave] formData 구조 확인:", { @@ -508,7 +515,14 @@ export class ButtonActionExecutor { context: ButtonActionContext, selectedItemsKeys: string[] ): Promise { - const { formData, tableName, screenId } = context; + const { formData, tableName, screenId, selectedRowsData, originalData } = context; + + console.log(`🔍 [handleBatchSave] context 확인:`, { + hasSelectedRowsData: !!selectedRowsData, + selectedRowsCount: selectedRowsData?.length || 0, + hasOriginalData: !!originalData, + originalDataKeys: originalData ? Object.keys(originalData) : [], + }); if (!tableName || !screenId) { toast.error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)"); @@ -520,6 +534,15 @@ export class ButtonActionExecutor { let failCount = 0; const errors: string[] = []; + // 🆕 부모 화면 데이터 준비 (parentDataMapping용) + // selectedRowsData 또는 originalData를 parentData로 사용 + const parentData = selectedRowsData?.[0] || originalData || {}; + + console.log(`🔍 [handleBatchSave] 부모 데이터:`, { + hasParentData: Object.keys(parentData).length > 0, + parentDataKeys: Object.keys(parentData), + }); + // 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리 for (const key of selectedItemsKeys) { // 🆕 새로운 데이터 구조: ItemData[] with fieldGroups @@ -531,22 +554,152 @@ export class ButtonActionExecutor { console.log(`📦 [handleBatchSave] ${key} 처리 중 (${items.length}개 품목)`); - // 각 품목의 모든 그룹의 모든 항목을 개별 저장 + // 🆕 이 컴포넌트의 parentDataMapping 설정 가져오기 + // TODO: 실제로는 componentConfig에서 가져와야 함 + // 현재는 selectedItemsKeys[0]을 사용하여 임시로 가져옴 + const componentConfig = (context as any).componentConfigs?.[key]; + const parentDataMapping = componentConfig?.parentDataMapping || []; + + console.log(`🔍 [handleBatchSave] parentDataMapping 설정:`, { + hasMapping: parentDataMapping.length > 0, + mappings: parentDataMapping + }); + + // 🆕 각 품목의 그룹 간 조합(카티션 곱) 생성 for (const item of items) { - const allGroupEntries = Object.values(item.fieldGroups).flat(); - console.log(`🔍 [handleBatchSave] 품목 처리: ${item.id} (${allGroupEntries.length}개 입력 항목)`); + const groupKeys = Object.keys(item.fieldGroups); + console.log(`🔍 [handleBatchSave] 품목 처리: ${item.id} (${groupKeys.length}개 그룹)`); - // 모든 그룹의 모든 항목을 개별 레코드로 저장 - for (const entry of allGroupEntries) { + // 각 그룹의 항목 배열 가져오기 + const groupArrays = groupKeys.map(groupKey => ({ + groupKey, + entries: item.fieldGroups[groupKey] || [] + })); + + console.log(`📊 [handleBatchSave] 그룹별 항목 수:`, + groupArrays.map(g => `${g.groupKey}: ${g.entries.length}개`).join(", ") + ); + + // 카티션 곱 계산 함수 + const cartesianProduct = (arrays: any[][]): any[][] => { + if (arrays.length === 0) return [[]]; + if (arrays.length === 1) return arrays[0].map(item => [item]); + + const [first, ...rest] = arrays; + const restProduct = cartesianProduct(rest); + + return first.flatMap(item => + restProduct.map(combination => [item, ...combination]) + ); + }; + + // 모든 그룹의 카티션 곱 생성 + const entryArrays = groupArrays.map(g => g.entries); + const combinations = cartesianProduct(entryArrays); + + console.log(`🔢 [handleBatchSave] 생성된 조합 수: ${combinations.length}개`); + + // 각 조합을 개별 레코드로 저장 + for (let i = 0; i < combinations.length; i++) { + const combination = combinations[i]; try { - // 원본 데이터 + 입력 데이터 병합 - const mergedData = { - ...item.originalData, - ...entry, - }; + // 🆕 부모 데이터 매핑 적용 + const mappedData: any = {}; - // id 필드 제거 (entry.id는 임시 ID이므로) - delete mergedData.id; + // 1. parentDataMapping 설정이 있으면 적용 + if (parentDataMapping.length > 0) { + console.log(` 🔗 [parentDataMapping] 매핑 시작 (${parentDataMapping.length}개 매핑)`); + + for (const mapping of parentDataMapping) { + // sourceTable을 기준으로 데이터 소스 결정 + let sourceData: any; + + // 🔍 sourceTable과 실제 데이터 테이블 비교 + // - parentData는 이전 화면 데이터 (예: 거래처 테이블) + // - item.originalData는 선택된 항목 데이터 (예: 품목 테이블) + + // 원본 데이터 테이블명 확인 (sourceTable이 config에 명시되어 있음) + const sourceTableName = mapping.sourceTable; + + // 현재 선택된 항목의 테이블 = config.sourceTable + const selectedItemTable = componentConfig?.sourceTable; + + if (sourceTableName === selectedItemTable) { + // 선택된 항목 데이터 사용 + sourceData = item.originalData; + console.log(` 📦 소스: 선택된 항목 데이터 (${sourceTableName})`); + } else { + // 이전 화면 데이터 사용 + sourceData = parentData; + console.log(` 👤 소스: 이전 화면 데이터 (${sourceTableName})`); + } + + const sourceValue = sourceData[mapping.sourceField]; + + if (sourceValue !== undefined && sourceValue !== null) { + mappedData[mapping.targetField] = sourceValue; + console.log(` ✅ [${sourceTableName}] ${mapping.sourceField} → ${mapping.targetField}: ${sourceValue}`); + } else if (mapping.defaultValue !== undefined) { + mappedData[mapping.targetField] = mapping.defaultValue; + console.log(` ⚠️ [${sourceTableName}] ${mapping.sourceField} 없음, 기본값 사용 → ${mapping.targetField}: ${mapping.defaultValue}`); + } else { + console.log(` ⚠️ [${sourceTableName}] ${mapping.sourceField} 없음, 건너뜀`); + } + } + } else { + // 🔧 parentDataMapping 설정이 없는 경우 기본 매핑 (하위 호환성) + console.log(` ⚠️ [parentDataMapping] 설정 없음, 기본 매핑 적용`); + + // 기본 item_id 매핑 (item.originalData의 id) + if (item.originalData.id) { + mappedData.item_id = item.originalData.id; + console.log(` ✅ [기본] item_id 매핑: ${item.originalData.id}`); + } + + // 기본 customer_id 매핑 (parentData의 id 또는 customer_id) + if (parentData.id || parentData.customer_id) { + mappedData.customer_id = parentData.customer_id || parentData.id; + console.log(` ✅ [기본] customer_id 매핑: ${mappedData.customer_id}`); + } + } + + // 공통 필드 복사 (company_code, currency_code 등) + if (item.originalData.company_code && !mappedData.company_code) { + mappedData.company_code = item.originalData.company_code; + } + if (item.originalData.currency_code && !mappedData.currency_code) { + mappedData.currency_code = item.originalData.currency_code; + } + + // 원본 데이터로 시작 (매핑된 데이터 사용) + let mergedData = { ...mappedData }; + + console.log(`🔍 [handleBatchSave] 조합 ${i + 1}/${combinations.length} 병합 시작:`, { + originalDataKeys: Object.keys(item.originalData), + mappedDataKeys: Object.keys(mappedData), + combinationLength: combination.length + }); + + // 각 그룹의 항목 데이터를 순차적으로 병합 + for (let j = 0; j < combination.length; j++) { + const entry = combination[j]; + const { id, ...entryData } = entry; // id 제외 + + console.log(` 🔸 그룹 ${j + 1} 데이터 병합:`, entryData); + + mergedData = { ...mergedData, ...entryData }; + } + + console.log(`📝 [handleBatchSave] 조합 ${i + 1}/${combinations.length} 최종 데이터:`, mergedData); + + // 🆕 조합 저장 시 id 필드 제거 (각 조합이 독립된 새 레코드가 되도록) + // originalData의 id는 원본 품목의 ID이므로, 새로운 customer_item_mapping 레코드 생성 시 제거 필요 + const { id: _removedId, ...dataWithoutId } = mergedData; + + console.log(`🔧 [handleBatchSave] 조합 ${i + 1}/${combinations.length} id 제거됨:`, { + removedId: _removedId, + hasId: 'id' in dataWithoutId + }); // 사용자 정보 추가 if (!context.userId) { @@ -557,16 +710,17 @@ export class ButtonActionExecutor { const companyCodeValue = context.companyCode || ""; const dataWithUserInfo = { - ...mergedData, - writer: mergedData.writer || writerValue, + ...dataWithoutId, // id가 제거된 데이터 사용 + writer: dataWithoutId.writer || writerValue, created_by: writerValue, updated_by: writerValue, - company_code: mergedData.company_code || companyCodeValue, + company_code: dataWithoutId.company_code || companyCodeValue, }; - console.log(`💾 [handleBatchSave] 입력 항목 저장:`, { + console.log(`💾 [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 요청:`, { itemId: item.id, - entryId: entry.id, + combinationIndex: i + 1, + totalCombinations: combinations.length, data: dataWithUserInfo }); @@ -580,16 +734,19 @@ export class ButtonActionExecutor { if (saveResult.success) { successCount++; - console.log(`✅ [handleBatchSave] 입력 항목 저장 성공: ${item.id} > ${entry.id}`); + console.log(`✅ [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 성공!`, { + savedId: saveResult.data?.id, + itemId: item.id + }); } else { failCount++; - errors.push(`품목 ${item.id} > 항목 ${entry.id}: ${saveResult.message}`); - console.error(`❌ [handleBatchSave] 입력 항목 저장 실패: ${item.id} > ${entry.id}`, saveResult.message); + errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${saveResult.message}`); + console.error(`❌ [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 실패:`, saveResult.message); } } catch (error: any) { failCount++; - errors.push(`품목 ${item.id} > 항목 ${entry.id}: ${error.message}`); - console.error(`❌ [handleBatchSave] 입력 항목 저장 오류: ${item.id} > ${entry.id}`, error); + errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${error.message}`); + console.error(`❌ [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 오류:`, error); } } }