feat: modal-repeater-table 배열 데이터 저장 기능 구현
- 백엔드: 배열 객체 형식 Repeater 데이터 처리 로직 추가 - 백엔드: Repeater 저장 시 company_code 자동 주입 - 백엔드: 부모 테이블 데이터 자동 병합 (targetTable = tableName) - 프론트엔드: beforeFormSave 이벤트로 formData 주입 - 프론트엔드: _targetTable 메타데이터 전달 - 프론트엔드: ComponentRendererProps 상속 및 Renderer 단순화 멀티테넌시 및 부모-자식 관계 자동 처리로 복잡한 배열 데이터 저장 안정성 확보
This commit is contained in:
parent
95b5e3dc7a
commit
fa426625cc
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
|
|
|
|||
|
|
@ -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("🔍 채번 규칙 할당 체크 시작");
|
||||
|
|
|
|||
Loading…
Reference in New Issue