diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index 16b6aa49..5e4cdbaf 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -187,6 +187,69 @@ router.post( } ); +/** + * 마스터-디테일 간단 모드 엑셀 업로드 + * - 마스터 정보는 UI에서 선택 + * - 디테일 정보만 엑셀에서 업로드 + * - 채번 규칙을 통해 마스터 키 자동 생성 + * + * POST /api/data/master-detail/upload-simple + */ +router.post( + "/master-detail/upload-simple", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { screenId, detailData, masterFieldValues, numberingRuleId } = req.body; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + if (!screenId || !detailData || !Array.isArray(detailData)) { + return res.status(400).json({ + success: false, + message: "screenId와 detailData 배열이 필요합니다.", + }); + } + + console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`); + console.log(` 마스터 필드 값:`, masterFieldValues); + console.log(` 채번 규칙 ID:`, numberingRuleId); + + // 업로드 실행 + const result = await masterDetailExcelService.uploadSimple( + parseInt(screenId), + detailData, + masterFieldValues || {}, + numberingRuleId, + companyCode, + userId + ); + + console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, { + masterInserted: result.masterInserted, + detailInserted: result.detailInserted, + generatedKey: result.generatedKey, + errors: result.errors.length, + }); + + return res.json({ + success: result.success, + data: result, + message: result.success + ? `마스터 1건(${result.generatedKey}), 디테일 ${result.detailInserted}건 처리되었습니다.` + : "업로드 중 오류가 발생했습니다.", + }); + } catch (error: any) { + console.error("마스터-디테일 간단 모드 업로드 오류:", error); + return res.status(500).json({ + success: false, + message: "마스터-디테일 업로드 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + // ================================ // 기존 데이터 API // ================================ diff --git a/backend-node/src/services/masterDetailExcelService.ts b/backend-node/src/services/masterDetailExcelService.ts index dbb129c9..6a267765 100644 --- a/backend-node/src/services/masterDetailExcelService.ts +++ b/backend-node/src/services/masterDetailExcelService.ts @@ -283,10 +283,81 @@ class MasterDetailExcelService { try { const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation; + // 조인 컬럼과 일반 컬럼 분리 + // 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name) + const entityJoins: Array<{ + refTable: string; + refColumn: string; + sourceColumn: string; + alias: string; + displayColumn: string; + }> = []; + // SELECT 절 구성 - const masterSelectCols = masterColumns.map(col => `m."${col.name}"`); - const detailSelectCols = detailColumns.map(col => `d."${col.name}"`); - const selectClause = [...masterSelectCols, ...detailSelectCols].join(", "); + const selectParts: string[] = []; + let aliasIndex = 0; + + // 마스터 컬럼 처리 + for (const col of masterColumns) { + if (col.name.includes(".")) { + // 조인 컬럼: 테이블명.컬럼명 + const [refTable, displayColumn] = col.name.split("."); + const alias = `ej${aliasIndex++}`; + + // column_labels에서 FK 컬럼 찾기 + const fkColumn = await this.findForeignKeyColumn(masterTable, refTable); + if (fkColumn) { + entityJoins.push({ + refTable, + refColumn: fkColumn.referenceColumn, + sourceColumn: fkColumn.sourceColumn, + alias, + displayColumn, + }); + selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); + } else { + // FK를 못 찾으면 NULL로 처리 + selectParts.push(`NULL AS "${col.name}"`); + } + } else { + // 일반 컬럼 + selectParts.push(`m."${col.name}"`); + } + } + + // 디테일 컬럼 처리 + for (const col of detailColumns) { + if (col.name.includes(".")) { + // 조인 컬럼: 테이블명.컬럼명 + const [refTable, displayColumn] = col.name.split("."); + const alias = `ej${aliasIndex++}`; + + // column_labels에서 FK 컬럼 찾기 + const fkColumn = await this.findForeignKeyColumn(detailTable, refTable); + if (fkColumn) { + entityJoins.push({ + refTable, + refColumn: fkColumn.referenceColumn, + sourceColumn: fkColumn.sourceColumn, + alias, + displayColumn, + }); + selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); + } else { + selectParts.push(`NULL AS "${col.name}"`); + } + } else { + // 일반 컬럼 + selectParts.push(`d."${col.name}"`); + } + } + + const selectClause = selectParts.join(", "); + + // 엔티티 조인 절 구성 + const entityJoinClauses = entityJoins.map(ej => + `LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"` + ).join("\n "); // WHERE 절 구성 const whereConditions: string[] = []; @@ -304,6 +375,8 @@ class MasterDetailExcelService { if (filters) { for (const [key, value] of Object.entries(filters)) { if (value !== undefined && value !== null && value !== "") { + // 조인 컬럼인지 확인 + if (key.includes(".")) continue; // 마스터 테이블 컬럼인지 확인 const isMasterCol = masterColumns.some(c => c.name === key); const tableAlias = isMasterCol ? "m" : "d"; @@ -325,6 +398,7 @@ class MasterDetailExcelService { LEFT JOIN "${detailTable}" d ON m."${masterKeyColumn}" = d."${detailFkColumn}" AND m.company_code = d.company_code + ${entityJoinClauses} ${whereClause} ORDER BY m."${masterKeyColumn}", d.id `; @@ -353,6 +427,37 @@ class MasterDetailExcelService { } } + /** + * 특정 테이블에서 참조 테이블로의 FK 컬럼 찾기 + */ + private async findForeignKeyColumn( + sourceTable: string, + referenceTable: string + ): Promise<{ sourceColumn: string; referenceColumn: string } | null> { + try { + const result = await query<{ column_name: string; reference_column: string }>( + `SELECT column_name, reference_column + FROM column_labels + WHERE table_name = $1 + AND reference_table = $2 + AND input_type = 'entity' + LIMIT 1`, + [sourceTable, referenceTable] + ); + + if (result.length > 0) { + return { + sourceColumn: result[0].column_name, + referenceColumn: result[0].reference_column, + }; + } + return null; + } catch (error) { + logger.error(`FK 컬럼 조회 실패: ${sourceTable} -> ${referenceTable}`, error); + return null; + } + } + /** * 마스터-디테일 데이터 업로드 (엑셀 업로드용) * @@ -521,6 +626,168 @@ class MasterDetailExcelService { return result; } + + /** + * 마스터-디테일 간단 모드 업로드 + * + * 마스터 정보는 UI에서 선택하고, 엑셀은 디테일 데이터만 포함 + * 채번 규칙을 통해 마스터 키 자동 생성 + * + * @param screenId 화면 ID + * @param detailData 디테일 데이터 배열 + * @param masterFieldValues UI에서 선택한 마스터 필드 값 + * @param numberingRuleId 채번 규칙 ID (optional) + * @param companyCode 회사 코드 + * @param userId 사용자 ID + */ + async uploadSimple( + screenId: number, + detailData: Record[], + masterFieldValues: Record, + numberingRuleId: string | undefined, + companyCode: string, + userId: string + ): Promise<{ + success: boolean; + masterInserted: number; + detailInserted: number; + generatedKey: string; + errors: string[]; + }> { + const result = { + success: false, + masterInserted: 0, + detailInserted: 0, + generatedKey: "", + errors: [] as string[], + }; + + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 1. 마스터-디테일 관계 정보 조회 + const relation = await this.getMasterDetailRelation(screenId); + if (!relation) { + throw new Error("마스터-디테일 관계 정보를 찾을 수 없습니다."); + } + + const { masterTable, detailTable, masterKeyColumn, detailFkColumn } = relation; + + // 2. 채번 처리 + let generatedKey: string; + + if (numberingRuleId) { + // 채번 규칙으로 키 생성 + generatedKey = await this.generateNumberWithRule(client, numberingRuleId, companyCode); + } else { + // 채번 규칙 없으면 마스터 필드에서 키 값 사용 + generatedKey = masterFieldValues[masterKeyColumn]; + if (!generatedKey) { + throw new Error(`마스터 키(${masterKeyColumn}) 값이 필요합니다.`); + } + } + + result.generatedKey = generatedKey; + logger.info(`채번 결과: ${generatedKey}`); + + // 3. 마스터 레코드 생성 + const masterData: Record = { + ...masterFieldValues, + [masterKeyColumn]: generatedKey, + company_code: companyCode, + writer: userId, + }; + + // 마스터 컬럼명 목록 구성 + const masterCols = Object.keys(masterData).filter(k => masterData[k] !== undefined); + const masterPlaceholders = masterCols.map((_, i) => `$${i + 1}`); + const masterValues = masterCols.map(k => masterData[k]); + + await client.query( + `INSERT INTO "${masterTable}" (${masterCols.map(c => `"${c}"`).join(", ")}, created_date) + VALUES (${masterPlaceholders.join(", ")}, NOW())`, + masterValues + ); + result.masterInserted = 1; + logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`); + + // 4. 디테일 레코드들 생성 + for (const row of detailData) { + try { + const detailRowData: Record = { + ...row, + [detailFkColumn]: generatedKey, + company_code: companyCode, + writer: userId, + }; + + // 빈 값 필터링 및 id 제외 + const detailCols = Object.keys(detailRowData).filter(k => + k !== "id" && + detailRowData[k] !== undefined && + detailRowData[k] !== null && + detailRowData[k] !== "" + ); + const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`); + const detailValues = detailCols.map(k => detailRowData[k]); + + await client.query( + `INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date) + VALUES (${detailPlaceholders.join(", ")}, NOW())`, + detailValues + ); + result.detailInserted++; + } catch (error: any) { + result.errors.push(`디테일 행 처리 실패: ${error.message}`); + logger.error(`디테일 행 처리 실패:`, error); + } + } + + await client.query("COMMIT"); + result.success = result.errors.length === 0 || result.detailInserted > 0; + + logger.info(`마스터-디테일 간단 모드 업로드 완료:`, { + masterInserted: result.masterInserted, + detailInserted: result.detailInserted, + generatedKey: result.generatedKey, + errors: result.errors.length, + }); + + } catch (error: any) { + await client.query("ROLLBACK"); + result.errors.push(`트랜잭션 실패: ${error.message}`); + logger.error(`마스터-디테일 간단 모드 업로드 실패:`, error); + } finally { + client.release(); + } + + return result; + } + + /** + * 채번 규칙으로 번호 생성 (기존 numberingRuleService 사용) + */ + private async generateNumberWithRule( + client: any, + ruleId: string, + companyCode: string + ): Promise { + try { + // 기존 numberingRuleService를 사용하여 코드 할당 + const { numberingRuleService } = await import("./numberingRuleService"); + const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode); + + logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`); + + return generatedCode; + } catch (error: any) { + logger.error(`채번 생성 실패: rule=${ruleId}, error=${error.message}`); + throw error; + } + } } export const masterDetailExcelService = new MasterDetailExcelService(); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 7df10fdb..b21a7c61 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2761,33 +2761,64 @@ export class TableManagementService { ); for (const additionalColumn of options.additionalJoinColumns) { - // 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기) - const baseJoinConfig = joinConfigs.find( + // 🔍 1차: sourceColumn을 기준으로 기존 조인 설정 찾기 + let baseJoinConfig = joinConfigs.find( (config) => config.sourceColumn === additionalColumn.sourceColumn ); + // 🔍 2차: referenceTable을 기준으로 찾기 (프론트엔드가 customer_mng.customer_name 같은 형식을 요청할 때) + // 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응 + if (!baseJoinConfig && (additionalColumn as any).referenceTable) { + baseJoinConfig = joinConfigs.find( + (config) => config.referenceTable === (additionalColumn as any).referenceTable + ); + if (baseJoinConfig) { + logger.info(`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable} → ${baseJoinConfig.sourceColumn}`); + } + } + if (baseJoinConfig) { - // joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name) - // sourceColumn을 제거한 나머지 부분이 실제 컬럼명 - const sourceColumn = baseJoinConfig.sourceColumn; // dept_code - const joinAlias = additionalColumn.joinAlias; // dept_code_company_name - const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name + // joinAlias에서 실제 컬럼명 추출 + const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id) + const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name) + + // 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리 + // customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거) + // 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거) + let actualColumnName: string; + + // 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출 + const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id) + if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) { + // 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거 + actualColumnName = originalJoinAlias.replace(`${frontendSourceColumn}_`, ""); + } else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) { + // 실제 소스 컬럼으로 시작하면 그 부분 제거 + actualColumnName = originalJoinAlias.replace(`${sourceColumn}_`, ""); + } else { + // 어느 것도 아니면 원본 사용 + actualColumnName = originalJoinAlias; + } + + // 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반) + const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`; logger.info(`🔍 조인 컬럼 상세 분석:`, { sourceColumn, - joinAlias, + frontendSourceColumn, + originalJoinAlias, + correctedJoinAlias, actualColumnName, - referenceTable: additionalColumn.sourceTable, + referenceTable: (additionalColumn as any).referenceTable, }); // 🚨 기본 Entity 조인과 중복되지 않도록 체크 const isBasicEntityJoin = - additionalColumn.joinAlias === - `${baseJoinConfig.sourceColumn}_name`; + correctedJoinAlias === `${sourceColumn}_name`; if (isBasicEntityJoin) { logger.info( - `⚠️ 기본 Entity 조인과 중복: ${additionalColumn.joinAlias} - 건너뜀` + `⚠️ 기본 Entity 조인과 중복: ${correctedJoinAlias} - 건너뜀` ); continue; // 기본 Entity 조인과 중복되면 추가하지 않음 } @@ -2795,14 +2826,14 @@ export class TableManagementService { // 추가 조인 컬럼 설정 생성 const additionalJoinConfig: EntityJoinConfig = { sourceTable: tableName, - sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code) + sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id) referenceTable: (additionalColumn as any).referenceTable || - baseJoinConfig.referenceTable, // 참조 테이블 (dept_info) - referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code) - displayColumns: [actualColumnName], // 표시할 컬럼들 (company_name) + baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng) + referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code) + displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name) displayColumn: actualColumnName, // 하위 호환성 - aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_company_name) + aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name) separator: " - ", // 기본 구분자 }; diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 07058fac..f4b0056e 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -34,6 +34,35 @@ import { cn } from "@/lib/utils"; import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping"; import { EditableSpreadsheet } from "./EditableSpreadsheet"; +// 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정) +export interface MasterDetailExcelConfig { + // 테이블 정보 + masterTable?: string; + detailTable?: string; + masterKeyColumn?: string; + detailFkColumn?: string; + // 채번 + numberingRuleId?: string; + // 업로드 전 사용자가 선택할 마스터 테이블 필드 + masterSelectFields?: Array<{ + columnName: string; + columnLabel: string; + required: boolean; + inputType: "entity" | "date" | "text" | "select"; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; + }>; + // 엑셀에서 매핑할 디테일 테이블 필드 + detailExcelFields?: Array<{ + columnName: string; + columnLabel: string; + required: boolean; + }>; + masterDefaults?: Record; + detailDefaults?: Record; +} + export interface ExcelUploadModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -53,6 +82,8 @@ export interface ExcelUploadModalProps { masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>; detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>; }; + // 🆕 마스터-디테일 엑셀 업로드 설정 + masterDetailExcelConfig?: MasterDetailExcelConfig; } interface ColumnMapping { @@ -71,6 +102,7 @@ export const ExcelUploadModal: React.FC = ({ screenId, isMasterDetail = false, masterDetailRelation, + masterDetailExcelConfig, }) => { const [currentStep, setCurrentStep] = useState(1); @@ -93,6 +125,116 @@ export const ExcelUploadModal: React.FC = ({ // 3단계: 확인 const [isUploading, setIsUploading] = useState(false); + // 🆕 마스터-디테일 모드: 마스터 필드 입력값 + const [masterFieldValues, setMasterFieldValues] = useState>({}); + const [entitySearchData, setEntitySearchData] = useState>({}); + const [entitySearchLoading, setEntitySearchLoading] = useState>({}); + const [entityDisplayColumns, setEntityDisplayColumns] = useState>({}); + + // 🆕 엔티티 참조 데이터 로드 + useEffect(() => { + console.log("🔍 엔티티 데이터 로드 체크:", { + masterSelectFields: masterDetailExcelConfig?.masterSelectFields, + open, + isMasterDetail, + }); + + if (!masterDetailExcelConfig?.masterSelectFields) return; + + const loadEntityData = async () => { + const { apiClient } = await import("@/lib/api/client"); + const { DynamicFormApi } = await import("@/lib/api/dynamicForm"); + + for (const field of masterDetailExcelConfig.masterSelectFields!) { + console.log("🔍 필드 처리:", field); + + if (field.inputType === "entity") { + setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: true })); + try { + let refTable = field.referenceTable; + console.log("🔍 초기 refTable:", refTable); + + let displayCol = field.displayColumn; + + // referenceTable 또는 displayColumn이 없으면 DB에서 동적으로 조회 + if ((!refTable || !displayCol) && masterDetailExcelConfig.masterTable) { + console.log("🔍 DB에서 referenceTable/displayColumn 조회 시도:", masterDetailExcelConfig.masterTable); + const colResponse = await apiClient.get( + `/table-management/tables/${masterDetailExcelConfig.masterTable}/columns` + ); + console.log("🔍 컬럼 조회 응답:", colResponse.data); + + if (colResponse.data?.success && colResponse.data?.data?.columns) { + const colInfo = colResponse.data.data.columns.find( + (c: any) => (c.columnName || c.column_name) === field.columnName + ); + console.log("🔍 찾은 컬럼 정보:", colInfo); + if (colInfo) { + if (!refTable) { + refTable = colInfo.referenceTable || colInfo.reference_table; + console.log("🔍 DB에서 가져온 refTable:", refTable); + } + if (!displayCol) { + displayCol = colInfo.displayColumn || colInfo.display_column; + console.log("🔍 DB에서 가져온 displayColumn:", displayCol); + } + } + } + } + + // displayColumn 저장 (Select 렌더링 시 사용) + if (displayCol) { + setEntityDisplayColumns((prev) => ({ ...prev, [field.columnName]: displayCol })); + } + + if (refTable) { + console.log("🔍 엔티티 데이터 조회:", refTable); + const response = await DynamicFormApi.getTableData(refTable, { + page: 1, + pageSize: 1000, + }); + console.log("🔍 엔티티 데이터 응답:", response); + // getTableData는 { success, data: [...] } 형식으로 반환 + const rows = response.data?.rows || response.data; + if (response.success && rows && Array.isArray(rows)) { + setEntitySearchData((prev) => ({ + ...prev, + [field.columnName]: rows, + })); + console.log("✅ 엔티티 데이터 로드 성공:", field.columnName, rows.length, "개"); + } + } else { + console.warn("❌ 엔티티 필드의 referenceTable을 찾을 수 없음:", field.columnName); + } + } catch (error) { + console.error("❌ 엔티티 데이터 로드 실패:", field.columnName, error); + } finally { + setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: false })); + } + } + } + }; + + if (open && isMasterDetail && masterDetailExcelConfig?.masterSelectFields?.length > 0) { + loadEntityData(); + } + }, [open, isMasterDetail, masterDetailExcelConfig]); + + // 마스터-디테일 모드에서 마스터 필드 입력 여부 확인 + const isSimpleMasterDetailMode = isMasterDetail && masterDetailExcelConfig; + const hasMasterSelectFields = isSimpleMasterDetailMode && + (masterDetailExcelConfig?.masterSelectFields?.length ?? 0) > 0; + + // 마스터 필드가 모두 입력되었는지 확인 + const isMasterFieldsValid = () => { + if (!hasMasterSelectFields) return true; + return masterDetailExcelConfig!.masterSelectFields!.every((field) => { + if (!field.required) return true; + const value = masterFieldValues[field.columnName]; + return value !== undefined && value !== null && value !== ""; + }); + }; + // 파일 선택 핸들러 const handleFileChange = async (e: React.ChangeEvent) => { const selectedFile = e.target.files?.[0]; @@ -198,12 +340,51 @@ export const ExcelUploadModal: React.FC = ({ const loadTableSchema = async () => { try { - console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail }); + console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail, isSimpleMasterDetailMode }); let allColumns: TableColumn[] = []; - // 🆕 마스터-디테일 모드: 두 테이블의 컬럼 합치기 - if (isMasterDetail && masterDetailRelation) { + // 🆕 마스터-디테일 간단 모드: 디테일 테이블 컬럼만 로드 (마스터 필드는 UI에서 선택) + if (isSimpleMasterDetailMode && masterDetailRelation) { + const { detailTable, detailFkColumn } = masterDetailRelation; + + console.log("📊 마스터-디테일 간단 모드 스키마 로드 (디테일만):", { detailTable }); + + // 디테일 테이블 스키마만 로드 (마스터 정보는 UI에서 선택) + const detailResponse = await getTableSchema(detailTable); + if (detailResponse.success && detailResponse.data) { + // 설정된 detailExcelFields가 있으면 해당 필드만, 없으면 전체 + const configuredFields = masterDetailExcelConfig?.detailExcelFields; + + const detailCols = detailResponse.data.columns + .filter((col) => { + // 자동 생성 컬럼, FK 컬럼 제외 + if (AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())) return false; + if (col.name === detailFkColumn) return false; + + // 설정된 필드가 있으면 해당 필드만 + if (configuredFields && configuredFields.length > 0) { + return configuredFields.some((f) => f.columnName === col.name); + } + return true; + }) + .map((col) => { + // 설정에서 라벨 찾기 + const configField = configuredFields?.find((f) => f.columnName === col.name); + return { + ...col, + label: configField?.columnLabel || col.label || col.name, + originalName: col.name, + sourceTable: detailTable, + }; + }); + allColumns = detailCols; + } + + console.log("✅ 마스터-디테일 간단 모드 컬럼 로드 완료:", allColumns.length); + } + // 🆕 마스터-디테일 기존 모드: 두 테이블의 컬럼 합치기 + else if (isMasterDetail && masterDetailRelation) { const { masterTable, detailTable, detailFkColumn } = masterDetailRelation; console.log("📊 마스터-디테일 스키마 로드:", { masterTable, detailTable }); @@ -365,6 +546,12 @@ export const ExcelUploadModal: React.FC = ({ return; } + // 🆕 마스터-디테일 간단 모드: 마스터 필드 유효성 검사 + if (currentStep === 1 && hasMasterSelectFields && !isMasterFieldsValid()) { + toast.error("마스터 정보를 모두 입력해주세요."); + return; + } + // 1단계 → 2단계 전환 시: 빈 헤더 열 제외 if (currentStep === 1) { // 빈 헤더가 아닌 열만 필터링 @@ -449,8 +636,39 @@ export const ExcelUploadModal: React.FC = ({ `📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행` ); - // 🆕 마스터-디테일 모드 처리 - if (isMasterDetail && screenId && masterDetailRelation) { + // 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번) + if (isSimpleMasterDetailMode && screenId && masterDetailRelation) { + console.log("📊 마스터-디테일 간단 모드 업로드:", { + masterDetailRelation, + masterFieldValues, + numberingRuleId: masterDetailExcelConfig?.numberingRuleId, + }); + + const uploadResult = await DynamicFormApi.uploadMasterDetailSimple( + screenId, + filteredData, + masterFieldValues, + masterDetailExcelConfig?.numberingRuleId || undefined + ); + + if (uploadResult.success && uploadResult.data) { + const { masterInserted, detailInserted, generatedKey, errors } = uploadResult.data; + + toast.success( + `마스터 ${masterInserted}건(${generatedKey || ""}), 디테일 ${detailInserted}건 처리되었습니다.` + + (errors?.length > 0 ? ` (오류: ${errors.length}건)` : "") + ); + + // 매핑 템플릿 저장 + await saveMappingTemplateInternal(); + + onSuccess?.(); + } else { + toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다."); + } + } + // 🆕 마스터-디테일 기존 모드 처리 + else if (isMasterDetail && screenId && masterDetailRelation) { console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation); const uploadResult = await DynamicFormApi.uploadMasterDetailData( @@ -558,6 +776,8 @@ export const ExcelUploadModal: React.FC = ({ setExcelColumns([]); setSystemColumns([]); setColumnMappings([]); + // 🆕 마스터-디테일 모드 초기화 + setMasterFieldValues({}); } }, [open]); @@ -647,6 +867,87 @@ export const ExcelUploadModal: React.FC = ({ {/* 1단계: 파일 선택 & 미리보기 (통합) */} {currentStep === 1 && (
+ {/* 🆕 마스터-디테일 간단 모드: 마스터 필드 입력 */} + {hasMasterSelectFields && ( +
+ {masterDetailExcelConfig?.masterSelectFields?.map((field) => ( +
+ + {field.inputType === "entity" ? ( + + ) : field.inputType === "date" ? ( + + setMasterFieldValues((prev) => ({ + ...prev, + [field.columnName]: e.target.value, + })) + } + className="h-9 w-full rounded-md border px-3 text-xs" + /> + ) : ( + + setMasterFieldValues((prev) => ({ + ...prev, + [field.columnName]: e.target.value, + })) + } + placeholder={field.columnLabel} + className="h-9 w-full rounded-md border px-3 text-xs" + /> + )} +
+ ))} +
+ )} + {/* 파일 선택 영역 */}