feat: modal-repeater-table 배열 데이터 저장 기능 구현

- 백엔드: 배열 객체 형식 Repeater 데이터 처리 로직 추가
- 백엔드: Repeater 저장 시 company_code 자동 주입
- 백엔드: 부모 테이블 데이터 자동 병합 (targetTable = tableName)
- 프론트엔드: beforeFormSave 이벤트로 formData 주입
- 프론트엔드: _targetTable 메타데이터 전달
- 프론트엔드: ComponentRendererProps 상속 및 Renderer 단순화

멀티테넌시 및 부모-자식 관계 자동 처리로
복잡한 배열 데이터 저장 안정성 확보
This commit is contained in:
SeongHyun Kim 2025-11-21 10:12:29 +09:00
parent 95b5e3dc7a
commit fa426625cc
5 changed files with 229 additions and 86 deletions

View File

@ -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)) {

View File

@ -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];

View File

@ -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<ModalRepeaterTableProps> {
// ✅ 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<string, any>;
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({
<RepeaterTable
columns={columns}
data={value}
onDataChange={onChange}
onDataChange={handleChange}
onRowChange={handleRowChange}
onRowDelete={handleRowDelete}
/>

View File

@ -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 <ModalRepeaterTableComponent {...restProps} onChange={handleChange} />;
// ✅ props를 그대로 전달 (Component에서 모든 로직 처리)
return <ModalRepeaterTableComponent {...this.props} />;
}
/**
* ( - )
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
}
// 자동 등록 실행

View File

@ -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("🔍 채번 규칙 할당 체크 시작");