From cea3aa53aee2337c31242918944c9a56a18c1a12 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 9 Jan 2026 11:21:16 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=EC=97=91=EC=85=80=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EB=B6=84=ED=95=A0=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/routes/dataRoutes.ts | 186 +++++++ .../src/services/masterDetailExcelService.ts | 527 ++++++++++++++++++ .../components/common/ExcelUploadModal.tsx | 319 +++++++---- frontend/lib/api/dynamicForm.ts | 124 +++++ frontend/lib/utils/buttonActions.ts | 84 ++- 5 files changed, 1142 insertions(+), 98 deletions(-) create mode 100644 backend-node/src/services/masterDetailExcelService.ts diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index f9d88d92..16b6aa49 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -1,10 +1,196 @@ import express from "express"; import { dataService } from "../services/dataService"; +import { masterDetailExcelService } from "../services/masterDetailExcelService"; import { authenticateToken } from "../middleware/authMiddleware"; import { AuthenticatedRequest } from "../types/auth"; const router = express.Router(); +// ================================ +// 마스터-디테일 엑셀 API +// ================================ + +/** + * 마스터-디테일 관계 정보 조회 + * GET /api/data/master-detail/relation/:screenId + */ +router.get( + "/master-detail/relation/:screenId", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { screenId } = req.params; + + if (!screenId || isNaN(parseInt(screenId))) { + return res.status(400).json({ + success: false, + message: "유효한 screenId가 필요합니다.", + }); + } + + console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`); + + const relation = await masterDetailExcelService.getMasterDetailRelation( + parseInt(screenId) + ); + + if (!relation) { + return res.json({ + success: true, + data: null, + message: "마스터-디테일 구조가 아닙니다.", + }); + } + + console.log(`✅ 마스터-디테일 관계 발견:`, { + masterTable: relation.masterTable, + detailTable: relation.detailTable, + joinKey: relation.masterKeyColumn, + }); + + return res.json({ + success: true, + data: relation, + }); + } catch (error: any) { + console.error("마스터-디테일 관계 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "마스터-디테일 관계 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + +/** + * 마스터-디테일 엑셀 다운로드 데이터 조회 + * POST /api/data/master-detail/download + */ +router.post( + "/master-detail/download", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { screenId, filters } = req.body; + const companyCode = req.user?.companyCode || "*"; + + if (!screenId) { + return res.status(400).json({ + success: false, + message: "screenId가 필요합니다.", + }); + } + + console.log(`📥 마스터-디테일 엑셀 다운로드: screenId=${screenId}`); + + // 1. 마스터-디테일 관계 조회 + const relation = await masterDetailExcelService.getMasterDetailRelation( + parseInt(screenId) + ); + + if (!relation) { + return res.status(400).json({ + success: false, + message: "마스터-디테일 구조가 아닙니다.", + }); + } + + // 2. JOIN 데이터 조회 + const data = await masterDetailExcelService.getJoinedData( + relation, + companyCode, + filters + ); + + console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}행`); + + return res.json({ + success: true, + data, + }); + } catch (error: any) { + console.error("마스터-디테일 다운로드 오류:", error); + return res.status(500).json({ + success: false, + message: "마스터-디테일 다운로드 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + +/** + * 마스터-디테일 엑셀 업로드 + * POST /api/data/master-detail/upload + */ +router.post( + "/master-detail/upload", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { screenId, data } = req.body; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId; + + if (!screenId || !data || !Array.isArray(data)) { + return res.status(400).json({ + success: false, + message: "screenId와 data 배열이 필요합니다.", + }); + } + + console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`); + + // 1. 마스터-디테일 관계 조회 + const relation = await masterDetailExcelService.getMasterDetailRelation( + parseInt(screenId) + ); + + if (!relation) { + return res.status(400).json({ + success: false, + message: "마스터-디테일 구조가 아닙니다.", + }); + } + + // 2. 데이터 업로드 + const result = await masterDetailExcelService.uploadJoinedData( + relation, + data, + companyCode, + userId + ); + + console.log(`✅ 마스터-디테일 업로드 완료:`, { + masterInserted: result.masterInserted, + masterUpdated: result.masterUpdated, + detailInserted: result.detailInserted, + errors: result.errors.length, + }); + + return res.json({ + success: result.success, + data: result, + message: result.success + ? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.` + : "업로드 중 오류가 발생했습니다.", + }); + } catch (error: any) { + console.error("마스터-디테일 업로드 오류:", error); + return res.status(500).json({ + success: false, + message: "마스터-디테일 업로드 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + +// ================================ +// 기존 데이터 API +// ================================ + /** * 조인 데이터 조회 API (다른 라우트보다 먼저 정의) * GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=... diff --git a/backend-node/src/services/masterDetailExcelService.ts b/backend-node/src/services/masterDetailExcelService.ts new file mode 100644 index 00000000..dbb129c9 --- /dev/null +++ b/backend-node/src/services/masterDetailExcelService.ts @@ -0,0 +1,527 @@ +/** + * 마스터-디테일 엑셀 처리 서비스 + * + * 분할 패널 화면의 마스터-디테일 구조를 자동 감지하고 + * 엑셀 다운로드/업로드 시 JOIN 및 그룹화 처리를 수행합니다. + */ + +import { query, queryOne, transaction, getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// ================================ +// 인터페이스 정의 +// ================================ + +/** + * 마스터-디테일 관계 정보 + */ +export interface MasterDetailRelation { + masterTable: string; + detailTable: string; + masterKeyColumn: string; // 마스터 테이블의 키 컬럼 (예: order_no) + detailFkColumn: string; // 디테일 테이블의 FK 컬럼 (예: order_no) + masterColumns: ColumnInfo[]; + detailColumns: ColumnInfo[]; +} + +/** + * 컬럼 정보 + */ +export interface ColumnInfo { + name: string; + label: string; + inputType: string; + isFromMaster: boolean; +} + +/** + * 분할 패널 설정 + */ +export interface SplitPanelConfig { + leftPanel: { + tableName: string; + columns: Array<{ name: string; label: string; width?: number }>; + }; + rightPanel: { + tableName: string; + columns: Array<{ name: string; label: string; width?: number }>; + relation?: { + type: string; + foreignKey: string; + leftColumn: string; + }; + }; +} + +/** + * 엑셀 다운로드 결과 + */ +export interface ExcelDownloadData { + headers: string[]; // 컬럼 라벨들 + columns: string[]; // 컬럼명들 + data: Record[]; + masterColumns: string[]; // 마스터 컬럼 목록 + detailColumns: string[]; // 디테일 컬럼 목록 + joinKey: string; // 조인 키 +} + +/** + * 엑셀 업로드 결과 + */ +export interface ExcelUploadResult { + success: boolean; + masterInserted: number; + masterUpdated: number; + detailInserted: number; + detailDeleted: number; + errors: string[]; +} + +// ================================ +// 서비스 클래스 +// ================================ + +class MasterDetailExcelService { + + /** + * 화면 ID로 분할 패널 설정 조회 + */ + async getSplitPanelConfig(screenId: number): Promise { + try { + logger.info(`분할 패널 설정 조회: screenId=${screenId}`); + + // screen_layouts에서 split-panel-layout 컴포넌트 찾기 + const result = await queryOne( + `SELECT properties->>'componentConfig' as config + FROM screen_layouts + WHERE screen_id = $1 + AND component_type = 'component' + AND properties->>'componentType' = 'split-panel-layout' + LIMIT 1`, + [screenId] + ); + + if (!result || !result.config) { + logger.info(`분할 패널 없음: screenId=${screenId}`); + return null; + } + + const config = typeof result.config === "string" + ? JSON.parse(result.config) + : result.config; + + logger.info(`분할 패널 설정 발견:`, { + leftTable: config.leftPanel?.tableName, + rightTable: config.rightPanel?.tableName, + relation: config.rightPanel?.relation, + }); + + return { + leftPanel: config.leftPanel, + rightPanel: config.rightPanel, + }; + } catch (error: any) { + logger.error(`분할 패널 설정 조회 실패: ${error.message}`); + return null; + } + } + + /** + * column_labels에서 Entity 관계 정보 조회 + * 디테일 테이블에서 마스터 테이블을 참조하는 컬럼 찾기 + */ + async getEntityRelation( + detailTable: string, + masterTable: string + ): Promise<{ detailFkColumn: string; masterKeyColumn: string } | null> { + try { + logger.info(`Entity 관계 조회: ${detailTable} -> ${masterTable}`); + + const result = await queryOne( + `SELECT column_name, reference_column + FROM column_labels + WHERE table_name = $1 + AND input_type = 'entity' + AND reference_table = $2 + LIMIT 1`, + [detailTable, masterTable] + ); + + if (!result) { + logger.warn(`Entity 관계 없음: ${detailTable} -> ${masterTable}`); + return null; + } + + logger.info(`Entity 관계 발견: ${detailTable}.${result.column_name} -> ${masterTable}.${result.reference_column}`); + + return { + detailFkColumn: result.column_name, + masterKeyColumn: result.reference_column, + }; + } catch (error: any) { + logger.error(`Entity 관계 조회 실패: ${error.message}`); + return null; + } + } + + /** + * 테이블의 컬럼 라벨 정보 조회 + */ + async getColumnLabels(tableName: string): Promise> { + try { + const result = await query( + `SELECT column_name, column_label + FROM column_labels + WHERE table_name = $1`, + [tableName] + ); + + const labelMap = new Map(); + for (const row of result) { + labelMap.set(row.column_name, row.column_label || row.column_name); + } + + return labelMap; + } catch (error: any) { + logger.error(`컬럼 라벨 조회 실패: ${error.message}`); + return new Map(); + } + } + + /** + * 마스터-디테일 관계 정보 조합 + */ + async getMasterDetailRelation( + screenId: number + ): Promise { + try { + // 1. 분할 패널 설정 조회 + const splitPanel = await this.getSplitPanelConfig(screenId); + if (!splitPanel) { + return null; + } + + const masterTable = splitPanel.leftPanel.tableName; + const detailTable = splitPanel.rightPanel.tableName; + + if (!masterTable || !detailTable) { + logger.warn("마스터 또는 디테일 테이블명 없음"); + return null; + } + + // 2. 분할 패널의 relation 정보가 있으면 우선 사용 + let masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn; + let detailFkColumn = splitPanel.rightPanel.relation?.foreignKey; + + // 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회 + if (!masterKeyColumn || !detailFkColumn) { + const entityRelation = await this.getEntityRelation(detailTable, masterTable); + if (entityRelation) { + masterKeyColumn = entityRelation.masterKeyColumn; + detailFkColumn = entityRelation.detailFkColumn; + } + } + + if (!masterKeyColumn || !detailFkColumn) { + logger.warn("조인 키 정보를 찾을 수 없음"); + return null; + } + + // 4. 컬럼 라벨 정보 조회 + const masterLabels = await this.getColumnLabels(masterTable); + const detailLabels = await this.getColumnLabels(detailTable); + + // 5. 마스터 컬럼 정보 구성 + const masterColumns: ColumnInfo[] = splitPanel.leftPanel.columns.map(col => ({ + name: col.name, + label: masterLabels.get(col.name) || col.label || col.name, + inputType: "text", + isFromMaster: true, + })); + + // 6. 디테일 컬럼 정보 구성 (FK 컬럼 제외) + const detailColumns: ColumnInfo[] = splitPanel.rightPanel.columns + .filter(col => col.name !== detailFkColumn) // FK 컬럼 제외 + .map(col => ({ + name: col.name, + label: detailLabels.get(col.name) || col.label || col.name, + inputType: "text", + isFromMaster: false, + })); + + logger.info(`마스터-디테일 관계 구성 완료:`, { + masterTable, + detailTable, + masterKeyColumn, + detailFkColumn, + masterColumnCount: masterColumns.length, + detailColumnCount: detailColumns.length, + }); + + return { + masterTable, + detailTable, + masterKeyColumn, + detailFkColumn, + masterColumns, + detailColumns, + }; + } catch (error: any) { + logger.error(`마스터-디테일 관계 조회 실패: ${error.message}`); + return null; + } + } + + /** + * 마스터-디테일 JOIN 데이터 조회 (엑셀 다운로드용) + */ + async getJoinedData( + relation: MasterDetailRelation, + companyCode: string, + filters?: Record + ): Promise { + try { + const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation; + + // SELECT 절 구성 + const masterSelectCols = masterColumns.map(col => `m."${col.name}"`); + const detailSelectCols = detailColumns.map(col => `d."${col.name}"`); + const selectClause = [...masterSelectCols, ...detailSelectCols].join(", "); + + // WHERE 절 구성 + const whereConditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터 (최고 관리자 제외) + if (companyCode && companyCode !== "*") { + whereConditions.push(`m.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + // 추가 필터 적용 + if (filters) { + for (const [key, value] of Object.entries(filters)) { + if (value !== undefined && value !== null && value !== "") { + // 마스터 테이블 컬럼인지 확인 + const isMasterCol = masterColumns.some(c => c.name === key); + const tableAlias = isMasterCol ? "m" : "d"; + whereConditions.push(`${tableAlias}."${key}" = $${paramIndex}`); + params.push(value); + paramIndex++; + } + } + } + + const whereClause = whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + + // JOIN 쿼리 실행 + const sql = ` + SELECT ${selectClause} + FROM "${masterTable}" m + LEFT JOIN "${detailTable}" d + ON m."${masterKeyColumn}" = d."${detailFkColumn}" + AND m.company_code = d.company_code + ${whereClause} + ORDER BY m."${masterKeyColumn}", d.id + `; + + logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params }); + + const data = await query(sql, params); + + // 헤더 및 컬럼 정보 구성 + const headers = [...masterColumns.map(c => c.label), ...detailColumns.map(c => c.label)]; + const columns = [...masterColumns.map(c => c.name), ...detailColumns.map(c => c.name)]; + + logger.info(`마스터-디테일 데이터 조회 완료: ${data.length}행`); + + return { + headers, + columns, + data, + masterColumns: masterColumns.map(c => c.name), + detailColumns: detailColumns.map(c => c.name), + joinKey: masterKeyColumn, + }; + } catch (error: any) { + logger.error(`마스터-디테일 데이터 조회 실패: ${error.message}`); + throw error; + } + } + + /** + * 마스터-디테일 데이터 업로드 (엑셀 업로드용) + * + * 처리 로직: + * 1. 엑셀 데이터를 마스터 키로 그룹화 + * 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT + * 3. 해당 마스터 키의 기존 디테일 삭제 + * 4. 새 디테일 데이터 INSERT + */ + async uploadJoinedData( + relation: MasterDetailRelation, + data: Record[], + companyCode: string, + userId?: string + ): Promise { + const result: ExcelUploadResult = { + success: false, + masterInserted: 0, + masterUpdated: 0, + detailInserted: 0, + detailDeleted: 0, + errors: [], + }; + + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation; + + // 1. 데이터를 마스터 키로 그룹화 + const groupedData = new Map[]>(); + + for (const row of data) { + const masterKey = row[masterKeyColumn]; + if (!masterKey) { + result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`); + continue; + } + + if (!groupedData.has(masterKey)) { + groupedData.set(masterKey, []); + } + groupedData.get(masterKey)!.push(row); + } + + logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`); + + // 2. 각 그룹 처리 + for (const [masterKey, rows] of groupedData.entries()) { + try { + // 2a. 마스터 데이터 추출 (첫 번째 행에서) + const masterData: Record = {}; + for (const col of masterColumns) { + if (rows[0][col.name] !== undefined) { + masterData[col.name] = rows[0][col.name]; + } + } + + // 회사 코드, 작성자 추가 + masterData.company_code = companyCode; + if (userId) { + masterData.writer = userId; + } + + // 2b. 마스터 UPSERT + const existingMaster = await client.query( + `SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`, + [masterKey, companyCode] + ); + + if (existingMaster.rows.length > 0) { + // UPDATE + const updateCols = Object.keys(masterData) + .filter(k => k !== masterKeyColumn && k !== "id") + .map((k, i) => `"${k}" = $${i + 1}`); + const updateValues = Object.keys(masterData) + .filter(k => k !== masterKeyColumn && k !== "id") + .map(k => masterData[k]); + + if (updateCols.length > 0) { + await client.query( + `UPDATE "${masterTable}" + SET ${updateCols.join(", ")}, updated_date = NOW() + WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`, + [...updateValues, masterKey, companyCode] + ); + } + result.masterUpdated++; + } else { + // INSERT + const insertCols = Object.keys(masterData); + const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); + const insertValues = insertCols.map(k => masterData[k]); + + await client.query( + `INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) + VALUES (${insertPlaceholders.join(", ")}, NOW())`, + insertValues + ); + result.masterInserted++; + } + + // 2c. 기존 디테일 삭제 + const deleteResult = await client.query( + `DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`, + [masterKey, companyCode] + ); + result.detailDeleted += deleteResult.rowCount || 0; + + // 2d. 새 디테일 INSERT + for (const row of rows) { + const detailData: Record = {}; + + // FK 컬럼 추가 + detailData[detailFkColumn] = masterKey; + detailData.company_code = companyCode; + if (userId) { + detailData.writer = userId; + } + + // 디테일 컬럼 데이터 추출 + for (const col of detailColumns) { + if (row[col.name] !== undefined) { + detailData[col.name] = row[col.name]; + } + } + + const insertCols = Object.keys(detailData); + const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); + const insertValues = insertCols.map(k => detailData[k]); + + await client.query( + `INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) + VALUES (${insertPlaceholders.join(", ")}, NOW())`, + insertValues + ); + result.detailInserted++; + } + } catch (error: any) { + result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`); + logger.error(`마스터 키 ${masterKey} 처리 실패:`, error); + } + } + + await client.query("COMMIT"); + result.success = result.errors.length === 0 || result.masterInserted + result.masterUpdated > 0; + + logger.info(`마스터-디테일 업로드 완료:`, { + masterInserted: result.masterInserted, + masterUpdated: result.masterUpdated, + detailInserted: result.detailInserted, + detailDeleted: result.detailDeleted, + errors: result.errors.length, + }); + + } catch (error: any) { + await client.query("ROLLBACK"); + result.errors.push(`트랜잭션 실패: ${error.message}`); + logger.error(`마스터-디테일 업로드 트랜잭션 실패:`, error); + } finally { + client.release(); + } + + return result; + } +} + +export const masterDetailExcelService = new MasterDetailExcelService(); + diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 867b6f85..07058fac 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -42,6 +42,17 @@ export interface ExcelUploadModalProps { keyColumn?: string; onSuccess?: () => void; userId?: string; + // 마스터-디테일 지원 + screenId?: number; + isMasterDetail?: boolean; + masterDetailRelation?: { + masterTable: string; + detailTable: string; + masterKeyColumn: string; + detailFkColumn: string; + masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>; + detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>; + }; } interface ColumnMapping { @@ -57,6 +68,9 @@ export const ExcelUploadModal: React.FC = ({ keyColumn, onSuccess, userId = "guest", + screenId, + isMasterDetail = false, + masterDetailRelation, }) => { const [currentStep, setCurrentStep] = useState(1); @@ -184,50 +198,99 @@ export const ExcelUploadModal: React.FC = ({ const loadTableSchema = async () => { try { - console.log("🔍 테이블 스키마 로드 시작:", { tableName }); + console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail }); - const response = await getTableSchema(tableName); + let allColumns: TableColumn[] = []; - console.log("📊 테이블 스키마 응답:", response); + // 🆕 마스터-디테일 모드: 두 테이블의 컬럼 합치기 + if (isMasterDetail && masterDetailRelation) { + const { masterTable, detailTable, detailFkColumn } = masterDetailRelation; + + console.log("📊 마스터-디테일 스키마 로드:", { masterTable, detailTable }); - if (response.success && response.data) { - // 자동 생성 컬럼 제외 - const filteredColumns = response.data.columns.filter( - (col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) - ); - console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", filteredColumns); - setSystemColumns(filteredColumns); - - // 기존 매핑 템플릿 조회 - console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns }); - const mappingResponse = await findMappingByColumns(tableName, excelColumns); - - if (mappingResponse.success && mappingResponse.data) { - // 저장된 매핑 템플릿이 있으면 자동 적용 - console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data); - const savedMappings = mappingResponse.data.columnMappings; - - const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({ - excelColumn: col, - systemColumn: savedMappings[col] || null, - })); - setColumnMappings(appliedMappings); - setIsAutoMappingLoaded(true); - - const matchedCount = appliedMappings.filter((m) => m.systemColumn).length; - toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`); - } else { - // 매핑 템플릿이 없으면 초기 상태로 설정 - console.log("ℹ️ 매핑 템플릿 없음 - 새 엑셀 구조"); - const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({ - excelColumn: col, - systemColumn: null, - })); - setColumnMappings(initialMappings); - setIsAutoMappingLoaded(false); + // 마스터 테이블 스키마 + const masterResponse = await getTableSchema(masterTable); + if (masterResponse.success && masterResponse.data) { + const masterCols = masterResponse.data.columns + .filter((col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())) + .map((col) => ({ + ...col, + // 유니크 키를 위해 테이블명 접두사 추가 + name: `${masterTable}.${col.name}`, + label: `[마스터] ${col.label || col.name}`, + originalName: col.name, + sourceTable: masterTable, + })); + allColumns = [...allColumns, ...masterCols]; } + + // 디테일 테이블 스키마 (FK 컬럼 제외) + const detailResponse = await getTableSchema(detailTable); + if (detailResponse.success && detailResponse.data) { + const detailCols = detailResponse.data.columns + .filter((col) => + !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) && + col.name !== detailFkColumn // FK 컬럼 제외 + ) + .map((col) => ({ + ...col, + // 유니크 키를 위해 테이블명 접두사 추가 + name: `${detailTable}.${col.name}`, + label: `[디테일] ${col.label || col.name}`, + originalName: col.name, + sourceTable: detailTable, + })); + allColumns = [...allColumns, ...detailCols]; + } + + console.log("✅ 마스터-디테일 컬럼 로드 완료:", allColumns.length); } else { - console.error("❌ 테이블 스키마 로드 실패:", response); + // 기존 단일 테이블 모드 + const response = await getTableSchema(tableName); + + console.log("📊 테이블 스키마 응답:", response); + + if (response.success && response.data) { + // 자동 생성 컬럼 제외 + allColumns = response.data.columns.filter( + (col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) + ); + } else { + console.error("❌ 테이블 스키마 로드 실패:", response); + return; + } + } + + console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", allColumns); + setSystemColumns(allColumns); + + // 기존 매핑 템플릿 조회 + console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns }); + const mappingResponse = await findMappingByColumns(tableName, excelColumns); + + if (mappingResponse.success && mappingResponse.data) { + // 저장된 매핑 템플릿이 있으면 자동 적용 + console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data); + const savedMappings = mappingResponse.data.columnMappings; + + const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({ + excelColumn: col, + systemColumn: savedMappings[col] || null, + })); + setColumnMappings(appliedMappings); + setIsAutoMappingLoaded(true); + + const matchedCount = appliedMappings.filter((m) => m.systemColumn).length; + toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`); + } else { + // 매핑 템플릿이 없으면 초기 상태로 설정 + console.log("ℹ️ 매핑 템플릿 없음 - 새 엑셀 구조"); + const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({ + excelColumn: col, + systemColumn: null, + })); + setColumnMappings(initialMappings); + setIsAutoMappingLoaded(false); } } catch (error) { console.error("❌ 테이블 스키마 로드 실패:", error); @@ -239,18 +302,35 @@ export const ExcelUploadModal: React.FC = ({ const handleAutoMapping = () => { const newMappings = excelColumns.map((excelCol) => { const normalizedExcelCol = excelCol.toLowerCase().trim(); + // [마스터], [디테일] 접두사 제거 후 비교 + const cleanExcelCol = normalizedExcelCol.replace(/^\[(마스터|디테일)\]\s*/i, ""); - // 1. 먼저 라벨로 매칭 시도 - let matchedSystemCol = systemColumns.find( - (sysCol) => - sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol - ); + // 1. 먼저 라벨로 매칭 시도 (접두사 제거 후) + let matchedSystemCol = systemColumns.find((sysCol) => { + if (!sysCol.label) return false; + // [마스터], [디테일] 접두사 제거 후 비교 + const cleanLabel = sysCol.label.toLowerCase().trim().replace(/^\[(마스터|디테일)\]\s*/i, ""); + return cleanLabel === normalizedExcelCol || cleanLabel === cleanExcelCol; + }); // 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도 if (!matchedSystemCol) { - matchedSystemCol = systemColumns.find( - (sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol - ); + matchedSystemCol = systemColumns.find((sysCol) => { + // 마스터-디테일 모드: originalName이 있으면 사용 + const originalName = (sysCol as any).originalName; + const colName = originalName || sysCol.name; + return colName.toLowerCase().trim() === normalizedExcelCol || colName.toLowerCase().trim() === cleanExcelCol; + }); + } + + // 3. 여전히 매칭 안되면 전체 이름(테이블.컬럼)에서 컬럼 부분만 추출해서 비교 + if (!matchedSystemCol) { + matchedSystemCol = systemColumns.find((sysCol) => { + // 테이블.컬럼 형식에서 컬럼만 추출 + const nameParts = sysCol.name.split("."); + const colNameOnly = nameParts.length > 1 ? nameParts[1] : nameParts[0]; + return colNameOnly.toLowerCase().trim() === normalizedExcelCol || colNameOnly.toLowerCase().trim() === cleanExcelCol; + }); } return { @@ -344,7 +424,12 @@ export const ExcelUploadModal: React.FC = ({ const mappedRow: Record = {}; columnMappings.forEach((mapping) => { if (mapping.systemColumn) { - mappedRow[mapping.systemColumn] = row[mapping.excelColumn]; + // 마스터-디테일 모드: 테이블.컬럼 형식에서 컬럼명만 추출 + let colName = mapping.systemColumn; + if (isMasterDetail && colName.includes(".")) { + colName = colName.split(".")[1]; + } + mappedRow[colName] = row[mapping.excelColumn]; } }); return mappedRow; @@ -364,60 +449,63 @@ export const ExcelUploadModal: React.FC = ({ `📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행` ); - let successCount = 0; - let failCount = 0; + // 🆕 마스터-디테일 모드 처리 + if (isMasterDetail && screenId && masterDetailRelation) { + console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation); - for (const row of filteredData) { - try { - if (uploadMode === "insert") { - const formData = { screenId: 0, tableName, data: row }; - const result = await DynamicFormApi.saveFormData(formData); - if (result.success) { - successCount++; - } else { - failCount++; - } - } - } catch (error) { - failCount++; - } - } - - if (successCount > 0) { - toast.success( - `${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}` + const uploadResult = await DynamicFormApi.uploadMasterDetailData( + screenId, + filteredData ); - // 매핑 템플릿 저장 (UPSERT - 자동 저장) - try { - const mappingsToSave: Record = {}; - columnMappings.forEach((mapping) => { - mappingsToSave[mapping.excelColumn] = mapping.systemColumn; - }); - - console.log("💾 매핑 템플릿 저장 중...", { - tableName, - excelColumns, - mappingsToSave, - }); - const saveResult = await saveMappingTemplate( - tableName, - excelColumns, - mappingsToSave + if (uploadResult.success && uploadResult.data) { + const { masterInserted, masterUpdated, detailInserted, errors } = uploadResult.data; + + toast.success( + `마스터 ${masterInserted + masterUpdated}건, 디테일 ${detailInserted}건 처리되었습니다.` + + (errors.length > 0 ? ` (오류: ${errors.length}건)` : "") ); - if (saveResult.success) { - console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data); - } else { - console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error); + // 매핑 템플릿 저장 + await saveMappingTemplateInternal(); + + onSuccess?.(); + } else { + toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다."); + } + } else { + // 기존 단일 테이블 업로드 로직 + let successCount = 0; + let failCount = 0; + + for (const row of filteredData) { + try { + if (uploadMode === "insert") { + const formData = { screenId: 0, tableName, data: row }; + const result = await DynamicFormApi.saveFormData(formData); + if (result.success) { + successCount++; + } else { + failCount++; + } + } + } catch (error) { + failCount++; } - } catch (error) { - console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error); } - onSuccess?.(); - } else { - toast.error("업로드에 실패했습니다."); + if (successCount > 0) { + toast.success( + `${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}` + ); + + // 매핑 템플릿 저장 + await saveMappingTemplateInternal(); + + onSuccess?.(); + } else { + toast.error("업로드에 실패했습니다."); + } } } catch (error) { console.error("❌ 엑셀 업로드 실패:", error); @@ -427,6 +515,35 @@ export const ExcelUploadModal: React.FC = ({ } }; + // 매핑 템플릿 저장 헬퍼 함수 + const saveMappingTemplateInternal = async () => { + try { + const mappingsToSave: Record = {}; + columnMappings.forEach((mapping) => { + mappingsToSave[mapping.excelColumn] = mapping.systemColumn; + }); + + console.log("💾 매핑 템플릿 저장 중...", { + tableName, + excelColumns, + mappingsToSave, + }); + const saveResult = await saveMappingTemplate( + tableName, + excelColumns, + mappingsToSave + ); + + if (saveResult.success) { + console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data); + } else { + console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error); + } + } catch (error) { + console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error); + } + }; + // 모달 닫기 시 초기화 useEffect(() => { if (!open) { @@ -461,9 +578,21 @@ export const ExcelUploadModal: React.FC = ({ 엑셀 데이터 업로드 + {isMasterDetail && ( + + 마스터-디테일 + + )} - 엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요. + {isMasterDetail && masterDetailRelation ? ( + <> + 마스터({masterDetailRelation.masterTable}) + 디테일({masterDetailRelation.detailTable}) 구조입니다. + 마스터 데이터는 중복 입력 시 병합됩니다. + + ) : ( + "엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요." + )} diff --git a/frontend/lib/api/dynamicForm.ts b/frontend/lib/api/dynamicForm.ts index d2433c48..f28704cf 100644 --- a/frontend/lib/api/dynamicForm.ts +++ b/frontend/lib/api/dynamicForm.ts @@ -556,6 +556,130 @@ export class DynamicFormApi { }; } } + + // ================================ + // 마스터-디테일 엑셀 API + // ================================ + + /** + * 마스터-디테일 관계 정보 조회 + * @param screenId 화면 ID + * @returns 마스터-디테일 관계 정보 (null이면 마스터-디테일 구조 아님) + */ + static async getMasterDetailRelation(screenId: number): Promise> { + try { + console.log("🔍 마스터-디테일 관계 조회:", screenId); + + const response = await apiClient.get(`/data/master-detail/relation/${screenId}`); + + return { + success: true, + data: response.data?.data || null, + message: response.data?.message || "조회 완료", + }; + } catch (error: any) { + console.error("❌ 마스터-디테일 관계 조회 실패:", error); + return { + success: false, + data: null, + message: error.response?.data?.message || error.message, + }; + } + } + + /** + * 마스터-디테일 엑셀 다운로드 데이터 조회 + * @param screenId 화면 ID + * @param filters 필터 조건 + * @returns JOIN된 플랫 데이터 + */ + static async getMasterDetailDownloadData( + screenId: number, + filters?: Record + ): Promise> { + try { + console.log("📥 마스터-디테일 다운로드 데이터 조회:", { screenId, filters }); + + const response = await apiClient.post(`/data/master-detail/download`, { + screenId, + filters, + }); + + return { + success: true, + data: response.data?.data, + message: "데이터 조회 완료", + }; + } catch (error: any) { + console.error("❌ 마스터-디테일 다운로드 실패:", error); + return { + success: false, + message: error.response?.data?.message || error.message, + }; + } + } + + /** + * 마스터-디테일 엑셀 업로드 + * @param screenId 화면 ID + * @param data 엑셀에서 읽은 플랫 데이터 + * @returns 업로드 결과 + */ + static async uploadMasterDetailData( + screenId: number, + data: Record[] + ): Promise> { + try { + console.log("📤 마스터-디테일 업로드:", { screenId, rowCount: data.length }); + + const response = await apiClient.post(`/data/master-detail/upload`, { + screenId, + data, + }); + + return { + success: response.data?.success, + data: response.data?.data, + message: response.data?.message, + }; + } catch (error: any) { + console.error("❌ 마스터-디테일 업로드 실패:", error); + return { + success: false, + message: error.response?.data?.message || error.message, + }; + } + } +} + +// 마스터-디테일 관계 타입 +export interface MasterDetailRelation { + masterTable: string; + detailTable: string; + masterKeyColumn: string; + detailFkColumn: string; + masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>; + detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>; +} + +// 마스터-디테일 다운로드 데이터 타입 +export interface MasterDetailDownloadData { + headers: string[]; + columns: string[]; + data: Record[]; + masterColumns: string[]; + detailColumns: string[]; + joinKey: string; +} + +// 마스터-디테일 업로드 결과 타입 +export interface MasterDetailUploadResult { + success: boolean; + masterInserted: number; + masterUpdated: number; + detailInserted: number; + detailDeleted: number; + errors: string[]; } // 편의를 위한 기본 export diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 82cf68ff..a74ed208 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -4472,8 +4472,67 @@ export class ButtonActionExecutor { const { exportToExcel } = await import("@/lib/utils/excelExport"); let dataToExport: any[] = []; + let visibleColumns: string[] | undefined = undefined; + let columnLabels: Record | undefined = undefined; - // ✅ 항상 API 호출로 필터링된 전체 데이터 가져오기 + // 🆕 마스터-디테일 구조 확인 및 처리 + if (context.screenId) { + const { DynamicFormApi } = await import("@/lib/api/dynamicForm"); + const relationResponse = await DynamicFormApi.getMasterDetailRelation(context.screenId); + + if (relationResponse.success && relationResponse.data) { + // 마스터-디테일 구조인 경우 전용 API 사용 + console.log("📊 마스터-디테일 엑셀 다운로드:", relationResponse.data); + + const downloadResponse = await DynamicFormApi.getMasterDetailDownloadData( + context.screenId, + context.filterConditions + ); + + if (downloadResponse.success && downloadResponse.data) { + dataToExport = downloadResponse.data.data; + visibleColumns = downloadResponse.data.columns; + + // 헤더와 컬럼 매핑 + columnLabels = {}; + downloadResponse.data.columns.forEach((col: string, index: number) => { + columnLabels![col] = downloadResponse.data.headers[index] || col; + }); + + console.log(`✅ 마스터-디테일 데이터 조회 완료: ${dataToExport.length}행`); + } else { + toast.error("마스터-디테일 데이터 조회에 실패했습니다."); + return false; + } + + // 마스터-디테일 데이터 변환 및 다운로드 + if (visibleColumns && visibleColumns.length > 0 && dataToExport.length > 0) { + dataToExport = dataToExport.map((row: any) => { + const filteredRow: Record = {}; + visibleColumns!.forEach((columnName: string) => { + const label = columnLabels?.[columnName] || columnName; + filteredRow[label] = row[columnName]; + }); + return filteredRow; + }); + } + + // 파일명 생성 + let defaultFileName = relationResponse.data.masterTable || "데이터"; + if (typeof window !== "undefined") { + const menuName = localStorage.getItem("currentMenuName"); + if (menuName) defaultFileName = menuName; + } + const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`; + const sheetName = config.excelSheetName || "Sheet1"; + + await exportToExcel(dataToExport, fileName, sheetName, true); + toast.success(`${dataToExport.length}개 행이 다운로드되었습니다.`); + return true; + } + } + + // ✅ 기존 로직: 단일 테이블 처리 if (context.tableName) { const { tableDisplayStore } = await import("@/stores/tableDisplayStore"); const storedData = tableDisplayStore.getTableData(context.tableName); @@ -4565,8 +4624,7 @@ export class ButtonActionExecutor { const includeHeaders = config.excelIncludeHeaders !== false; // 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기 - let visibleColumns: string[] | undefined = undefined; - let columnLabels: Record | undefined = undefined; + // visibleColumns, columnLabels는 함수 상단에서 이미 선언됨 try { // 화면 레이아웃 데이터 가져오기 (별도 API 사용) @@ -4767,8 +4825,24 @@ export class ButtonActionExecutor { context, userId: context.userId, tableName: context.tableName, + screenId: context.screenId, }); + // 🆕 마스터-디테일 구조 확인 + let isMasterDetail = false; + let masterDetailRelation: any = null; + + if (context.screenId) { + const { DynamicFormApi } = await import("@/lib/api/dynamicForm"); + const relationResponse = await DynamicFormApi.getMasterDetailRelation(context.screenId); + + if (relationResponse.success && relationResponse.data) { + isMasterDetail = true; + masterDetailRelation = relationResponse.data; + console.log("📊 마스터-디테일 구조 감지:", masterDetailRelation); + } + } + // 동적 import로 모달 컴포넌트 로드 const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal"); const { createRoot } = await import("react-dom/client"); @@ -4811,6 +4885,10 @@ export class ButtonActionExecutor { uploadMode: config.excelUploadMode || "insert", keyColumn: config.excelKeyColumn, userId: context.userId, + // 🆕 마스터-디테일 관련 props + screenId: context.screenId, + isMasterDetail, + masterDetailRelation, onSuccess: () => { // 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨 context.onRefresh?.(); -- 2.43.0 From ee3a648917ca06420d4d2692d7ca26dec764275b Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 9 Jan 2026 13:43:14 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EC=82=AD=EC=A0=9C=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EB=8F=99=EC=9E=91=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8D=98=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/dynamicFormController.ts | 13 +- .../src/services/dynamicFormService.ts | 39 ++- .../src/services/nodeFlowExecutionService.ts | 112 +++++-- .../properties/InsertActionProperties.tsx | 314 ++++++++++++++---- frontend/components/screen/EditModal.tsx | 2 + .../screen/InteractiveScreenViewer.tsx | 3 +- frontend/lib/api/dynamicForm.ts | 11 +- frontend/lib/utils/buttonActions.ts | 17 +- frontend/types/node-editor.ts | 5 + 9 files changed, 400 insertions(+), 116 deletions(-) diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 98606f51..48b55d18 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -231,7 +231,7 @@ export const deleteFormData = async ( try { const { id } = req.params; const { companyCode, userId } = req.user as any; - const { tableName } = req.body; + const { tableName, screenId } = req.body; if (!tableName) { return res.status(400).json({ @@ -240,7 +240,16 @@ export const deleteFormData = async ( }); } - await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가 + // screenId를 숫자로 변환 (문자열로 전달될 수 있음) + const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined; + + await dynamicFormService.deleteFormData( + id, + tableName, + companyCode, + userId, + parsedScreenId // screenId 추가 (제어관리 실행용) + ); res.json({ success: true, diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 8337ed74..89d96859 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1192,12 +1192,18 @@ export class DynamicFormService { /** * 폼 데이터 삭제 (실제 테이블에서 직접 삭제) + * @param id 삭제할 레코드 ID + * @param tableName 테이블명 + * @param companyCode 회사 코드 + * @param userId 사용자 ID + * @param screenId 화면 ID (제어관리 실행용, 선택사항) */ async deleteFormData( id: string | number, tableName: string, companyCode?: string, - userId?: string + userId?: string, + screenId?: number ): Promise { try { console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { @@ -1310,14 +1316,19 @@ export class DynamicFormService { const recordCompanyCode = deletedRecord?.company_code || companyCode || "*"; - await this.executeDataflowControlIfConfigured( - 0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) - tableName, - deletedRecord, - "delete", - userId || "system", - recordCompanyCode - ); + // screenId가 전달되지 않으면 제어관리를 실행하지 않음 + if (screenId && screenId > 0) { + await this.executeDataflowControlIfConfigured( + screenId, + tableName, + deletedRecord, + "delete", + userId || "system", + recordCompanyCode + ); + } else { + console.log("ℹ️ screenId가 전달되지 않아 제어관리를 건너뜁니다. (screenId:", screenId, ")"); + } } } catch (controlError) { console.error("⚠️ 제어관리 실행 오류:", controlError); @@ -1662,10 +1673,16 @@ export class DynamicFormService { !!properties?.webTypeConfig?.dataflowConfig?.flowControls, }); - // 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우 + // 버튼 컴포넌트이고 제어관리가 활성화된 경우 + // triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete + const buttonActionType = properties?.componentConfig?.action?.type; + const isMatchingAction = + (triggerType === "delete" && buttonActionType === "delete") || + ((triggerType === "insert" || triggerType === "update") && buttonActionType === "save"); + if ( properties?.componentType === "button-primary" && - properties?.componentConfig?.action?.type === "save" && + isMatchingAction && properties?.webTypeConfig?.enableDataflowControl === true ) { const dataflowConfig = properties?.webTypeConfig?.dataflowConfig; diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index baa1f02c..bfd628ce 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -969,21 +969,56 @@ export class NodeFlowExecutionService { const insertedData = { ...data }; console.log("🗺️ 필드 매핑 처리 중..."); - fieldMappings.forEach((mapping: any) => { + + // 🔥 채번 규칙 서비스 동적 import + const { numberingRuleService } = await import("./numberingRuleService"); + + for (const mapping of fieldMappings) { fields.push(mapping.targetField); - const value = - mapping.staticValue !== undefined - ? mapping.staticValue - : data[mapping.sourceField]; - - console.log( - ` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}` - ); + let value: any; + + // 🔥 값 생성 유형에 따른 처리 + const valueType = mapping.valueType || (mapping.staticValue !== undefined ? "static" : "source"); + + if (valueType === "autoGenerate" && mapping.numberingRuleId) { + // 자동 생성 (채번 규칙) + const companyCode = context.buttonContext?.companyCode || "*"; + try { + value = await numberingRuleService.allocateCode( + mapping.numberingRuleId, + companyCode + ); + console.log( + ` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})` + ); + } catch (error: any) { + logger.error(`채번 규칙 적용 실패: ${error.message}`); + console.error( + ` ❌ 채번 실패 → ${mapping.targetField}: ${error.message}` + ); + throw new Error( + `채번 규칙 '${mapping.numberingRuleName || mapping.numberingRuleId}' 적용 실패: ${error.message}` + ); + } + } else if (valueType === "static" || mapping.staticValue !== undefined) { + // 고정값 + value = mapping.staticValue; + console.log( + ` 📌 고정값: ${mapping.targetField} = ${value}` + ); + } else { + // 소스 필드 + value = data[mapping.sourceField]; + console.log( + ` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}` + ); + } + values.push(value); // 🔥 삽입된 값을 데이터에 반영 insertedData[mapping.targetField] = value; - }); + } // 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우) const hasWriterMapping = fieldMappings.some( @@ -1528,16 +1563,24 @@ export class NodeFlowExecutionService { } }); - // 🔑 Primary Key 자동 추가 (context-data 모드) - console.log("🔑 context-data 모드: Primary Key 자동 추가"); - const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK( - whereConditions, - data, - targetTable - ); + // 🔑 Primary Key 자동 추가 여부 결정: + // whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음 + // (사용자가 직접 조건을 설정한 경우 의도를 존중) + let finalWhereConditions: any[]; + if (whereConditions && whereConditions.length > 0) { + console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)"); + finalWhereConditions = whereConditions; + } else { + console.log("🔑 context-data 모드: Primary Key 자동 추가"); + finalWhereConditions = await this.enhanceWhereConditionsWithPK( + whereConditions, + data, + targetTable + ); + } const whereResult = this.buildWhereClause( - enhancedWhereConditions, + finalWhereConditions, data, paramIndex ); @@ -1907,22 +1950,30 @@ export class NodeFlowExecutionService { return deletedDataArray; } - // 🆕 context-data 모드: 개별 삭제 (PK 자동 추가) + // 🆕 context-data 모드: 개별 삭제 console.log("🎯 context-data 모드: 개별 삭제 시작"); for (const data of dataArray) { console.log("🔍 WHERE 조건 처리 중..."); - // 🔑 Primary Key 자동 추가 (context-data 모드) - console.log("🔑 context-data 모드: Primary Key 자동 추가"); - const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK( - whereConditions, - data, - targetTable - ); + // 🔑 Primary Key 자동 추가 여부 결정: + // whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음 + // (사용자가 직접 조건을 설정한 경우 의도를 존중) + let finalWhereConditions: any[]; + if (whereConditions && whereConditions.length > 0) { + console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)"); + finalWhereConditions = whereConditions; + } else { + console.log("🔑 context-data 모드: Primary Key 자동 추가"); + finalWhereConditions = await this.enhanceWhereConditionsWithPK( + whereConditions, + data, + targetTable + ); + } const whereResult = this.buildWhereClause( - enhancedWhereConditions, + finalWhereConditions, data, 1 ); @@ -2865,10 +2916,11 @@ export class NodeFlowExecutionService { if (fieldValue === null || fieldValue === undefined || fieldValue === "") { logger.info( - `⚠️ EXISTS 조건: 필드값이 비어있어 ${operator === "NOT_EXISTS_IN" ? "TRUE" : "FALSE"} 반환` + `⚠️ EXISTS 조건: 필드값이 비어있어 FALSE 반환 (빈 값은 조건 검사하지 않음)` ); - // 값이 비어있으면: EXISTS_IN은 false, NOT_EXISTS_IN은 true - return operator === "NOT_EXISTS_IN"; + // 값이 비어있으면 조건 검사 자체가 무의미하므로 항상 false 반환 + // 이렇게 하면 빈 값으로 인한 의도치 않은 INSERT/UPDATE/DELETE가 방지됨 + return false; } try { diff --git a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx index 437487e9..c68ff8d4 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx @@ -5,7 +5,7 @@ */ import { useEffect, useState } from "react"; -import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react"; +import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2, Sparkles } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -18,6 +18,8 @@ import { cn } from "@/lib/utils"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { tableTypeApi } from "@/lib/api/screen"; import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections"; +import { getNumberingRules } from "@/lib/api/numberingRule"; +import type { NumberingRuleConfig } from "@/types/numbering-rule"; import type { InsertActionNodeData } from "@/types/node-editor"; import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; @@ -89,6 +91,11 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP const [apiHeaders, setApiHeaders] = useState>(data.apiHeaders || {}); const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || ""); + // 🔥 채번 규칙 관련 상태 + const [numberingRules, setNumberingRules] = useState([]); + const [numberingRulesLoading, setNumberingRulesLoading] = useState(false); + const [mappingNumberingRulesOpenState, setMappingNumberingRulesOpenState] = useState([]); + // 데이터 변경 시 로컬 상태 업데이트 useEffect(() => { setDisplayName(data.displayName || data.targetTable); @@ -128,8 +135,33 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP useEffect(() => { setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false)); setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false)); + setMappingNumberingRulesOpenState(new Array(fieldMappings.length).fill(false)); }, [fieldMappings.length]); + // 🔥 채번 규칙 로딩 (자동 생성 사용 시) + useEffect(() => { + const loadNumberingRules = async () => { + setNumberingRulesLoading(true); + try { + const response = await getNumberingRules(); + if (response.success && response.data) { + setNumberingRules(response.data); + console.log(`✅ 채번 규칙 ${response.data.length}개 로딩 완료`); + } else { + console.error("❌ 채번 규칙 로딩 실패:", response.error); + setNumberingRules([]); + } + } catch (error) { + console.error("❌ 채번 규칙 로딩 오류:", error); + setNumberingRules([]); + } finally { + setNumberingRulesLoading(false); + } + }; + + loadNumberingRules(); + }, []); + // 🔥 외부 테이블 변경 시 컬럼 로드 useEffect(() => { if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) { @@ -540,6 +572,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP sourceField: null, targetField: "", staticValue: undefined, + valueType: "source" as const, // 🔥 기본값: 소스 필드 }, ]; setFieldMappings(newMappings); @@ -548,6 +581,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP // Combobox 열림 상태 배열 초기화 setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false)); setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false)); + setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false)); }; const handleRemoveMapping = (index: number) => { @@ -558,6 +592,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP // Combobox 열림 상태 배열도 업데이트 setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false)); setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false)); + setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false)); }; const handleMappingChange = (index: number, field: string, value: any) => { @@ -586,6 +621,24 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP targetField: value, targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value, }; + } else if (field === "valueType") { + // 🔥 값 생성 유형 변경 시 관련 필드 초기화 + newMappings[index] = { + ...newMappings[index], + valueType: value, + // 유형 변경 시 다른 유형의 값 초기화 + ...(value !== "source" && { sourceField: null, sourceFieldLabel: undefined }), + ...(value !== "static" && { staticValue: undefined }), + ...(value !== "autoGenerate" && { numberingRuleId: undefined, numberingRuleName: undefined }), + }; + } else if (field === "numberingRuleId") { + // 🔥 채번 규칙 선택 시 이름도 함께 저장 + const selectedRule = numberingRules.find((r) => r.ruleId === value); + newMappings[index] = { + ...newMappings[index], + numberingRuleId: value, + numberingRuleName: selectedRule?.ruleName, + }; } else { newMappings[index] = { ...newMappings[index], @@ -1165,54 +1218,203 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
- {/* 소스 필드 입력/선택 */} + {/* 🔥 값 생성 유형 선택 */}
- - {hasRestAPISource ? ( - // REST API 소스인 경우: 직접 입력 + +
+ + + +
+
+ + {/* 🔥 소스 필드 입력/선택 (valueType === "source" 일 때만) */} + {(mapping.valueType === "source" || !mapping.valueType) && ( +
+ + {hasRestAPISource ? ( + // REST API 소스인 경우: 직접 입력 + handleMappingChange(index, "sourceField", e.target.value || null)} + placeholder="필드명 입력 (예: userId, userName)" + className="mt-1 h-8 text-xs" + /> + ) : ( + // 일반 소스인 경우: Combobox 선택 + { + const newState = [...mappingSourceFieldsOpenState]; + newState[index] = open; + setMappingSourceFieldsOpenState(newState); + }} + > + + + + + + + + + 필드를 찾을 수 없습니다. + + + {sourceFields.map((field) => ( + { + handleMappingChange(index, "sourceField", currentValue || null); + const newState = [...mappingSourceFieldsOpenState]; + newState[index] = false; + setMappingSourceFieldsOpenState(newState); + }} + className="text-xs sm:text-sm" + > + +
+ {field.label || field.name} + {field.label && field.label !== field.name && ( + + {field.name} + + )} +
+
+ ))} +
+
+
+
+
+ )} + {hasRestAPISource && ( +

API 응답 JSON의 필드명을 입력하세요

+ )} +
+ )} + + {/* 🔥 고정값 입력 (valueType === "static" 일 때) */} + {mapping.valueType === "static" && ( +
+ handleMappingChange(index, "sourceField", e.target.value || null)} - placeholder="필드명 입력 (예: userId, userName)" + value={mapping.staticValue || ""} + onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)} + placeholder="고정값 입력" className="mt-1 h-8 text-xs" /> - ) : ( - // 일반 소스인 경우: Combobox 선택 +
+ )} + + {/* 🔥 채번 규칙 선택 (valueType === "autoGenerate" 일 때) */} + {mapping.valueType === "autoGenerate" && ( +
+ { - const newState = [...mappingSourceFieldsOpenState]; + const newState = [...mappingNumberingRulesOpenState]; newState[index] = open; - setMappingSourceFieldsOpenState(newState); + setMappingNumberingRulesOpenState(newState); }} > @@ -1222,37 +1424,36 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP align="start" > - + - 필드를 찾을 수 없습니다. + 채번 규칙을 찾을 수 없습니다. - {sourceFields.map((field) => ( + {numberingRules.map((rule) => ( { - handleMappingChange(index, "sourceField", currentValue || null); - const newState = [...mappingSourceFieldsOpenState]; + handleMappingChange(index, "numberingRuleId", currentValue); + const newState = [...mappingNumberingRulesOpenState]; newState[index] = false; - setMappingSourceFieldsOpenState(newState); + setMappingNumberingRulesOpenState(newState); }} className="text-xs sm:text-sm" >
- {field.label || field.name} - {field.label && field.label !== field.name && ( - - {field.name} - - )} + {rule.ruleName} + + {rule.ruleId} + {rule.tableName && ` - ${rule.tableName}`} +
))} @@ -1261,11 +1462,13 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
- )} - {hasRestAPISource && ( -

API 응답 JSON의 필드명을 입력하세요

- )} -
+ {numberingRules.length === 0 && !numberingRulesLoading && ( +

+ 등록된 채번 규칙이 없습니다. 시스템 관리에서 먼저 채번 규칙을 생성하세요. +

+ )} +
+ )}
@@ -1400,18 +1603,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
- - {/* 정적 값 */} -
- - handleMappingChange(index, "staticValue", e.target.value || undefined)} - placeholder="소스 필드 대신 고정 값 사용" - className="mt-1 h-8 text-xs" - /> -

