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

@@ -0,0 +1,263 @@
# v2-table-list Entity 조인 기능 분석
v2-repeater에 동일 기능을 추가하기 위한 상세 분석 문서입니다.
---
## 1. 개요
v2-table-list의 Entity 조인 기능은 두 가지 유형으로 구분됩니다:
| 유형 | 설명 | 설정 방식 |
|------|------|-----------|
| **isEntityJoin** | 테이블 컬럼이 `input_type=entity`인 경우 (테이블 타입 관리에서 참조 테이블 설정됨) | 자동 감지 + entityDisplayConfig로 표시 컬럼 선택 |
| **additionalJoinInfo** | ConfigPanel "Entity 조인 컬럼" 탭에서 수동 추가한 참조 테이블 컬럼 | addEntityColumn으로 추가, additionalJoinInfo 저장 |
---
## 2. Entity 조인 설정 UI 구조 (TableListConfigPanel)
### 2.1 데이터 소스
- **entityJoinApi.getEntityJoinColumns(tableName)** 호출
- targetTableName 변경 시 useEffect로 재호출
### 2.2 entityJoinColumns 상태 구조
```typescript
{
availableColumns: Array<{
tableName: string; // 참조 테이블명 (예: dept_info)
columnName: string; // 참조 테이블 컬럼명 (예: company_name)
columnLabel: string;
dataType: string;
joinAlias: string; // 예: dept_code_company_name (sourceColumn_columnName)
suggestedLabel: string;
}>;
joinTables: Array<{
tableName: string; // 참조 테이블명
currentDisplayColumn: string;
joinConfig: { // 백엔드 entity-join-columns API에서 반환
sourceColumn: string; // 기준 테이블 FK 컬럼 (예: dept_code)
referenceTable: string;
referenceColumn: string;
displayColumn: string;
// ...
};
availableColumns: Array<{
columnName: string;
columnLabel: string;
dataType: string;
inputType?: string;
}>;
}>;
}
```
### 2.3 Entity 조인 컬럼 UI (ConfigPanel)
- **위치**: 기본 컬럼 선택 영역 아래, "Entity 조인 컬럼" 섹션
- **조건**: `entityJoinColumns.joinTables.length > 0` 일 때만 표시
- **구조**: joinTables별로 그룹화 → 각 그룹 내 availableColumns를 체크박스로 표시
- **추가 로직**: `addEntityColumn(joinColumn)` 호출
### 2.4 addEntityColumn 함수 (핵심)
```typescript
const addEntityColumn = (joinColumn: availableColumns[0]) => {
// joinTables에서 sourceColumn 추출 (필수!)
const joinTableInfo = entityJoinColumns.joinTables?.find(
(jt) => jt.tableName === joinColumn.tableName
);
const sourceColumn = joinTableInfo?.joinConfig?.sourceColumn || "";
const newColumn: ColumnConfig = {
columnName: joinColumn.joinAlias, // 예: dept_code_company_name
displayName: joinColumn.columnLabel,
// ...
isEntityJoin: false, // 조인 탭에서 추가한 컬럼은 엔티티 타입이 아님
additionalJoinInfo: {
sourceTable: config.selectedTable || screenTableName || "",
sourceColumn: sourceColumn, // dept_code
referenceTable: joinColumn.tableName, // dept_info
joinAlias: joinColumn.joinAlias, // dept_code_company_name
},
};
handleChange("columns", [...config.columns, newColumn]);
};
```
**주의**: `sourceColumn`은 반드시 `joinTableInfo.joinConfig.sourceColumn`에서 가져와야 합니다. `joinColumn`에는 없습니다.
---
## 3. additionalJoinInfo 데이터 구조
### 3.1 타입 정의 (types.ts)
```typescript
additionalJoinInfo?: {
sourceTable: string; // 기준 테이블 (예: user_info)
sourceColumn: string; // 기준 테이블 FK 컬럼 (예: dept_code)
referenceTable?: string; // 참조 테이블 (예: dept_info)
joinAlias: string; // 조인 결과 컬럼 별칭 (예: dept_code_company_name)
};
```
### 3.2 네이밍 규칙
- **joinAlias**: `${sourceColumn}_${referenceTable컬럼명}`
- 예: `dept_code` + `company_name``dept_code_company_name`
- 백엔드가 이 규칙으로 SELECT 시 alias를 생성하고, 응답 row에 `dept_code_company_name` 키로 값이 들어옴
---
## 4. 백엔드 API 호출 흐름
### 4.1 TableListComponent 데이터 로딩
```typescript
// 1. additionalJoinInfo가 있는 컬럼만 추출
const entityJoinColumns = (tableConfig.columns || [])
.filter((col) => col.additionalJoinInfo)
.map((col) => ({
sourceTable: col.additionalJoinInfo!.sourceTable,
sourceColumn: col.additionalJoinInfo!.sourceColumn,
joinAlias: col.additionalJoinInfo!.joinAlias,
referenceTable: col.additionalJoinInfo!.referenceTable,
}));
// 2. entityDisplayConfig가 있는 컬럼 (isEntityJoin) - 화면별 표시 설정
const screenEntityConfigs: Record<string, any> = {};
(tableConfig.columns || [])
.filter((col) => col.entityDisplayConfig?.displayColumns?.length > 0)
.forEach((col) => {
screenEntityConfigs[col.columnName] = {
displayColumns: col.entityDisplayConfig!.displayColumns,
separator: col.entityDisplayConfig!.separator || " - ",
sourceTable: col.entityDisplayConfig!.sourceTable || tableConfig.selectedTable,
joinTable: col.entityDisplayConfig!.joinTable,
};
});
// 3. API 호출
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
page, size, sortBy, sortOrder,
search: hasFilters ? filters : undefined,
enableEntityJoin: true,
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined,
dataFilter: tableConfig.dataFilter,
excludeFilter: excludeFilterParam,
});
```
### 4.2 entityJoinApi.getTableDataWithJoins 파라미터
```typescript
additionalJoinColumns?: Array<{
sourceTable: string;
sourceColumn: string;
joinAlias: string;
referenceTable?: string; // 백엔드에서 referenceTable로 기존 조인 찾을 때 사용
}>;
```
- **전달 방식**: `JSON.stringify(additionalJoinColumns)` 후 쿼리 파라미터로 전달
- **백엔드**: `entityJoinController``tableManagementService.getTableDataWithEntityJoins`
### 4.3 백엔드 처리 (tableManagementService)
1. `detectEntityJoins`로 기본 Entity 조인 설정 조회
2. `additionalJoinColumns`가 있으면:
- `sourceColumn` 또는 `referenceTable`로 기존 joinConfig 찾기
- `joinAlias`에서 실제 컬럼명 추출 (예: `dept_code_company_name``company_name`)
- 기존 config에 `displayColumns` 병합 또는 새 config 추가
- `aliasColumn`: `${sourceColumn}_${actualColumnName}` (예: `dept_code_company_name`)
3. `additionalJoinColumns`가 있으면 **full_join** 전략 강제 사용 (캐시 미사용)
---
## 5. 데이터 표시 시 조인 데이터 매핑
### 5.1 additionalJoinInfo 컬럼 (조인 탭에서 추가한 컬럼)
- **백엔드 응답**: row에 `joinAlias` 키로 값이 직접 들어옴
- 예: `row.dept_code_company_name = "개발팀"`
- **프론트엔드**: `column.columnName``joinAlias`와 동일하므로 `rowData[column.columnName]`으로 바로 접근
- **formatCellValue**: `entityDisplayConfig`가 없으면 일반 컬럼처럼 `value` 사용 (이미 row에 joinAlias로 들어있음)
### 5.2 entityDisplayConfig 컬럼 (isEntityJoin, 테이블 타입 관리에서 entity 설정된 컬럼)
- **formatCellValue** 로직:
```typescript
if (column.entityDisplayConfig && rowData) {
const displayColumns = column.entityDisplayConfig.displayColumns;
const separator = column.entityDisplayConfig.separator;
const values = displayColumns.map((colName) => {
const joinedKey = `${column.columnName}_${colName}`; // 예: manager_user_name
let cellValue = rowData[joinedKey];
if (cellValue == null) cellValue = rowData[colName];
return cellValue ?? "";
});
return values.filter(v => v !== "").join(separator || " - ");
}
```
- **백엔드 alias 규칙**: `${sourceColumn}_${displayColumn}` (예: `manager_user_name`)
### 5.3 joinedColumnMeta (inputType/category 매핑)
- additionalJoinInfo 컬럼도 `joinedColumnMeta`에 등록됨
- `actualColumn` 추출: `joinAlias.replace(\`${sourceColumn}_\`, "")` → 참조 테이블의 실제 컬럼명
- 조인 테이블별로 `tableTypeApi.getColumnInputTypes` 호출하여 inputType 로드
---
## 6. entity-join-columns API (ConfigPanel용)
- **엔드포인트**: `GET /api/table-management/tables/:tableName/entity-join-columns`
- **역할**: 화면 편집기에서 "Entity 조인 컬럼" 탭에 표시할 데이터 제공
- **응답**:
- `joinTables`: 각 Entity 조인별 `joinConfig`, `tableName`, `availableColumns`
- `availableColumns`: 모든 조인 컬럼을 flat하게 (joinAlias 포함)
- **joinConfig**: `entityJoinService.detectEntityJoins` 결과에서 옴 (테이블 타입 관리의 reference_table 설정 기반)
---
## 7. v2-repeater 적용 시 체크리스트
### ConfigPanel
- [ ] `entityJoinApi.getEntityJoinColumns(targetTableName)` 호출
- [ ] `entityJoinColumns` 상태 (availableColumns, joinTables)
- [ ] "Entity 조인 컬럼" UI 섹션 (joinTables.length > 0일 때)
- [ ] `addEntityColumn` 함수: `joinConfig.sourceColumn` 사용
- [ ] RepeaterColumnConfig에 `additionalJoinInfo` 타입 추가
### 데이터 로딩 (RepeaterComponent)
- [ ] `additionalJoinInfo`가 있는 컬럼 추출 → `entityJoinColumns` 배열 생성
- [ ] `entityJoinApi.getTableDataWithJoins` 호출 시 `additionalJoinColumns` 전달
- [ ] `entityDisplayConfig`가 있으면 `screenEntityConfigs`에도 포함 (isEntityJoin 컬럼용)
### 셀 렌더링
- [ ] additionalJoinInfo 컬럼: `rowData[column.columnName]` (joinAlias와 동일)
- [ ] entityDisplayConfig 컬럼: displayColumns + separator로 조합, `joinedKey = ${columnName}_${colName}`
### 타입 정의
- [ ] `RepeaterColumnConfig`에 `additionalJoinInfo?: { sourceTable, sourceColumn, referenceTable, joinAlias }` 추가
---
## 8. 참고 파일
| 파일 | 용도 |
|------|------|
| `frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx` | Entity 조인 UI, addEntityColumn |
| `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | 데이터 로딩, formatCellValue |
| `frontend/lib/registry/components/v2-table-list/types.ts` | additionalJoinInfo 타입 |
| `frontend/lib/api/entityJoin.ts` | getTableDataWithJoins, getEntityJoinColumns |
| `backend-node/src/controllers/entityJoinController.ts` | entity-join-columns, data-with-joins |
| `backend-node/src/services/tableManagementService.ts` | additionalJoinColumns 병합 로직 |

View File

@@ -89,6 +89,67 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
const onDataChangeRef = useRef(onDataChange);
onDataChangeRef.current = onDataChange;
// Entity 조인 설정을 ref로 보관 (이벤트 핸들러 closure에서 항상 최신값 참조)
const entityJoinsRef = useRef(config.entityJoins);
useEffect(() => {
entityJoinsRef.current = config.entityJoins;
}, [config.entityJoins]);
// Entity 조인 해석: FK 값을 기반으로 참조 테이블에서 표시 데이터를 가져와 행에 채움
const resolveEntityJoins = useCallback(async (rows: any[]): Promise<any[]> => {
const entityJoins = entityJoinsRef.current;
console.log("🔍 [V2Repeater] resolveEntityJoins 시작:", {
entityJoins,
rowCount: rows.length,
sampleRow: rows[0],
});
if (!entityJoins || entityJoins.length === 0) {
console.warn("⚠️ [V2Repeater] entityJoins 설정 없음 - 해석 스킵");
return rows;
}
const resolvedRows = rows.map((r) => ({ ...r }));
for (const join of entityJoins) {
const fkValues = [...new Set(resolvedRows.map((r) => r[join.sourceColumn]).filter(Boolean))];
console.log(`🔍 [V2Repeater] FK 값 추출: ${join.sourceColumn} → [${fkValues.join(", ")}]`);
if (fkValues.length === 0) continue;
try {
const response = await apiClient.post(`/table-management/tables/${join.referenceTable}/data`, {
page: 1,
size: fkValues.length + 10,
dataFilter: {
enabled: true,
filters: [{ columnName: "id", operator: "in", value: fkValues }],
},
autoFilter: true,
});
console.log(`🔍 [V2Repeater] API 응답:`, response.data);
const refData = response.data?.data?.data || response.data?.data?.rows || [];
const lookupMap = new Map(refData.map((r: any) => [String(r.id), r]));
resolvedRows.forEach((row) => {
const fkVal = String(row[join.sourceColumn] || "");
const refRecord = lookupMap.get(fkVal);
if (refRecord) {
join.columns.forEach((col) => {
row[col.displayField] = refRecord[col.referenceField];
});
}
});
console.log(`✅ [V2Repeater] Entity 조인 해석 완료: ${join.referenceTable} (${fkValues.length}건, 조회결과: ${refData.length}건)`);
} catch (error) {
console.error(`❌ [V2Repeater] Entity 조인 해석 실패: ${join.referenceTable}`, error);
}
}
return resolvedRows;
}, []);
const handleReceiveData = useCallback(
async (incomingData: any[], configOrMode?: any) => {
console.log("📥 [V2Repeater] 데이터 수신:", { count: incomingData?.length, configOrMode });
@@ -98,6 +159,9 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
return;
}
// mappingRules 처리: configOrMode에 mappingRules가 있으면 적용
const mappingRules = configOrMode?.mappingRules;
// 데이터 정규화: {0: {...}} 형태 처리 + 소스 테이블 메타 필드 제거
const metaFieldsToStrip = new Set([
"id",
@@ -107,12 +171,33 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
"updated_by",
"company_code",
]);
const normalizedData = incomingData.map((item: any) => {
let normalizedData = incomingData.map((item: any, index: number) => {
let raw = item;
if (item && typeof item === "object" && item[0] && typeof item[0] === "object") {
const { 0: originalData, ...additionalFields } = item;
raw = { ...originalData, ...additionalFields };
}
// mappingRules가 있으면 규칙에 따라 매핑 (필요한 필드만 추출)
if (mappingRules && mappingRules.length > 0) {
const mapped: Record<string, any> = { _id: `receive_${Date.now()}_${index}` };
for (const rule of mappingRules) {
mapped[rule.targetField] = raw[rule.sourceField];
}
// additionalSources에서 추가된 필드도 유지 (mappingRules에 없는 필드 중 메타가 아닌 것)
for (const [key, value] of Object.entries(raw)) {
if (!metaFieldsToStrip.has(key) && !(key in mapped) && !key.startsWith("_")) {
// 소스 테이블의 컬럼이 아닌 추가 데이터만 유지 (additionalSources 등)
const isMappingSource = mappingRules.some((r: any) => r.sourceField === key);
if (!isMappingSource) {
mapped[key] = value;
}
}
}
return mapped;
}
// mappingRules 없으면 기존 로직: 메타 필드만 제거
const cleaned: Record<string, any> = {};
for (const [key, value] of Object.entries(raw)) {
if (!metaFieldsToStrip.has(key)) {
@@ -122,10 +207,16 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
return cleaned;
});
console.log("📥 [V2Repeater] 매핑 후 데이터:", normalizedData);
// Entity 조인 해석 (FK → 참조 테이블 데이터)
normalizedData = await resolveEntityJoins(normalizedData);
console.log("📥 [V2Repeater] Entity 조인 후 데이터:", normalizedData);
const mode = configOrMode?.mode || configOrMode || "append";
// 카테고리 코드 → 라벨 변환
// allCategoryColumns 또는 fromMainForm 컬럼의 값을 라벨로 변환
const codesToResolve = new Set<string>();
for (const item of normalizedData) {
for (const [key, val] of Object.entries(item)) {
@@ -167,7 +258,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
toast.success(`${normalizedData.length}개 항목이 추가되었습니다.`);
},
[],
[resolveEntityJoins],
);
useEffect(() => {
@@ -1412,32 +1503,31 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}
// 데이터 매핑 처리
const mappedData = transferData.map((item: any, index: number) => {
let mappedData = transferData.map((item: any, index: number) => {
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
if (mappingRules && mappingRules.length > 0) {
// 매핑 규칙이 있으면 적용
mappingRules.forEach((rule: any) => {
newRow[rule.targetField] = item[rule.sourceField];
});
} else {
// 매핑 규칙 없으면 그대로 복사
Object.assign(newRow, item);
}
return newRow;
});
// Entity 조인 해석 (FK → 참조 테이블 데이터)
mappedData = await resolveEntityJoins(mappedData);
// mode에 따라 데이터 처리
if (mode === "replace") {
handleDataChange(mappedData);
} else if (mode === "merge") {
// 중복 제거 후 병합 (id 기준)
const existingIds = new Set(data.map((row) => row.id || row._id));
const newItems = mappedData.filter((row: any) => !existingIds.has(row.id || row._id));
handleDataChange([...data, ...newItems]);
} else {
// 기본: append
handleDataChange([...data, ...mappedData]);
}
};
@@ -1447,12 +1537,21 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
const customEvent = event as CustomEvent;
const { data: transferData, mappingRules, mode, sourcePosition } = customEvent.detail || {};
console.log("📨 [V2Repeater] splitPanelDataTransfer 수신:", {
dataCount: transferData?.length,
mappingRules,
mode,
sourcePosition,
sampleSourceData: transferData?.[0],
entityJoinsConfig: entityJoinsRef.current,
});
if (!transferData || transferData.length === 0) {
return;
}
// 데이터 매핑 처리
const mappedData = transferData.map((item: any, index: number) => {
let mappedData = transferData.map((item: any, index: number) => {
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
if (mappingRules && mappingRules.length > 0) {
@@ -1466,6 +1565,13 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
return newRow;
});
console.log("📨 [V2Repeater] 매핑 후 데이터:", mappedData);
// Entity 조인 해석 (FK → 참조 테이블 데이터)
mappedData = await resolveEntityJoins(mappedData);
console.log("📨 [V2Repeater] Entity 조인 후 데이터:", mappedData);
// mode에 따라 데이터 처리
if (mode === "replace") {
handleDataChange(mappedData);

View File

@@ -48,12 +48,14 @@ import {
} from "@/components/ui/popover";
import { tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { getAvailableNumberingRules, getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
import { NumberingRuleConfig } from "@/types/numbering-rule";
import { cn } from "@/lib/utils";
import {
V2RepeaterConfig,
RepeaterColumnConfig,
RepeaterEntityJoin,
DEFAULT_REPEATER_CONFIG,
RENDER_MODE_OPTIONS,
MODAL_SIZE_OPTIONS,
@@ -151,6 +153,28 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
const [loadingRelations, setLoadingRelations] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false); // 테이블 Combobox 열림 상태
// Entity 조인 관련 상태
const [entityJoinData, setEntityJoinData] = useState<{
joinTables: Array<{
tableName: string;
currentDisplayColumn: string;
joinConfig?: { sourceColumn?: string };
availableColumns: Array<{
columnName: string;
columnLabel: string;
dataType: string;
inputType?: string;
}>;
}>;
availableColumns: Array<{
tableName: string;
columnName: string;
columnLabel: string;
joinAlias: string;
}>;
}>({ joinTables: [], availableColumns: [] });
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
// 🆕 확장된 컬럼 (상세 설정 표시용)
const [expandedColumn, setExpandedColumn] = useState<string | null>(null);
@@ -316,6 +340,89 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
loadRelatedTables();
}, [currentTableName, config.mainTableName]);
// Entity 조인 컬럼 정보 로드 (저장 테이블 기준)
const entityJoinTargetTable = config.useCustomTable && config.mainTableName
? config.mainTableName
: currentTableName;
useEffect(() => {
const fetchEntityJoinColumns = async () => {
if (!entityJoinTargetTable) return;
setLoadingEntityJoins(true);
try {
const result = await entityJoinApi.getEntityJoinColumns(entityJoinTargetTable);
setEntityJoinData({
joinTables: result.joinTables || [],
availableColumns: result.availableColumns || [],
});
} catch (error) {
console.error("Entity 조인 컬럼 조회 오류:", error);
setEntityJoinData({ joinTables: [], availableColumns: [] });
} finally {
setLoadingEntityJoins(false);
}
};
fetchEntityJoinColumns();
}, [entityJoinTargetTable]);
// Entity 조인 컬럼 토글 (추가/제거)
const toggleEntityJoinColumn = useCallback(
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => {
const currentJoins = config.entityJoins || [];
const existingJoinIdx = currentJoins.findIndex(
(j) => j.sourceColumn === sourceColumn && j.referenceTable === joinTableName,
);
if (existingJoinIdx >= 0) {
const existingJoin = currentJoins[existingJoinIdx];
const existingColIdx = existingJoin.columns.findIndex((c) => c.referenceField === refColumnName);
if (existingColIdx >= 0) {
const updatedColumns = existingJoin.columns.filter((_, i) => i !== existingColIdx);
if (updatedColumns.length === 0) {
updateConfig({ entityJoins: currentJoins.filter((_, i) => i !== existingJoinIdx) });
} else {
const updated = [...currentJoins];
updated[existingJoinIdx] = { ...existingJoin, columns: updatedColumns };
updateConfig({ entityJoins: updated });
}
} else {
const updated = [...currentJoins];
updated[existingJoinIdx] = {
...existingJoin,
columns: [...existingJoin.columns, { referenceField: refColumnName, displayField }],
};
updateConfig({ entityJoins: updated });
}
} else {
updateConfig({
entityJoins: [
...currentJoins,
{
sourceColumn,
referenceTable: joinTableName,
columns: [{ referenceField: refColumnName, displayField }],
},
],
});
}
},
[config.entityJoins, updateConfig],
);
// Entity 조인에 특정 컬럼이 설정되어 있는지 확인
const isEntityJoinColumnActive = useCallback(
(joinTableName: string, sourceColumn: string, refColumnName: string) => {
return (config.entityJoins || []).some(
(j) =>
j.sourceColumn === sourceColumn &&
j.referenceTable === joinTableName &&
j.columns.some((c) => c.referenceField === refColumnName),
);
},
[config.entityJoins],
);
// 설정 업데이트 헬퍼
const updateConfig = useCallback(
(updates: Partial<V2RepeaterConfig>) => {
@@ -654,9 +761,10 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
return (
<div className="space-y-4">
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="basic" className="text-xs"></TabsTrigger>
<TabsTrigger value="columns" className="text-xs"></TabsTrigger>
<TabsTrigger value="entityJoin" className="text-xs">Entity </TabsTrigger>
</TabsList>
{/* 기본 설정 탭 */}
@@ -1704,6 +1812,120 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
)}
</TabsContent>
{/* Entity 조인 설정 탭 */}
<TabsContent value="entityJoin" className="mt-4 space-y-4">
<div className="space-y-2">
<div>
<h3 className="text-sm font-semibold">Entity </h3>
<p className="text-muted-foreground text-[10px]">
FK
</p>
</div>
<hr className="border-border" />
{loadingEntityJoins ? (
<p className="text-muted-foreground py-2 text-center text-xs"> ...</p>
) : entityJoinData.joinTables.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center">
<p className="text-muted-foreground text-xs">
{entityJoinTargetTable
? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다`
: "저장 테이블을 먼저 설정해주세요"}
</p>
</div>
) : (
<div className="space-y-3">
{entityJoinData.joinTables.map((joinTable, tableIndex) => {
const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || "";
return (
<div key={tableIndex} className="space-y-1">
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-blue-600">
<Link2 className="h-3 w-3" />
<span>{joinTable.tableName}</span>
<span className="text-muted-foreground">({sourceColumn})</span>
</div>
<div className="max-h-40 space-y-0.5 overflow-y-auto rounded-md border border-blue-200 bg-blue-50/30 p-2">
{joinTable.availableColumns.map((column, colIndex) => {
const isActive = isEntityJoinColumnActive(
joinTable.tableName,
sourceColumn,
column.columnName,
);
const matchingCol = config.columns.find((c) => c.key === column.columnName);
const displayField = matchingCol?.key || column.columnName;
return (
<div
key={colIndex}
className={cn(
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-blue-100/50",
isActive && "bg-blue-100",
)}
onClick={() =>
toggleEntityJoinColumn(
joinTable.tableName,
sourceColumn,
column.columnName,
column.columnLabel,
displayField,
)
}
>
<Checkbox
checked={isActive}
className="pointer-events-none h-3.5 w-3.5"
/>
<Link2 className="h-3 w-3 flex-shrink-0 text-blue-500" />
<span className="truncate text-xs">{column.columnLabel}</span>
<span className="ml-auto text-[10px] text-blue-400">
{column.inputType || column.dataType}
</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
{/* 현재 설정된 Entity 조인 목록 */}
{config.entityJoins && config.entityJoins.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium"> </h4>
<div className="space-y-1">
{config.entityJoins.map((join, idx) => (
<div key={idx} className="flex items-center gap-1 rounded border bg-muted/30 px-2 py-1 text-[10px]">
<Database className="h-3 w-3 text-blue-500" />
<span className="font-medium">{join.sourceColumn}</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span>{join.referenceTable}</span>
<span className="text-muted-foreground">
({join.columns.map((c) => c.referenceField).join(", ")})
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
updateConfig({
entityJoins: config.entityJoins!.filter((_, i) => i !== idx),
});
}}
className="ml-auto h-4 w-4 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
);

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 {

View File

@@ -2339,9 +2339,12 @@ export class ButtonActionExecutor {
}
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
// 🔧 수정 모드 체크: formData.id 또는 originalGroupedData가 있으면 UPDATE 모드
// 🔧 수정 모드 체크: _mainRecordId, formData.id 또는 originalGroupedData가 있으면 UPDATE 모드
const parentMainRecordId = modalData._mainRecordId || formData._mainRecordId;
const isEditModeUniversal =
(formData.id !== undefined && formData.id !== null && formData.id !== "") || originalGroupedData.length > 0;
(parentMainRecordId !== undefined && parentMainRecordId !== null && parentMainRecordId !== "") ||
(formData.id !== undefined && formData.id !== null && formData.id !== "") ||
originalGroupedData.length > 0;
const fieldsWithNumbering: Record<string, string> = {};
@@ -2417,6 +2420,13 @@ export class ButtonActionExecutor {
);
if (hasSeparateTargetTable && Object.keys(commonFieldsData).length > 0) {
// _mainRecordId: 분할패널에서 전달한 실제 메인 레코드 ID (formData.id는 디테일 레코드 ID일 수 있음)
const mainRecordIdFromParent =
modalData._mainRecordId || formData._mainRecordId || commonFieldsData._mainRecordId;
// 메인 테이블 ID 결정: _mainRecordId > formData.id 순서로 탐색
const existingMainId = mainRecordIdFromParent || formData.id;
const isMainUpdate = existingMainId !== undefined && existingMainId !== null && existingMainId !== "";
const mainRowToSave = { ...commonFieldsData, ...userInfo };
// 메타데이터 제거
@@ -2426,24 +2436,15 @@ export class ButtonActionExecutor {
}
});
// 🆕 메인 테이블 UPDATE/INSERT 판단
// - formData.id가 있으면 편집 모드 → UPDATE
// - formData.id가 없으면 신규 등록 → INSERT
const existingMainId = formData.id;
const isMainUpdate = existingMainId !== undefined && existingMainId !== null && existingMainId !== "";
let mainSaveResult: { success: boolean; data?: any; message?: string };
if (isMainUpdate) {
// 🔄 편집 모드: UPDATE 실행
mainSaveResult = await DynamicFormApi.updateFormData(existingMainId, {
tableName: tableName!,
data: mainRowToSave,
});
mainRecordId = existingMainId;
} else {
// 신규 등록: INSERT 실행
console.log(" [handleUniversalFormModalTableSectionSave] 메인 테이블 INSERT 실행");
mainSaveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
@@ -2471,8 +2472,32 @@ export class ButtonActionExecutor {
// 1⃣ 신규 품목 INSERT (id가 없는 항목)
const newItems = currentItems.filter((item) => !item.id);
const existingItemsForRef = currentItems.filter((item) => item.id);
// 기존 DB 항목에서 공통 필드 추출 (수정 모달에서 commonFieldsData에 누락된 필드 보완)
// 예: item_code, item_name 등이 commonFieldsData에 없으면 기존 항목에서 가져옴
const sharedFieldsFromExisting: Record<string, any> = {};
if (existingItemsForRef.length > 0 && newItems.length > 0) {
const refItem = existingItemsForRef[0];
for (const [key, val] of Object.entries(refItem)) {
if (
key !== "id" &&
!key.startsWith("_") &&
val !== undefined &&
val !== null &&
val !== "" &&
commonFieldsData[key] === undefined
) {
sharedFieldsFromExisting[key] = val;
}
}
if (Object.keys(sharedFieldsFromExisting).length > 0) {
console.log("📋 [INSERT] 기존 항목에서 공통 필드 보완:", Object.keys(sharedFieldsFromExisting));
}
}
for (const item of newItems) {
const rowToSave = { ...commonFieldsData, ...item, ...userInfo };
const rowToSave = { ...sharedFieldsFromExisting, ...commonFieldsData, ...item, ...userInfo };
Object.keys(rowToSave).forEach((key) => {
if (key.startsWith("_")) {
@@ -6484,6 +6509,12 @@ export class ButtonActionExecutor {
return true;
} else {
// 기본: 분할 패널 데이터 전달 이벤트
console.log("📤 [transferData] splitPanelDataTransfer 발송:", {
rowCount: selectedRows.length,
mappingRules,
sampleRow: selectedRows[0],
hasItemId: selectedRows[0]?.item_id,
});
const transferEvent = new CustomEvent("splitPanelDataTransfer", {
detail: {

View File

@@ -142,6 +142,16 @@ export interface CalculationRule {
label?: string;
}
// Entity 조인 설정 (리피터 컬럼의 FK를 참조 테이블과 조인하여 표시)
export interface RepeaterEntityJoin {
sourceColumn: string; // FK 컬럼 (예: "item_id")
referenceTable: string; // 참조 테이블 (예: "item_info")
columns: Array<{
referenceField: string; // 참조 테이블 컬럼 (예: "item_name")
displayField: string; // 리피터 표시 컬럼 키 (예: "item_name")
}>;
}
// 소스 디테일 설정 (모달에서 전달받은 마스터 데이터의 디테일을 자동 조회)
export interface SourceDetailConfig {
tableName: string; // 디테일 테이블명 (예: "sales_order_detail")
@@ -185,6 +195,9 @@ export interface V2RepeaterConfig {
// 모달 설정 (modal, mixed 모드)
modal?: RepeaterModalConfig;
// Entity 조인 설정 (FK 기반으로 참조 테이블 데이터를 자동 해석하여 표시)
entityJoins?: RepeaterEntityJoin[];
// 기능 옵션
features: RepeaterFeatureOptions;