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:
263
docs/v2-table-list-entity-join-analysis.md
Normal file
263
docs/v2-table-list-entity-join-analysis.md
Normal 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 병합 로직 |
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user