From fa426625cc772765193245f7c488968fdd6f2f72 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 21 Nov 2025 10:12:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20modal-repeater-table=20=EB=B0=B0?= =?UTF-8?q?=EC=97=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=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 - 백엔드: 배열 객체 형식 Repeater 데이터 처리 로직 추가 - 백엔드: Repeater 저장 시 company_code 자동 주입 - 백엔드: 부모 테이블 데이터 자동 병합 (targetTable = tableName) - 프론트엔드: beforeFormSave 이벤트로 formData 주입 - 프론트엔드: _targetTable 메타데이터 전달 - 프론트엔드: ComponentRendererProps 상속 및 Renderer 단순화 멀티테넌시 및 부모-자식 관계 자동 처리로 복잡한 배열 데이터 저장 안정성 확보 --- .../src/services/dynamicFormService.ts | 94 +++++++---- frontend/components/order/orderConstants.ts | 21 +++ .../ModalRepeaterTableComponent.tsx | 151 +++++++++++++++--- .../ModalRepeaterTableRenderer.tsx | 31 +--- frontend/lib/utils/buttonActions.ts | 18 +-- 5 files changed, 229 insertions(+), 86 deletions(-) create mode 100644 frontend/components/order/orderConstants.ts diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 9e06804b..cc2fad77 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -320,43 +320,60 @@ export class DynamicFormService { Object.keys(dataToInsert).forEach((key) => { const value = dataToInsert[key]; - // RepeaterInput 데이터인지 확인 (JSON 배열 문자열) - if ( + // 🔥 RepeaterInput 데이터인지 확인 (배열 객체 또는 JSON 문자열) + let parsedArray: any[] | null = null; + + // 1️⃣ 이미 배열 객체인 경우 (ModalRepeaterTable, SelectedItemsDetailInput 등) + if (Array.isArray(value) && value.length > 0) { + parsedArray = value; + console.log( + `🔄 배열 객체 Repeater 데이터 감지: ${key}, ${parsedArray.length}개 항목` + ); + } + // 2️⃣ JSON 문자열인 경우 (레거시 RepeaterInput) + else if ( typeof value === "string" && value.trim().startsWith("[") && value.trim().endsWith("]") ) { try { - const parsedArray = JSON.parse(value); - if (Array.isArray(parsedArray) && parsedArray.length > 0) { - console.log( - `🔄 RepeaterInput 데이터 감지: ${key}, ${parsedArray.length}개 항목` - ); - - // 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해) - // 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음 - let targetTable: string | undefined; - let actualData = parsedArray; - - // 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달) - if (parsedArray[0] && parsedArray[0]._targetTable) { - targetTable = parsedArray[0]._targetTable; - actualData = parsedArray.map( - ({ _targetTable, ...item }) => item - ); - } - - repeaterData.push({ - data: actualData, - targetTable, - componentId: key, - }); - delete dataToInsert[key]; // 원본 배열 데이터는 제거 - } + parsedArray = JSON.parse(value); + console.log( + `🔄 JSON 문자열 Repeater 데이터 감지: ${key}, ${parsedArray?.length || 0}개 항목` + ); } catch (parseError) { console.log(`⚠️ JSON 파싱 실패: ${key}`); } } + + // 파싱된 배열이 있으면 처리 + if (parsedArray && Array.isArray(parsedArray) && parsedArray.length > 0) { + // 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해) + // 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음 + let targetTable: string | undefined; + let actualData = parsedArray; + + // 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달) + if (parsedArray[0] && parsedArray[0]._targetTable) { + targetTable = parsedArray[0]._targetTable; + actualData = parsedArray.map( + ({ _targetTable, ...item }) => item + ); + } + + repeaterData.push({ + data: actualData, + targetTable, + componentId: key, + }); + delete dataToInsert[key]; // 원본 배열 데이터는 제거 + + console.log(`✅ Repeater 데이터 추가: ${key}`, { + targetTable: targetTable || "없음 (화면 설계에서 설정 필요)", + itemCount: actualData.length, + firstItem: actualData[0], + }); + } }); // 존재하지 않는 컬럼 제거 @@ -497,8 +514,29 @@ export class DynamicFormService { created_by, updated_by, regdate: new Date(), + // 🔥 멀티테넌시: company_code 필수 추가 + company_code: data.company_code || company_code, }; + // 🔥 부모 테이블의 데이터를 자동 복사 (외래키 관계) + // targetTable이 메인 테이블과 같으면 부모 데이터 추가 + if (targetTableName === tableName) { + console.log( + `⚠️ [Repeater] targetTable이 메인 테이블과 같음 (${tableName}). 부모 데이터 추가 중...` + ); + // 메인 테이블의 모든 데이터를 Repeater 항목에 복사 + Object.keys(dataToInsert).forEach((key) => { + // 중복되지 않는 필드만 추가 + if (itemData[key] === undefined) { + itemData[key] = dataToInsert[key]; + } + }); + console.log( + `✅ [Repeater] 부모 데이터 병합 완료:`, + Object.keys(itemData) + ); + } + // 대상 테이블에 존재하는 컬럼만 필터링 Object.keys(itemData).forEach((key) => { if (!targetColumnNames.includes(key)) { diff --git a/frontend/components/order/orderConstants.ts b/frontend/components/order/orderConstants.ts new file mode 100644 index 00000000..f93451f8 --- /dev/null +++ b/frontend/components/order/orderConstants.ts @@ -0,0 +1,21 @@ +export const INPUT_MODE = { + CUSTOMER_FIRST: "customer_first", + QUOTATION: "quotation", + UNIT_PRICE: "unit_price", +} as const; + +export type InputMode = (typeof INPUT_MODE)[keyof typeof INPUT_MODE]; + +export const SALES_TYPE = { + DOMESTIC: "domestic", + EXPORT: "export", +} as const; + +export type SalesType = (typeof SALES_TYPE)[keyof typeof SALES_TYPE]; + +export const PRICE_TYPE = { + STANDARD: "standard", + CUSTOMER: "customer", +} as const; + +export type PriceType = (typeof PRICE_TYPE)[keyof typeof PRICE_TYPE]; diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index 2003c5ef..d903cc9f 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -9,9 +9,26 @@ import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition } from "./ import { useCalculation } from "./useCalculation"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import { ComponentRendererProps } from "@/types/component"; -interface ModalRepeaterTableComponentProps extends Partial { +// ✅ ComponentRendererProps 상속으로 필수 props 자동 확보 +export interface ModalRepeaterTableComponentProps extends ComponentRendererProps { config?: ModalRepeaterTableProps; + // ModalRepeaterTableProps의 개별 prop들도 지원 (호환성) + sourceTable?: string; + sourceColumns?: string[]; + sourceSearchFields?: string[]; + targetTable?: string; + modalTitle?: string; + modalButtonText?: string; + multiSelect?: boolean; + columns?: RepeaterColumnConfig[]; + calculationRules?: any[]; + value?: any[]; + onChange?: (newData: any[]) => void; + uniqueField?: string; + filterCondition?: Record; + companyCode?: string; } /** @@ -122,10 +139,25 @@ async function fetchReferenceValue( } export function ModalRepeaterTableComponent({ + // ComponentRendererProps (자동 전달) + component, + isDesignMode = false, + isSelected = false, + isInteractive = false, + onClick, + onDragStart, + onDragEnd, + className, + style, + formData, + onFormDataChange, + + // ModalRepeaterTable 전용 props config, sourceTable: propSourceTable, sourceColumns: propSourceColumns, sourceSearchFields: propSourceSearchFields, + targetTable: propTargetTable, modalTitle: propModalTitle, modalButtonText: propModalButtonText, multiSelect: propMultiSelect, @@ -136,36 +168,55 @@ export function ModalRepeaterTableComponent({ uniqueField: propUniqueField, filterCondition: propFilterCondition, companyCode: propCompanyCode, - className, + + ...props }: ModalRepeaterTableComponentProps) { + // ✅ config 또는 component.config 또는 개별 prop 우선순위로 병합 + const componentConfig = { + ...config, + ...component?.config, + }; + // config prop 우선, 없으면 개별 prop 사용 - const sourceTable = config?.sourceTable || propSourceTable || ""; + const sourceTable = componentConfig?.sourceTable || propSourceTable || ""; + const targetTable = componentConfig?.targetTable || propTargetTable; // sourceColumns에서 빈 문자열 필터링 - const rawSourceColumns = config?.sourceColumns || propSourceColumns || []; - const sourceColumns = rawSourceColumns.filter((col) => col && col.trim() !== ""); + const rawSourceColumns = componentConfig?.sourceColumns || propSourceColumns || []; + const sourceColumns = rawSourceColumns.filter((col: string) => col && col.trim() !== ""); - const sourceSearchFields = config?.sourceSearchFields || propSourceSearchFields || []; - const modalTitle = config?.modalTitle || propModalTitle || "항목 검색"; - const modalButtonText = config?.modalButtonText || propModalButtonText || "품목 검색"; - const multiSelect = config?.multiSelect ?? propMultiSelect ?? true; - const calculationRules = config?.calculationRules || propCalculationRules || []; - const value = config?.value || propValue || []; - const onChange = config?.onChange || propOnChange || (() => {}); + const sourceSearchFields = componentConfig?.sourceSearchFields || propSourceSearchFields || []; + const modalTitle = componentConfig?.modalTitle || propModalTitle || "항목 검색"; + const modalButtonText = componentConfig?.modalButtonText || propModalButtonText || "품목 검색"; + const multiSelect = componentConfig?.multiSelect ?? propMultiSelect ?? true; + const calculationRules = componentConfig?.calculationRules || propCalculationRules || []; + + // ✅ value는 formData[columnName] 우선, 없으면 prop 사용 + const columnName = component?.columnName; + const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || []; + + // ✅ onChange 래퍼 (기존 onChange 콜백만 호출, formData는 beforeFormSave에서 처리) + const handleChange = (newData: any[]) => { + // 기존 onChange 콜백 호출 (호환성) + const externalOnChange = componentConfig?.onChange || propOnChange; + if (externalOnChange) { + externalOnChange(newData); + } + }; // uniqueField 자동 보정: order_no는 item_info 테이블에 없으므로 item_number로 변경 - const rawUniqueField = config?.uniqueField || propUniqueField; + const rawUniqueField = componentConfig?.uniqueField || propUniqueField; const uniqueField = rawUniqueField === "order_no" && sourceTable === "item_info" ? "item_number" : rawUniqueField; - const filterCondition = config?.filterCondition || propFilterCondition || {}; - const companyCode = config?.companyCode || propCompanyCode; + const filterCondition = componentConfig?.filterCondition || propFilterCondition || {}; + const companyCode = componentConfig?.companyCode || propCompanyCode; const [modalOpen, setModalOpen] = useState(false); // columns가 비어있으면 sourceColumns로부터 자동 생성 const columns = React.useMemo((): RepeaterColumnConfig[] => { - const configuredColumns = config?.columns || propColumns || []; + const configuredColumns = componentConfig?.columns || propColumns || []; if (configuredColumns.length > 0) { console.log("✅ 설정된 columns 사용:", configuredColumns); @@ -188,7 +239,7 @@ export function ModalRepeaterTableComponent({ console.warn("⚠️ columns와 sourceColumns 모두 비어있음!"); return []; - }, [config?.columns, propColumns, sourceColumns]); + }, [componentConfig?.columns, propColumns, sourceColumns]); // 초기 props 로깅 useEffect(() => { @@ -221,6 +272,59 @@ export function ModalRepeaterTableComponent({ }); }, [value]); + // 🆕 저장 요청 시에만 데이터 전달 (beforeFormSave 이벤트 리스너) + useEffect(() => { + const handleSaveRequest = async (event: Event) => { + const componentKey = columnName || component?.id || "modal_repeater_data"; + + console.log("🔔 [ModalRepeaterTable] beforeFormSave 이벤트 수신!", { + componentKey, + itemsCount: value.length, + hasOnFormDataChange: !!onFormDataChange, + columnName, + componentId: component?.id, + targetTable, + }); + + if (value.length === 0) { + console.warn("⚠️ [ModalRepeaterTable] 저장할 데이터 없음"); + return; + } + + // 🔥 targetTable 메타데이터를 배열 항목에 추가 + const dataWithTargetTable = targetTable + ? value.map(item => ({ + ...item, + _targetTable: targetTable, // 백엔드가 인식할 메타데이터 + })) + : value; + + // ✅ CustomEvent의 detail에 데이터 추가 + if (event instanceof CustomEvent && event.detail) { + event.detail.formData[componentKey] = dataWithTargetTable; + console.log("✅ [ModalRepeaterTable] context.formData에 데이터 추가 완료:", { + key: componentKey, + itemCount: dataWithTargetTable.length, + targetTable: targetTable || "미설정 (화면 설계에서 설정 필요)", + sampleItem: dataWithTargetTable[0], + }); + } + + // 기존 onFormDataChange도 호출 (호환성) + if (onFormDataChange) { + onFormDataChange(componentKey, dataWithTargetTable); + console.log("✅ [ModalRepeaterTable] onFormDataChange 호출 완료"); + } + }; + + // 저장 버튼 클릭 시 데이터 수집 + window.addEventListener("beforeFormSave", handleSaveRequest as EventListener); + + return () => { + window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener); + }; + }, [value, columnName, component?.id, onFormDataChange, targetTable]); + const { calculateRow, calculateAll } = useCalculation(calculationRules); // 초기 데이터에 계산 필드 적용 @@ -338,7 +442,8 @@ export function ModalRepeaterTableComponent({ const newData = [...value, ...calculatedItems]; console.log("✅ 최종 데이터:", newData.length, "개 항목"); - onChange(newData); + // ✅ 통합 onChange 호출 (formData 반영 포함) + handleChange(newData); }; const handleRowChange = (index: number, newRow: any) => { @@ -348,12 +453,16 @@ export function ModalRepeaterTableComponent({ // 데이터 업데이트 const newData = [...value]; newData[index] = calculatedRow; - onChange(newData); + + // ✅ 통합 onChange 호출 (formData 반영 포함) + handleChange(newData); }; const handleRowDelete = (index: number) => { const newData = value.filter((_, i) => i !== index); - onChange(newData); + + // ✅ 통합 onChange 호출 (formData 반영 포함) + handleChange(newData); }; // 컬럼명 -> 라벨명 매핑 생성 @@ -382,7 +491,7 @@ export function ModalRepeaterTableComponent({ diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer.tsx index 6362e1ce..6f61c052 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer.tsx @@ -7,40 +7,15 @@ import { ModalRepeaterTableComponent } from "./ModalRepeaterTableComponent"; /** * ModalRepeaterTable 렌더러 - * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + * ✅ 단순 전달만 수행 (TextInput 패턴 따름) */ export class ModalRepeaterTableRenderer extends AutoRegisteringComponentRenderer { static componentDefinition = ModalRepeaterTableDefinition; render(): React.ReactElement { - // onChange 콜백을 명시적으로 전달 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const handleChange = (newValue: any[]) => { - console.log("🔄 ModalRepeaterTableRenderer onChange:", newValue.length, "개 항목"); - - // 컴포넌트 업데이트 - this.updateComponent({ value: newValue }); - - // 원본 onChange 콜백도 호출 (있다면) - if (this.props.onChange) { - this.props.onChange(newValue); - } - }; - - // renderer prop 제거 (불필요) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { onChange, ...restProps } = this.props; - - return ; + // ✅ props를 그대로 전달 (Component에서 모든 로직 처리) + return ; } - - /** - * 값 변경 처리 (레거시 메서드 - 호환성 유지) - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected handleValueChange = (value: any) => { - this.updateComponent({ value }); - }; } // 자동 등록 실행 diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index d6ddd96c..3edd3e8a 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -342,15 +342,15 @@ export class ButtonActionExecutor { const writerValue = context.userId; const companyCodeValue = context.companyCode || ""; - // console.log("👤 [buttonActions] 사용자 정보:", { - // userId: context.userId, - // userName: context.userName, - // companyCode: context.companyCode, // ✅ 회사 코드 - // formDataWriter: formData.writer, // ✅ 폼에서 입력한 writer 값 - // formDataCompanyCode: formData.company_code, // ✅ 폼에서 입력한 company_code 값 - // defaultWriterValue: writerValue, - // companyCodeValue, // ✅ 최종 회사 코드 값 - // }); + console.log("👤 [buttonActions] 사용자 정보:", { + userId: context.userId, + userName: context.userName, + companyCode: context.companyCode, // ✅ 회사 코드 + formDataWriter: formData.writer, // ✅ 폼에서 입력한 writer 값 + formDataCompanyCode: formData.company_code, // ✅ 폼에서 입력한 company_code 값 + defaultWriterValue: writerValue, + companyCodeValue, // ✅ 최종 회사 코드 값 + }); // 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가) // console.log("🔍 채번 규칙 할당 체크 시작");