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

@@ -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}
/>