From 5609e32daf6e665cdfda5d658523de75a9c6ef79 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 25 Nov 2025 14:23:54 +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=20CRUD=20=EB=B0=8F=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=9E=90=EB=8F=99=20=EB=B3=B5=EC=82=AC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 품목 추가 시 공통 필드(거래처, 담당자, 메모) 자동 복사 - ModalRepeaterTable onChange 시 groupData 반영 - 백엔드 타입 캐스팅으로 PostgreSQL 에러 해결 - 타입 정규화로 불필요한 UPDATE 방지 - 수정 모달에서 거래처/수주번호 읽기 전용 처리 --- .../src/services/dynamicFormService.ts | 34 ++++++- frontend/components/screen/EditModal.tsx | 89 ++++++++++++------- .../screen/InteractiveScreenViewerDynamic.tsx | 5 ++ .../lib/registry/DynamicComponentRenderer.tsx | 11 ++- .../ConditionalSectionViewer.tsx | 18 ++-- .../ModalRepeaterTableComponent.tsx | 7 +- 6 files changed, 117 insertions(+), 47 deletions(-) diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 4d33dc1c..e9485620 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -811,9 +811,39 @@ export class DynamicFormService { const primaryKeyColumn = primaryKeys[0]; console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`); - // 동적 UPDATE SQL 생성 (변경된 필드만) + // 🆕 컬럼 타입 조회 (타입 캐스팅용) + const columnTypesQuery = ` + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_name = $1 AND table_schema = 'public' + `; + const columnTypesResult = await query<{ column_name: string; data_type: string }>( + columnTypesQuery, + [tableName] + ); + const columnTypes: Record = {}; + columnTypesResult.forEach((row) => { + columnTypes[row.column_name] = row.data_type; + }); + + console.log("📊 컬럼 타입 정보:", columnTypes); + + // 🆕 동적 UPDATE SQL 생성 (타입 캐스팅 포함) const setClause = Object.keys(changedFields) - .map((key, index) => `${key} = $${index + 1}`) + .map((key, index) => { + const dataType = columnTypes[key]; + // 숫자 타입인 경우 명시적 캐스팅 + if (dataType === 'integer' || dataType === 'bigint' || dataType === 'smallint') { + return `${key} = $${index + 1}::integer`; + } else if (dataType === 'numeric' || dataType === 'decimal' || dataType === 'real' || dataType === 'double precision') { + return `${key} = $${index + 1}::numeric`; + } else if (dataType === 'boolean') { + return `${key} = $${index + 1}::boolean`; + } else { + // 문자열 타입은 캐스팅 불필요 + return `${key} = $${index + 1}`; + } + }) .join(", "); const values: any[] = Object.values(changedFields); diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 3280891f..f9b803b2 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -320,43 +320,24 @@ export const EditModal: React.FC = ({ className }) => { let updatedCount = 0; let deletedCount = 0; - // 🆕 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", - ]; - // 1️⃣ 신규 품목 추가 (id가 없는 항목) for (const currentData of groupData) { if (!currentData.id) { console.log("➕ 신규 품목 추가:", currentData); + console.log("📋 [신규 품목] currentData 키 목록:", Object.keys(currentData)); - // 실제 테이블 컬럼만 추출 - const insertData: Record = {}; - Object.keys(currentData).forEach((key) => { - if (salesOrderColumns.includes(key) && key !== "id") { - insertData[key] = currentData[key]; - } - }); + // 🆕 모든 데이터를 포함 (id 제외) + const insertData: Record = { ...currentData }; + console.log("📦 [신규 품목] 복사 직후 insertData:", insertData); + console.log("📋 [신규 품목] insertData 키 목록:", Object.keys(insertData)); + + delete insertData.id; // id는 자동 생성되므로 제거 // 🆕 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); + // 기존 품목(originalGroupData[0])에서 groupByColumns 값 가져오기 + const referenceData = originalGroupData[0] || groupData.find((item) => item.id); if (referenceData && referenceData[colName]) { insertData[colName] = referenceData[colName]; console.log(`🔑 [신규 품목] ${colName} 값 추가:`, referenceData[colName]); @@ -364,7 +345,31 @@ export const EditModal: React.FC = ({ className }) => { }); } + // 🆕 공통 필드 추가 (거래처, 담당자, 납품처, 메모 등) + // formData에서 품목별 필드가 아닌 공통 필드를 복사 + const commonFields = [ + 'partner_id', // 거래처 + 'manager_id', // 담당자 + 'delivery_partner_id', // 납품처 + 'delivery_address', // 납품장소 + 'memo', // 메모 + 'order_date', // 주문일 + 'due_date', // 납기일 + 'shipping_method', // 배송방법 + 'status', // 상태 + 'sales_type', // 영업유형 + ]; + + commonFields.forEach((fieldName) => { + // formData에 값이 있으면 추가 + if (formData[fieldName] !== undefined && formData[fieldName] !== null) { + insertData[fieldName] = formData[fieldName]; + console.log(`🔗 [공통 필드] ${fieldName} 값 추가:`, formData[fieldName]); + } + }); + console.log("📦 [신규 품목] 최종 insertData:", insertData); + console.log("📋 [신규 품목] 최종 insertData 키 목록:", Object.keys(insertData)); try { const response = await dynamicFormApi.saveFormData({ @@ -398,16 +403,32 @@ export const EditModal: React.FC = ({ className }) => { continue; } - // 변경된 필드만 추출 + // 🆕 값 정규화 함수 (타입 통일) + const normalizeValue = (val: any): any => { + if (val === null || val === undefined || val === "") return null; + if (typeof val === "string" && !isNaN(Number(val))) { + // 숫자로 변환 가능한 문자열은 숫자로 + return Number(val); + } + return val; + }; + + // 변경된 필드만 추출 (id 제외) const changedData: Record = {}; Object.keys(currentData).forEach((key) => { - // sales_order_mng 테이블의 컬럼만 처리 (조인 컬럼 제외) - if (!salesOrderColumns.includes(key)) { + // id는 변경 불가 + if (key === "id") { return; } - if (currentData[key] !== originalItemData[key]) { - changedData[key] = currentData[key]; + // 🆕 타입 정규화 후 비교 + const currentValue = normalizeValue(currentData[key]); + const originalValue = normalizeValue(originalItemData[key]); + + // 값이 변경된 경우만 포함 + if (currentValue !== originalValue) { + console.log(`🔍 [품목 수정 감지] ${key}: ${originalValue} → ${currentValue}`); + changedData[key] = currentData[key]; // 원본 값 사용 (문자열 그대로) } }); @@ -677,6 +698,8 @@ export const EditModal: React.FC = ({ className }) => { isInModal={true} // 🆕 그룹 데이터를 ModalRepeaterTable에 전달 groupedData={groupData.length > 0 ? groupData : undefined} + // 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처) + disabledFields={["order_no", "partner_id"]} /> ); })} diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index fb5046c3..aa46ed40 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에서 전달) + disabledFields?: string[]; // 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록) isInModal?: boolean; } @@ -66,6 +68,7 @@ export const InteractiveScreenViewerDynamic: React.FC { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 @@ -341,6 +344,8 @@ export const InteractiveScreenViewerDynamic: React.FC { diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index bf2b6ecb..cf6037eb 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -110,6 +110,8 @@ export interface DynamicComponentRendererProps { selectedRows?: any[]; // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable) groupedData?: Record[]; + // 🆕 비활성화할 필드 목록 (EditModal → 각 컴포넌트) + disabledFields?: string[]; selectedRowsData?: any[]; onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void; // 테이블 정렬 정보 (엑셀 다운로드용) @@ -168,6 +170,9 @@ export const DynamicComponentRenderer: React.FC = } }; + // 🆕 disabledFields 체크 + const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).readonly; + return ( = onChange={handleChange} placeholder={component.componentConfig?.placeholder || "선택하세요"} required={(component as any).required} - disabled={(component as any).readonly} + disabled={isFieldDisabled} className="w-full" /> ); @@ -271,6 +276,7 @@ export const DynamicComponentRenderer: React.FC = onConfigChange, isPreview, autoGeneration, + disabledFields, // 🆕 비활성화 필드 목록 ...restProps } = props; @@ -368,7 +374,8 @@ export const DynamicComponentRenderer: React.FC = mode, isInModal, readonly: component.readonly, - disabled: component.readonly, + // 🆕 disabledFields 체크 또는 기존 readonly + disabled: disabledFields?.includes(fieldName) || component.readonly, originalData, allComponents, onUpdateLayout, diff --git a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx index 9709b620..735fac6d 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx @@ -154,18 +154,18 @@ export function ConditionalSectionViewer({ }} > + /> ); })} diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index 3941a89f..59ce35a8 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -195,13 +195,18 @@ export function ModalRepeaterTableComponent({ const columnName = component?.columnName; const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || []; - // ✅ onChange 래퍼 (기존 onChange 콜백만 호출, formData는 beforeFormSave에서 처리) + // ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출) const handleChange = (newData: any[]) => { // 기존 onChange 콜백 호출 (호환성) const externalOnChange = componentConfig?.onChange || propOnChange; if (externalOnChange) { externalOnChange(newData); } + + // 🆕 onFormDataChange 호출하여 EditModal의 groupData 업데이트 + if (onFormDataChange && columnName) { + onFormDataChange(columnName, newData); + } }; // uniqueField 자동 보정: order_no는 item_info 테이블에 없으므로 item_number로 변경