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:
kjs
2026-03-04 21:08:45 +09:00
parent f97edad1ea
commit ac2da7a1d7
10 changed files with 813 additions and 31 deletions

View File

@@ -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;
});
};
// 이미 추가된 항목 제외한 결과 필터링

View File

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

View File

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

View File

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

View File

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