From cea3aa53aee2337c31242918944c9a56a18c1a12 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 9 Jan 2026 11:21:16 +0900 Subject: [PATCH] =?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?.();