feat: Implement entity join functionality in V2Repeater and configuration panel
- Added support for entity joins in the V2Repeater component, allowing for automatic resolution of foreign key references to display data from related tables. - Introduced a new `resolveEntityJoins` function to handle the fetching and mapping of reference data based on configured entity joins. - Enhanced the V2RepeaterConfigPanel to manage entity join configurations, including loading available columns and toggling join settings. - Updated the data handling logic to incorporate mapping rules for incoming data, ensuring that only necessary fields are retained during processing. - Improved user experience by providing clear logging and feedback during entity join resolution and data mapping operations.
This commit is contained in:
@@ -305,12 +305,26 @@ export function ItemSelectionModal({
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// 이미 추가된 항목인지 확인
|
||||
// 이미 추가된 항목인지 확인 (매핑된 데이터의 _sourceData도 검사)
|
||||
const isAlreadyAdded = (item: any): boolean => {
|
||||
if (!uniqueField) return false;
|
||||
return alreadySelected.some(
|
||||
(selected) => selected[uniqueField] === item[uniqueField]
|
||||
);
|
||||
const checkField = uniqueField || "id";
|
||||
const itemValue = item[checkField];
|
||||
if (itemValue === undefined || itemValue === null) return false;
|
||||
const strItemValue = String(itemValue);
|
||||
|
||||
return alreadySelected.some((selected) => {
|
||||
// _sourceData 우선 확인 (DB 로드 항목의 참조 ID가 매핑되어 있음)
|
||||
const sourceValue = selected._sourceData?.[checkField];
|
||||
if (sourceValue !== undefined && sourceValue !== null && String(sourceValue) === strItemValue) {
|
||||
return true;
|
||||
}
|
||||
// _sourceData에 없으면 직접 필드 비교 (동일 필드명인 경우)
|
||||
const directValue = selected[checkField];
|
||||
if (directValue !== undefined && directValue !== null && String(directValue) === strItemValue) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
// 이미 추가된 항목 제외한 결과 필터링
|
||||
|
||||
@@ -642,6 +642,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||
}
|
||||
}
|
||||
|
||||
// 메인 레코드 ID 전달 (분할패널에서는 좌측 선택 항목이 메인 레코드)
|
||||
const leftPkColumn = config.leftPanel?.primaryKeyColumn || "id";
|
||||
if (selectedLeftItem?.[leftPkColumn]) {
|
||||
initialData._mainRecordId = selectedLeftItem[leftPkColumn];
|
||||
}
|
||||
|
||||
// EditModal 열기 이벤트 발생
|
||||
const event = new CustomEvent("openEditModal", {
|
||||
detail: {
|
||||
@@ -662,6 +668,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||
}, [
|
||||
config.rightPanel?.addModalScreenId,
|
||||
config.rightPanel?.addButtonLabel,
|
||||
config.leftPanel?.primaryKeyColumn,
|
||||
config.dataTransferFields,
|
||||
selectedLeftItem,
|
||||
loadRightData,
|
||||
@@ -718,6 +725,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||
}
|
||||
}
|
||||
|
||||
// 메인 레코드 ID 전달 (디테일 레코드의 id와 구분)
|
||||
const editItemLeftPkColumn = config.leftPanel?.primaryKeyColumn || "id";
|
||||
if (selectedLeftItem?.[editItemLeftPkColumn]) {
|
||||
editData._mainRecordId = selectedLeftItem[editItemLeftPkColumn];
|
||||
}
|
||||
|
||||
// EditModal 열기 이벤트 발생 (수정 모드)
|
||||
const event = new CustomEvent("openEditModal", {
|
||||
detail: {
|
||||
@@ -737,7 +750,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||
window.dispatchEvent(event);
|
||||
console.log("[SplitPanelLayout2] 우측 수정 모달 열기:", editData);
|
||||
},
|
||||
[config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, config.rightPanel?.mainTableForEdit, selectedLeftItem, loadRightData, loadLeftData],
|
||||
[config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, config.rightPanel?.mainTableForEdit, config.leftPanel?.primaryKeyColumn, selectedLeftItem, loadRightData, loadLeftData],
|
||||
);
|
||||
|
||||
// 좌측 패널 수정 버튼 클릭
|
||||
@@ -896,6 +909,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||
}
|
||||
}
|
||||
|
||||
// 메인 레코드 ID 전달 (분할패널에서는 좌측 선택 항목이 메인 레코드)
|
||||
const addLeftPkColumn = config.leftPanel?.primaryKeyColumn || "id";
|
||||
if (selectedLeftItem?.[addLeftPkColumn]) {
|
||||
initialData._mainRecordId = selectedLeftItem[addLeftPkColumn];
|
||||
}
|
||||
|
||||
const event = new CustomEvent("openEditModal", {
|
||||
detail: {
|
||||
screenId: btn.modalScreenId,
|
||||
@@ -929,12 +948,19 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||
return;
|
||||
}
|
||||
|
||||
// 메인 레코드 ID 전달 (디테일 레코드의 id와 구분)
|
||||
const editLeftPkColumn = config.leftPanel?.primaryKeyColumn || "id";
|
||||
const editData = { ...item };
|
||||
if (selectedLeftItem?.[editLeftPkColumn]) {
|
||||
editData._mainRecordId = selectedLeftItem[editLeftPkColumn];
|
||||
}
|
||||
|
||||
const event = new CustomEvent("openEditModal", {
|
||||
detail: {
|
||||
screenId: modalScreenId,
|
||||
title: btn.label || "수정",
|
||||
modalSize: "lg",
|
||||
editData: item,
|
||||
editData,
|
||||
isCreateMode: false,
|
||||
onSave: () => {
|
||||
if (selectedLeftItem) {
|
||||
|
||||
@@ -2132,6 +2132,101 @@ export function TableSectionRenderer({
|
||||
return Object.values(conditionalTableData).reduce((sum, data) => sum + data.length, 0);
|
||||
}, [conditionalTableData]);
|
||||
|
||||
// 조건부 테이블: 모달 중복 체크용 alreadySelected 구성
|
||||
// DB에서 로드된 항목은 _sourceData가 없으므로 참조 ID 필드를 기반으로 _sourceData를 생성
|
||||
const conditionalAlreadySelected = useMemo(() => {
|
||||
const allItems = Object.values(conditionalTableData).flat();
|
||||
if (allItems.length === 0) return allItems;
|
||||
|
||||
// 참조 ID 필드 탐색 (소스 테이블의 id를 저장하는 디테일 테이블 컬럼)
|
||||
const referenceIdField = (tableConfig.columns || [])
|
||||
.map((col) => col.saveConfig?.referenceDisplay?.referenceIdField)
|
||||
.find(Boolean)
|
||||
|| (tableConfig.columns || [])
|
||||
.map((col) => (col as any).dynamicSelectOptions?.rowSelectionMode?.targetIdField)
|
||||
.find(Boolean);
|
||||
|
||||
// sourceField 매핑 수집 (소스 테이블 필드 → 디테일 테이블 필드)
|
||||
const sourceFieldMap: Record<string, string> = {};
|
||||
for (const col of tableConfig.columns || []) {
|
||||
if (col.sourceField && col.sourceField !== col.field) {
|
||||
sourceFieldMap[col.sourceField] = col.field;
|
||||
}
|
||||
}
|
||||
|
||||
return allItems.map((item) => {
|
||||
if (item._sourceData) return item;
|
||||
|
||||
// DB에서 로드된 항목: _sourceData 재구성
|
||||
const sourceData: any = {};
|
||||
|
||||
// 참조 ID 필드가 있으면 소스 테이블의 id로 매핑
|
||||
if (referenceIdField && item[referenceIdField] !== undefined) {
|
||||
sourceData.id = item[referenceIdField];
|
||||
}
|
||||
|
||||
// sourceField 매핑을 역으로 적용 (디테일 필드 → 소스 필드)
|
||||
for (const [srcField, detailField] of Object.entries(sourceFieldMap)) {
|
||||
if (item[detailField] !== undefined) {
|
||||
sourceData[srcField] = item[detailField];
|
||||
}
|
||||
}
|
||||
|
||||
// 디테일 테이블의 필드도 소스 데이터에 포함 (동일 필드명인 경우)
|
||||
for (const col of tableConfig.columns || []) {
|
||||
if (!col.sourceField && item[col.field] !== undefined) {
|
||||
sourceData[col.field] = item[col.field];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(sourceData).length > 0
|
||||
? { ...item, _sourceData: sourceData }
|
||||
: item;
|
||||
});
|
||||
}, [conditionalTableData, tableConfig.columns]);
|
||||
|
||||
// 일반 테이블: 모달 중복 체크용 alreadySelected 구성 (DB 로드 항목 대응)
|
||||
const normalAlreadySelected = useMemo(() => {
|
||||
if (tableData.length === 0) return tableData;
|
||||
|
||||
const referenceIdField = (tableConfig.columns || [])
|
||||
.map((col) => col.saveConfig?.referenceDisplay?.referenceIdField)
|
||||
.find(Boolean)
|
||||
|| (tableConfig.columns || [])
|
||||
.map((col) => (col as any).dynamicSelectOptions?.rowSelectionMode?.targetIdField)
|
||||
.find(Boolean);
|
||||
|
||||
const sourceFieldMap: Record<string, string> = {};
|
||||
for (const col of tableConfig.columns || []) {
|
||||
if (col.sourceField && col.sourceField !== col.field) {
|
||||
sourceFieldMap[col.sourceField] = col.field;
|
||||
}
|
||||
}
|
||||
|
||||
return tableData.map((item) => {
|
||||
if (item._sourceData) return item;
|
||||
|
||||
const sourceData: any = {};
|
||||
if (referenceIdField && item[referenceIdField] !== undefined) {
|
||||
sourceData.id = item[referenceIdField];
|
||||
}
|
||||
for (const [srcField, detailField] of Object.entries(sourceFieldMap)) {
|
||||
if (item[detailField] !== undefined) {
|
||||
sourceData[srcField] = item[detailField];
|
||||
}
|
||||
}
|
||||
for (const col of tableConfig.columns || []) {
|
||||
if (!col.sourceField && item[col.field] !== undefined) {
|
||||
sourceData[col.field] = item[col.field];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(sourceData).length > 0
|
||||
? { ...item, _sourceData: sourceData }
|
||||
: item;
|
||||
});
|
||||
}, [tableData, tableConfig.columns]);
|
||||
|
||||
// ============================================
|
||||
// 조건부 테이블 렌더링
|
||||
// ============================================
|
||||
@@ -2449,7 +2544,7 @@ export function TableSectionRenderer({
|
||||
multiSelect={multiSelect}
|
||||
filterCondition={conditionalFilterCondition}
|
||||
modalTitle={`${effectiveOptions.find((o) => o.value === modalCondition)?.label || modalCondition} - ${modalTitle}`}
|
||||
alreadySelected={Object.values(conditionalTableData).flat()}
|
||||
alreadySelected={conditionalAlreadySelected}
|
||||
uniqueField={tableConfig.saveConfig?.uniqueField}
|
||||
onSelect={handleConditionalAddItems}
|
||||
columnLabels={columnLabels}
|
||||
@@ -2560,7 +2655,7 @@ export function TableSectionRenderer({
|
||||
multiSelect={multiSelect}
|
||||
filterCondition={baseFilterCondition}
|
||||
modalTitle={modalTitle}
|
||||
alreadySelected={tableData}
|
||||
alreadySelected={normalAlreadySelected}
|
||||
uniqueField={tableConfig.saveConfig?.uniqueField}
|
||||
onSelect={handleAddItems}
|
||||
columnLabels={columnLabels}
|
||||
|
||||
@@ -468,6 +468,11 @@ export function UniversalFormModalComponent({
|
||||
console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}개`);
|
||||
}
|
||||
|
||||
// 분할패널에서 전달한 메인 레코드 ID 전달
|
||||
if (latestFormData._mainRecordId) {
|
||||
event.detail.formData._mainRecordId = latestFormData._mainRecordId;
|
||||
}
|
||||
|
||||
// 🆕 부모 formData의 중첩 객체(modalKey)도 최신 데이터로 업데이트
|
||||
// onChange(setTimeout)가 아직 부모에 전파되지 않았을 수 있으므로 직접 업데이트
|
||||
for (const parentKey of Object.keys(event.detail.formData)) {
|
||||
@@ -993,6 +998,11 @@ export function UniversalFormModalComponent({
|
||||
}
|
||||
}
|
||||
|
||||
// 분할패널에서 전달한 메인 레코드 ID 보존
|
||||
if (effectiveInitialData?._mainRecordId) {
|
||||
newFormData._mainRecordId = effectiveInitialData._mainRecordId;
|
||||
}
|
||||
|
||||
setFormData(newFormData);
|
||||
formDataRef.current = newFormData;
|
||||
setRepeatSections(newRepeatSections);
|
||||
|
||||
@@ -44,9 +44,11 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
|
||||
console.log("📋 V2RepeaterRenderer config 추출:", {
|
||||
hasComponentConfig: !!component?.componentConfig,
|
||||
hasConfig: !!component?.config,
|
||||
hasOverrides: !!(component as any)?.overrides,
|
||||
useCustomTable: componentConfig.useCustomTable,
|
||||
mainTableName: componentConfig.mainTableName,
|
||||
foreignKeyColumn: componentConfig.foreignKeyColumn,
|
||||
entityJoins: componentConfig.entityJoins,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user