소스 필드가 비어있을 때만 사용됩니다

-
))} @@ -1428,9 +1619,8 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP {/* 안내 */}
- ✅ 테이블과 필드는 실제 데이터베이스에서 조회됩니다. -
- 💡 소스 필드가 없으면 정적 값이 사용됩니다. +

테이블과 필드는 실제 데이터베이스에서 조회됩니다.

+

값 생성 방식: 소스 필드(입력값 연결) / 고정값(직접 입력) / 자동생성(채번 규칙)

diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index c1b644cc..b3c94ade 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -671,9 +671,11 @@ export const EditModal: React.FC = ({ className }) => { console.log("🗑️ 품목 삭제:", deletedItem); try { + // screenId 전달하여 제어관리 실행 가능하도록 함 const response = await dynamicFormApi.deleteFormDataFromTable( deletedItem.id, screenData.screenInfo.tableName, + modalState.screenId || screenData.screenInfo?.id, ); if (response.success) { diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index f786d1d1..9a0ffa8d 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -1676,7 +1676,8 @@ export const InteractiveScreenViewer: React.FC = ( try { // console.log("🗑️ 삭제 실행:", { recordId, tableName, formData }); - const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName); + // screenId 전달하여 제어관리 실행 가능하도록 함 + const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName, screenInfo?.id); if (result.success) { alert("삭제되었습니다."); diff --git a/frontend/lib/api/dynamicForm.ts b/frontend/lib/api/dynamicForm.ts index f28704cf..57fb1a8d 100644 --- a/frontend/lib/api/dynamicForm.ts +++ b/frontend/lib/api/dynamicForm.ts @@ -202,14 +202,19 @@ export class DynamicFormApi { * 실제 테이블에서 폼 데이터 삭제 * @param id 레코드 ID * @param tableName 테이블명 + * @param screenId 화면 ID (제어관리 실행용, 선택사항) * @returns 삭제 결과 */ - static async deleteFormDataFromTable(id: string | number, tableName: string): Promise> { + static async deleteFormDataFromTable( + id: string | number, + tableName: string, + screenId?: number + ): Promise> { try { - console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName }); + console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName, screenId }); await apiClient.delete(`/dynamic-form/${id}`, { - data: { tableName }, + data: { tableName, screenId }, }); console.log("✅ 실제 테이블에서 폼 데이터 삭제 성공"); diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index c65fba89..27f76c79 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -967,11 +967,11 @@ export class ButtonActionExecutor { deletedItemIds, }); - // 삭제 API 호출 + // 삭제 API 호출 - screenId 전달하여 제어관리 실행 가능하도록 함 for (const itemId of deletedItemIds) { try { console.log(`🗑️ [handleSave] 항목 삭제 중: ${itemId} from ${targetTable}`); - const deleteResult = await DynamicFormApi.deleteFormDataFromTable(itemId, targetTable); + const deleteResult = await DynamicFormApi.deleteFormDataFromTable(itemId, targetTable, context.screenId); if (deleteResult.success) { console.log(`✅ [handleSave] 항목 삭제 완료: ${itemId}`); } else { @@ -1967,7 +1967,8 @@ export class ButtonActionExecutor { for (const deletedItem of deletedItems) { console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`); - const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deletedItem.id, saveTableName); + // screenId 전달하여 제어관리 실행 가능하도록 함 + const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deletedItem.id, saveTableName, context.screenId); if (!deleteResult.success) { throw new Error(deleteResult.message || "품목 삭제 실패"); @@ -2434,7 +2435,8 @@ export class ButtonActionExecutor { if (deleteId) { console.log("다중 데이터 삭제:", { tableName, screenId, id: deleteId }); - const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deleteId, tableName); + // screenId 전달하여 제어관리 실행 가능하도록 함 + const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deleteId, tableName, screenId); if (!deleteResult.success) { throw new Error(`ID ${deleteId} 삭제 실패: ${deleteResult.message}`); } @@ -2469,8 +2471,8 @@ export class ButtonActionExecutor { if (tableName && screenId && formData.id) { console.log("단일 데이터 삭제:", { tableName, screenId, id: formData.id }); - // 실제 삭제 API 호출 - const deleteResult = await DynamicFormApi.deleteFormDataFromTable(formData.id, tableName); + // 실제 삭제 API 호출 - screenId 전달하여 제어관리 실행 가능하도록 함 + const deleteResult = await DynamicFormApi.deleteFormDataFromTable(formData.id, tableName, screenId); if (!deleteResult.success) { throw new Error(deleteResult.message || "삭제에 실패했습니다."); @@ -4251,7 +4253,8 @@ export class ButtonActionExecutor { throw new Error("삭제할 항목의 ID를 찾을 수 없습니다."); } - const result = await DynamicFormApi.deleteFormDataFromTable(deleteId, context.tableName); + // screenId 전달하여 제어관리 실행 가능하도록 함 + const result = await DynamicFormApi.deleteFormDataFromTable(deleteId, context.tableName, context.screenId); if (result.success) { console.log("✅ 삭제 성공:", result); diff --git a/frontend/types/node-editor.ts b/frontend/types/node-editor.ts index 6eb1bb1c..9c7d5c5e 100644 --- a/frontend/types/node-editor.ts +++ b/frontend/types/node-editor.ts @@ -344,6 +344,11 @@ export interface InsertActionNodeData { targetField: string; targetFieldLabel?: string; staticValue?: any; + // 🔥 값 생성 유형 추가 + valueType?: "source" | "static" | "autoGenerate"; // 소스 필드 / 고정값 / 자동 생성 + // 자동 생성 옵션 (valueType === "autoGenerate" 일 때) + numberingRuleId?: string; // 채번 규칙 ID + numberingRuleName?: string; // 채번 규칙명 (표시용) }>; options: { batchSize?: number; -- 2.43.0 From aa0698556e65afa9f8199d961ffaa4532bd217c4 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 9 Jan 2026 15:32:02 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=EC=97=91=EC=85=80=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C,=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/routes/dataRoutes.ts | 63 + .../src/services/masterDetailExcelService.ts | 273 +++- .../src/services/tableManagementService.ts | 65 +- .../components/common/ExcelUploadModal.tsx | 311 ++++- .../config-panels/ButtonConfigPanel.tsx | 1197 ++++++++++++----- frontend/lib/api/dynamicForm.ts | 55 + .../SplitPanelLayoutComponent.tsx | 7 + .../SplitPanelLayoutConfigPanel.tsx | 1 + frontend/lib/utils/buttonActions.ts | 34 +- 9 files changed, 1619 insertions(+), 387 deletions(-) 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" + /> + )} +
+ ))} +
+ )} + {/* 파일 선택 영역 */}