From a9f57add62f2256f86a08ea21d12bdd8271e7d1d Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 25 Nov 2025 12:07:14 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=88=98=EC=A3=BC=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=ED=92=88=EB=AA=A9=20=EC=B6=94=EA=B0=80/=EC=88=98=EC=A0=95/?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EditModal의 handleSave가 button-primary까지 전달되도록 수정 - ConditionalContainer/ConditionalSectionViewer에 onSave prop 추가 - DynamicComponentRenderer와 InteractiveScreenViewerDynamic에 onSave 전달 로직 추가 - ButtonActionExecutor에서 context.onSave 콜백 우선 실행 로직 구현 - 신규 품목 추가 시 groupByColumns 값 자동 포함 처리 기능: - 품목 추가: order_no 자동 설정 - 품목 수정: 변경 필드만 부분 업데이트 - 품목 삭제: originalGroupData 비교 후 제거 --- frontend/components/screen/EditModal.tsx | 220 ++++++++++++------ .../screen/InteractiveScreenViewerDynamic.tsx | 6 + .../lib/registry/DynamicComponentRenderer.tsx | 3 + .../button-primary/ButtonPrimaryComponent.tsx | 7 + .../ConditionalContainerComponent.tsx | 3 + .../ConditionalSectionViewer.tsx | 20 +- .../components/conditional-container/types.ts | 2 + frontend/lib/utils/buttonActions.ts | 19 +- 8 files changed, 204 insertions(+), 76 deletions(-) diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 4e756600..3280891f 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -305,84 +305,173 @@ export const EditModal: React.FC = ({ className }) => { } try { - // 🆕 그룹 데이터가 있는 경우: 모든 품목 일괄 수정 - if (groupData.length > 0) { - console.log("🔄 그룹 데이터 일괄 수정 시작:", { + // 🆕 그룹 데이터가 있는 경우: 모든 품목 일괄 처리 (추가/수정/삭제) + if (groupData.length > 0 || originalGroupData.length > 0) { + console.log("🔄 그룹 데이터 일괄 처리 시작:", { groupDataLength: groupData.length, originalGroupDataLength: originalGroupData.length, + groupData, + originalGroupData, + tableName: screenData.screenInfo.tableName, + screenId: modalState.screenId, }); + let insertedCount = 0; let updatedCount = 0; + let deletedCount = 0; - for (let i = 0; i < groupData.length; i++) { - const currentData = groupData[i]; - const originalItemData = originalGroupData[i]; + // 🆕 sales_order_mng 테이블의 실제 컬럼만 포함 (조인된 컬럼 제외) + const salesOrderColumns = [ + "id", + "order_no", + "customer_code", + "customer_name", + "order_date", + "delivery_date", + "item_code", + "quantity", + "unit_price", + "amount", + "status", + "notes", + "created_at", + "updated_at", + "company_code", + ]; - if (!originalItemData) { - console.warn(`원본 데이터가 없습니다 (index: ${i})`); - continue; - } + // 1️⃣ 신규 품목 추가 (id가 없는 항목) + for (const currentData of groupData) { + if (!currentData.id) { + console.log("➕ 신규 품목 추가:", currentData); - // 변경된 필드만 추출 - const changedData: Record = {}; - - // 🆕 sales_order_mng 테이블의 실제 컬럼만 포함 (조인된 컬럼 제외) - const salesOrderColumns = [ - "id", - "order_no", - "customer_code", - "customer_name", - "order_date", - "delivery_date", - "item_code", - "quantity", - "unit_price", - "amount", - "status", - "notes", - "created_at", - "updated_at", - "company_code", - ]; - - Object.keys(currentData).forEach((key) => { - // sales_order_mng 테이블의 컬럼만 처리 (조인 컬럼 제외) - if (!salesOrderColumns.includes(key)) { - return; + // 실제 테이블 컬럼만 추출 + const insertData: Record = {}; + Object.keys(currentData).forEach((key) => { + if (salesOrderColumns.includes(key) && key !== "id") { + insertData[key] = currentData[key]; + } + }); + + // 🆕 groupByColumns의 값을 강제로 포함 (order_no 등) + if (modalState.groupByColumns && modalState.groupByColumns.length > 0) { + modalState.groupByColumns.forEach((colName) => { + // 기존 품목(groupData[0])에서 groupByColumns 값 가져오기 + const referenceData = originalGroupData[0] || groupData.find(item => item.id); + if (referenceData && referenceData[colName]) { + insertData[colName] = referenceData[colName]; + console.log(`🔑 [신규 품목] ${colName} 값 추가:`, referenceData[colName]); + } + }); } - - if (currentData[key] !== originalItemData[key]) { - changedData[key] = currentData[key]; + + console.log("📦 [신규 품목] 최종 insertData:", insertData); + + try { + const response = await dynamicFormApi.saveFormData({ + screenId: modalState.screenId || 0, + tableName: screenData.screenInfo.tableName, + data: insertData, + }); + + if (response.success) { + insertedCount++; + console.log("✅ 신규 품목 추가 성공:", response.data); + } else { + console.error("❌ 신규 품목 추가 실패:", response.message); + } + } catch (error: any) { + console.error("❌ 신규 품목 추가 오류:", error); } - }); - - // 변경사항이 없으면 스킵 - if (Object.keys(changedData).length === 0) { - console.log(`변경사항 없음 (index: ${i})`); - continue; - } - - // 기본키 확인 - const recordId = originalItemData.id || Object.values(originalItemData)[0]; - - // UPDATE 실행 - const response = await dynamicFormApi.updateFormDataPartial( - recordId, - originalItemData, - changedData, - screenData.screenInfo.tableName, - ); - - if (response.success) { - updatedCount++; - console.log(`✅ 품목 ${i + 1} 수정 성공 (id: ${recordId})`); - } else { - console.error(`❌ 품목 ${i + 1} 수정 실패 (id: ${recordId}):`, response.message); } } - if (updatedCount > 0) { - toast.success(`${updatedCount}개의 품목이 수정되었습니다.`); + // 2️⃣ 기존 품목 수정 (id가 있는 항목) + for (const currentData of groupData) { + if (currentData.id) { + // id 기반 매칭 (인덱스 기반 X) + const originalItemData = originalGroupData.find( + (orig) => orig.id === currentData.id + ); + + if (!originalItemData) { + console.warn(`원본 데이터를 찾을 수 없습니다 (id: ${currentData.id})`); + continue; + } + + // 변경된 필드만 추출 + const changedData: Record = {}; + Object.keys(currentData).forEach((key) => { + // sales_order_mng 테이블의 컬럼만 처리 (조인 컬럼 제외) + if (!salesOrderColumns.includes(key)) { + return; + } + + if (currentData[key] !== originalItemData[key]) { + changedData[key] = currentData[key]; + } + }); + + // 변경사항이 없으면 스킵 + if (Object.keys(changedData).length === 0) { + console.log(`변경사항 없음 (id: ${currentData.id})`); + continue; + } + + // UPDATE 실행 + try { + const response = await dynamicFormApi.updateFormDataPartial( + currentData.id, + originalItemData, + changedData, + screenData.screenInfo.tableName, + ); + + if (response.success) { + updatedCount++; + console.log(`✅ 품목 수정 성공 (id: ${currentData.id})`); + } else { + console.error(`❌ 품목 수정 실패 (id: ${currentData.id}):`, response.message); + } + } catch (error: any) { + console.error(`❌ 품목 수정 오류 (id: ${currentData.id}):`, error); + } + } + } + + // 3️⃣ 삭제된 품목 제거 (원본에는 있지만 현재 데이터에는 없는 항목) + const currentIds = new Set(groupData.map((item) => item.id).filter(Boolean)); + const deletedItems = originalGroupData.filter( + (orig) => orig.id && !currentIds.has(orig.id) + ); + + for (const deletedItem of deletedItems) { + console.log("🗑️ 품목 삭제:", deletedItem); + + try { + const response = await dynamicFormApi.deleteFormDataFromTable( + deletedItem.id, + screenData.screenInfo.tableName + ); + + if (response.success) { + deletedCount++; + console.log(`✅ 품목 삭제 성공 (id: ${deletedItem.id})`); + } else { + console.error(`❌ 품목 삭제 실패 (id: ${deletedItem.id}):`, response.message); + } + } catch (error: any) { + console.error(`❌ 품목 삭제 오류 (id: ${deletedItem.id}):`, error); + } + } + + // 결과 메시지 + const messages: string[] = []; + if (insertedCount > 0) messages.push(`${insertedCount}개 추가`); + if (updatedCount > 0) messages.push(`${updatedCount}개 수정`); + if (deletedCount > 0) messages.push(`${deletedCount}개 삭제`); + + if (messages.length > 0) { + toast.success(`품목이 저장되었습니다 (${messages.join(", ")})`); // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) if (modalState.onSave) { @@ -585,6 +674,7 @@ export const EditModal: React.FC = ({ className }) => { tableName: screenData.screenInfo?.tableName, }} onSave={handleSave} + isInModal={true} // 🆕 그룹 데이터를 ModalRepeaterTable에 전달 groupedData={groupData.length > 0 ? groupData : undefined} /> diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index d1cd2a5f..fb5046c3 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -48,6 +48,8 @@ interface InteractiveScreenViewerProps { companyCode?: string; // 🆕 그룹 데이터 (EditModal에서 전달) groupedData?: Record[]; + // 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록) + isInModal?: boolean; } export const InteractiveScreenViewerDynamic: React.FC = ({ @@ -64,6 +66,7 @@ export const InteractiveScreenViewerDynamic: React.FC { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { userName: authUserName, user: authUser } = useAuth(); @@ -329,6 +332,7 @@ export const InteractiveScreenViewerDynamic: React.FC { @@ -401,6 +405,8 @@ export const InteractiveScreenViewerDynamic: React.FC { diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 92fd89e8..bf2b6ecb 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -105,6 +105,7 @@ export interface DynamicComponentRendererProps { companyCode?: string; // 🆕 현재 사용자의 회사 코드 onRefresh?: () => void; onClose?: () => void; + onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable) @@ -244,6 +245,7 @@ export const DynamicComponentRenderer: React.FC = selectedScreen, // 🆕 화면 정보 onRefresh, onClose, + onSave, // 🆕 EditModal의 handleSave 콜백 screenId, userId, // 🆕 사용자 ID userName, // 🆕 사용자 이름 @@ -358,6 +360,7 @@ export const DynamicComponentRenderer: React.FC = selectedScreen, // 🆕 화면 정보 onRefresh, onClose, + onSave, // 🆕 EditModal의 handleSave 콜백 screenId, userId, // 🆕 사용자 ID userName, // 🆕 사용자 이름 diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 112a285c..d2b69074 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -35,6 +35,7 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps { onRefresh?: () => void; onClose?: () => void; onFlowRefresh?: () => void; + onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 // 폼 데이터 관련 originalData?: Record; // 부분 업데이트용 원본 데이터 @@ -83,6 +84,7 @@ export const ButtonPrimaryComponent: React.FC = ({ onRefresh, onClose, onFlowRefresh, + onSave, // 🆕 EditModal의 handleSave 콜백 sortBy, // 🆕 정렬 컬럼 sortOrder, // 🆕 정렬 방향 columnOrder, // 🆕 컬럼 순서 @@ -95,6 +97,10 @@ export const ButtonPrimaryComponent: React.FC = ({ ...props }) => { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 + + // 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출) + const propsOnSave = (props as any).onSave as (() => Promise) | undefined; + const finalOnSave = onSave || propsOnSave; // 🆕 플로우 단계별 표시 제어 const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig; @@ -415,6 +421,7 @@ export const ButtonPrimaryComponent: React.FC = ({ onRefresh, onClose, onFlowRefresh, // 플로우 새로고침 콜백 추가 + onSave: finalOnSave, // 🆕 EditModal의 handleSave 콜백 (props에서도 추출) // 테이블 선택된 행 정보 추가 selectedRows, selectedRowsData, diff --git a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx index 2589026f..626ee137 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx @@ -41,6 +41,7 @@ export function ConditionalContainerComponent({ style, className, groupedData, // 🆕 그룹 데이터 + onSave, // 🆕 EditModal의 handleSave 콜백 }: ConditionalContainerProps) { console.log("🎯 ConditionalContainerComponent 렌더링!", { isDesignMode, @@ -179,6 +180,7 @@ export function ConditionalContainerComponent({ formData={formData} onFormDataChange={onFormDataChange} groupedData={groupedData} + onSave={onSave} /> ))} @@ -199,6 +201,7 @@ export function ConditionalContainerComponent({ formData={formData} onFormDataChange={onFormDataChange} groupedData={groupedData} + onSave={onSave} /> ) : null ) diff --git a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx index f77dbcdb..9709b620 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx @@ -26,6 +26,7 @@ export function ConditionalSectionViewer({ formData, onFormDataChange, groupedData, // 🆕 그룹 데이터 + onSave, // 🆕 EditModal의 handleSave 콜백 }: ConditionalSectionViewerProps) { const { userId, userName, user } = useAuth(); const [isLoading, setIsLoading] = useState(false); @@ -153,17 +154,18 @@ export function ConditionalSectionViewer({ }} > + onSave={onSave} + /> ); })} diff --git a/frontend/lib/registry/components/conditional-container/types.ts b/frontend/lib/registry/components/conditional-container/types.ts index 0cf741b2..bcd701ef 100644 --- a/frontend/lib/registry/components/conditional-container/types.ts +++ b/frontend/lib/registry/components/conditional-container/types.ts @@ -46,6 +46,7 @@ export interface ConditionalContainerProps { formData?: Record; onFormDataChange?: (fieldName: string, value: any) => void; groupedData?: Record[]; // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable) + onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 // 화면 편집기 관련 isDesignMode?: boolean; // 디자인 모드 여부 @@ -77,5 +78,6 @@ export interface ConditionalSectionViewerProps { formData?: Record; onFormDataChange?: (fieldName: string, value: any) => void; groupedData?: Record[]; // 🆕 그룹 데이터 + onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 } diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 3b9b9d9e..ddcf0f18 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -112,6 +112,7 @@ export interface ButtonActionContext { onClose?: () => void; onRefresh?: () => void; onFlowRefresh?: () => void; // 플로우 새로고침 콜백 + onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; @@ -213,9 +214,23 @@ export class ButtonActionExecutor { * 저장 액션 처리 (INSERT/UPDATE 자동 판단 - DB 기반) */ private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise { - const { formData, originalData, tableName, screenId } = context; + const { formData, originalData, tableName, screenId, onSave } = context; - console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId }); + console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId, hasOnSave: !!onSave }); + + // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 + if (onSave) { + console.log("✅ [handleSave] onSave 콜백 발견 - 콜백 실행"); + try { + await onSave(); + return true; + } catch (error) { + console.error("❌ [handleSave] onSave 콜백 실행 오류:", error); + throw error; + } + } + + console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행"); // 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집) // context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함