diff --git a/docs/v2-table-list-entity-join-analysis.md b/docs/v2-table-list-entity-join-analysis.md new file mode 100644 index 00000000..c8ab1ca8 --- /dev/null +++ b/docs/v2-table-list-entity-join-analysis.md @@ -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 = {}; +(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 병합 로직 | diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index f6f1fc6b..7bf7e6fa 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -89,6 +89,67 @@ export const V2Repeater: React.FC = ({ 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 => { + 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 = ({ return; } + // mappingRules 처리: configOrMode에 mappingRules가 있으면 적용 + const mappingRules = configOrMode?.mappingRules; + // 데이터 정규화: {0: {...}} 형태 처리 + 소스 테이블 메타 필드 제거 const metaFieldsToStrip = new Set([ "id", @@ -107,12 +171,33 @@ export const V2Repeater: React.FC = ({ "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 = { _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 = {}; for (const [key, value] of Object.entries(raw)) { if (!metaFieldsToStrip.has(key)) { @@ -122,10 +207,16 @@ export const V2Repeater: React.FC = ({ 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(); for (const item of normalizedData) { for (const [key, val] of Object.entries(item)) { @@ -167,7 +258,7 @@ export const V2Repeater: React.FC = ({ toast.success(`${normalizedData.length}개 항목이 추가되었습니다.`); }, - [], + [resolveEntityJoins], ); useEffect(() => { @@ -1412,32 +1503,31 @@ export const V2Repeater: React.FC = ({ } // 데이터 매핑 처리 - 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 = ({ 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 = ({ return newRow; }); + console.log("📨 [V2Repeater] 매핑 후 데이터:", mappedData); + + // Entity 조인 해석 (FK → 참조 테이블 데이터) + mappedData = await resolveEntityJoins(mappedData); + + console.log("📨 [V2Repeater] Entity 조인 후 데이터:", mappedData); + // mode에 따라 데이터 처리 if (mode === "replace") { handleDataChange(mappedData); diff --git a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx index 66f0f18b..877b1523 100644 --- a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx @@ -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 = ({ 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(null); @@ -316,6 +340,89 @@ export const V2RepeaterConfigPanel: React.FC = ({ 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) => { @@ -654,9 +761,10 @@ export const V2RepeaterConfigPanel: React.FC = ({ return (
- + 기본 컬럼 + Entity 조인 {/* 기본 설정 탭 */} @@ -1704,6 +1812,120 @@ export const V2RepeaterConfigPanel: React.FC = ({ )} + {/* Entity 조인 설정 탭 */} + +
+
+

Entity 조인 연결

+

+ FK 컬럼을 기반으로 참조 테이블의 데이터를 자동으로 조회하여 표시합니다 +

+
+
+ + {loadingEntityJoins ? ( +

로딩 중...

+ ) : entityJoinData.joinTables.length === 0 ? ( +
+

+ {entityJoinTargetTable + ? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다` + : "저장 테이블을 먼저 설정해주세요"} +

+
+ ) : ( +
+ {entityJoinData.joinTables.map((joinTable, tableIndex) => { + const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || ""; + + return ( +
+
+ + {joinTable.tableName} + ({sourceColumn}) +
+
+ {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 ( +
+ toggleEntityJoinColumn( + joinTable.tableName, + sourceColumn, + column.columnName, + column.columnLabel, + displayField, + ) + } + > + + + {column.columnLabel} + + {column.inputType || column.dataType} + +
+ ); + })} +
+
+ ); + })} +
+ )} + + {/* 현재 설정된 Entity 조인 목록 */} + {config.entityJoins && config.entityJoins.length > 0 && ( +
+

설정된 조인

+
+ {config.entityJoins.map((join, idx) => ( +
+ + {join.sourceColumn} + + {join.referenceTable} + + ({join.columns.map((c) => c.referenceField).join(", ")}) + + +
+ ))} +
+
+ )} +
+
+
); diff --git a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx index c101d286..f5efbba8 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx @@ -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; + }); }; // 이미 추가된 항목 제외한 결과 필터링 diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index b53a3def..f3ed2145 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -642,6 +642,12 @@ export const SplitPanelLayout2Component: React.FC { if (selectedLeftItem) { diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx index 591795dc..8f9cf859 100644 --- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -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 = {}; + 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 = {}; + 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} diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 7e91d8b9..94149e4f 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -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); diff --git a/frontend/lib/registry/components/v2-repeater/V2RepeaterRenderer.tsx b/frontend/lib/registry/components/v2-repeater/V2RepeaterRenderer.tsx index a756cf6c..7eac3d83 100644 --- a/frontend/lib/registry/components/v2-repeater/V2RepeaterRenderer.tsx +++ b/frontend/lib/registry/components/v2-repeater/V2RepeaterRenderer.tsx @@ -44,9 +44,11 @@ const V2RepeaterRenderer: React.FC = ({ 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 { diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 37d506dc..c4604920 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -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 = {}; @@ -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 = {}; + 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: { diff --git a/frontend/types/v2-repeater.ts b/frontend/types/v2-repeater.ts index 2d9199b4..7a31e948 100644 --- a/frontend/types/v2-repeater.ts +++ b/frontend/types/v2-repeater.ts @@ -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;