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/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index f9d88d92..574f1cf8 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -1,10 +1,262 @@ 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, + }); + } + } +); + +/** + * 마스터-디테일 간단 모드 엑셀 업로드 + * - 마스터 정보는 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, afterUploadFlowId, afterUploadFlows } = 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); + console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}개` : afterUploadFlowId || "없음"); + + // 업로드 실행 + const result = await masterDetailExcelService.uploadSimple( + parseInt(screenId), + detailData, + masterFieldValues || {}, + numberingRuleId, + companyCode, + userId, + afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성) + afterUploadFlows // 업로드 후 제어 실행 (다중) + ); + + 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 +// ================================ + /** * 조인 데이터 조회 API (다른 라우트보다 먼저 정의) * GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=... 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/masterDetailExcelService.ts b/backend-node/src/services/masterDetailExcelService.ts new file mode 100644 index 00000000..4b1a7218 --- /dev/null +++ b/backend-node/src/services/masterDetailExcelService.ts @@ -0,0 +1,868 @@ +/** + * 마스터-디테일 엑셀 처리 서비스 + * + * 분할 패널 화면의 마스터-디테일 구조를 자동 감지하고 + * 엑셀 다운로드/업로드 시 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; + + // 조인 컬럼과 일반 컬럼 분리 + // 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name) + const entityJoins: Array<{ + refTable: string; + refColumn: string; + sourceColumn: string; + alias: string; + displayColumn: string; + }> = []; + + // SELECT 절 구성 + 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[] = []; + 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 !== "") { + // 조인 컬럼인지 확인 + if (key.includes(".")) continue; + // 마스터 테이블 컬럼인지 확인 + 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 + ${entityJoinClauses} + ${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; + } + } + + /** + * 특정 테이블에서 참조 테이블로의 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; + } + } + + /** + * 마스터-디테일 데이터 업로드 (엑셀 업로드용) + * + * 처리 로직: + * 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; + } + + /** + * 마스터-디테일 간단 모드 업로드 + * + * 마스터 정보는 UI에서 선택하고, 엑셀은 디테일 데이터만 포함 + * 채번 규칙을 통해 마스터 키 자동 생성 + * + * @param screenId 화면 ID + * @param detailData 디테일 데이터 배열 + * @param masterFieldValues UI에서 선택한 마스터 필드 값 + * @param numberingRuleId 채번 규칙 ID (optional) + * @param companyCode 회사 코드 + * @param userId 사용자 ID + * @param afterUploadFlowId 업로드 후 실행할 노드 플로우 ID (optional, 하위 호환성) + * @param afterUploadFlows 업로드 후 실행할 노드 플로우 배열 (optional) + */ + async uploadSimple( + screenId: number, + detailData: Record[], + masterFieldValues: Record, + numberingRuleId: string | undefined, + companyCode: string, + userId: string, + afterUploadFlowId?: string, + afterUploadFlows?: Array<{ flowId: string; order: number }> + ): Promise<{ + success: boolean; + masterInserted: number; + detailInserted: number; + generatedKey: string; + errors: string[]; + controlResult?: any; + }> { + const result: { + success: boolean; + masterInserted: number; + detailInserted: number; + generatedKey: string; + errors: string[]; + controlResult?: any; + } = { + 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, + }); + + // 업로드 후 제어 실행 (단일 또는 다중) + const flowsToExecute = afterUploadFlows && afterUploadFlows.length > 0 + ? afterUploadFlows // 다중 제어 + : afterUploadFlowId + ? [{ flowId: afterUploadFlowId, order: 1 }] // 단일 (하위 호환성) + : []; + + if (flowsToExecute.length > 0 && result.success) { + try { + const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService"); + + // 마스터 데이터를 제어에 전달 + const masterData = { + ...masterFieldValues, + [relation!.masterKeyColumn]: result.generatedKey, + company_code: companyCode, + }; + + const controlResults: any[] = []; + + // 순서대로 제어 실행 + for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) { + logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`); + + const controlResult = await NodeFlowExecutionService.executeFlow( + parseInt(flow.flowId), + { + sourceData: [masterData], + dataSourceType: "formData", + buttonId: "excel-upload-button", + screenId: screenId, + userId: userId, + companyCode: companyCode, + formData: masterData, + } + ); + + controlResults.push({ + flowId: flow.flowId, + order: flow.order, + success: controlResult.success, + message: controlResult.message, + executedNodes: controlResult.nodes?.length || 0, + }); + } + + result.controlResult = { + success: controlResults.every(r => r.success), + executedFlows: controlResults.length, + results: controlResults, + }; + + logger.info(`업로드 후 제어 실행 완료: ${controlResults.length}개 실행`, result.controlResult); + } catch (controlError: any) { + logger.error(`업로드 후 제어 실행 실패:`, controlError); + result.controlResult = { + success: false, + message: `제어 실행 실패: ${controlError.message}`, + }; + } + } + + } 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/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/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 6ae8f696..70ed6205 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 867b6f85..6785eac8 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; @@ -42,6 +71,19 @@ 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 }>; + }; + // 🆕 마스터-디테일 엑셀 업로드 설정 + masterDetailExcelConfig?: MasterDetailExcelConfig; } interface ColumnMapping { @@ -57,6 +99,10 @@ export const ExcelUploadModal: React.FC = ({ keyColumn, onSuccess, userId = "guest", + screenId, + isMasterDetail = false, + masterDetailRelation, + masterDetailExcelConfig, }) => { const [currentStep, setCurrentStep] = useState(1); @@ -79,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]; @@ -184,50 +340,138 @@ export const ExcelUploadModal: React.FC = ({ const loadTableSchema = async () => { try { - console.log("🔍 테이블 스키마 로드 시작:", { tableName }); + console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail, isSimpleMasterDetailMode }); - const response = await getTableSchema(tableName); + let allColumns: TableColumn[] = []; - console.log("📊 테이블 스키마 응답:", response); + // 🆕 마스터-디테일 간단 모드: 디테일 테이블 컬럼만 로드 (마스터 필드는 UI에서 선택) + if (isSimpleMasterDetailMode && masterDetailRelation) { + const { detailTable, detailFkColumn } = masterDetailRelation; + + console.log("📊 마스터-디테일 간단 모드 스키마 로드 (디테일만):", { 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); + // 디테일 테이블 스키마만 로드 (마스터 정보는 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 }); + + // 마스터 테이블 스키마 + 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 +483,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 { @@ -285,6 +546,12 @@ export const ExcelUploadModal: React.FC = ({ return; } + // 🆕 마스터-디테일 간단 모드: 마스터 필드 유효성 검사 + if (currentStep === 1 && hasMasterSelectFields && !isMasterFieldsValid()) { + toast.error("마스터 정보를 모두 입력해주세요."); + return; + } + // 1단계 → 2단계 전환 시: 빈 헤더 열 제외 if (currentStep === 1) { // 빈 헤더가 아닌 열만 필터링 @@ -344,7 +611,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 +636,96 @@ export const ExcelUploadModal: React.FC = ({ `📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행` ); - let successCount = 0; - let failCount = 0; + // 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번) + if (isSimpleMasterDetailMode && screenId && masterDetailRelation) { + console.log("📊 마스터-디테일 간단 모드 업로드:", { + masterDetailRelation, + masterFieldValues, + numberingRuleId: masterDetailExcelConfig?.numberingRuleId, + }); - 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.uploadMasterDetailSimple( + screenId, + filteredData, + masterFieldValues, + masterDetailExcelConfig?.numberingRuleId || undefined, + masterDetailExcelConfig?.afterUploadFlowId || undefined, // 하위 호환성 + masterDetailExcelConfig?.afterUploadFlows || undefined // 다중 제어 ); - // 매핑 템플릿 저장 (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, detailInserted, generatedKey, errors } = uploadResult.data; + + toast.success( + `마스터 ${masterInserted}건(${generatedKey || ""}), 디테일 ${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 if (isMasterDetail && screenId && masterDetailRelation) { + console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation); + + const uploadResult = await DynamicFormApi.uploadMasterDetailData( + screenId, + filteredData + ); + + if (uploadResult.success && uploadResult.data) { + const { masterInserted, masterUpdated, detailInserted, errors } = uploadResult.data; + + toast.success( + `마스터 ${masterInserted + masterUpdated}건, 디테일 ${detailInserted}건 처리되었습니다.` + + (errors.length > 0 ? ` (오류: ${errors.length}건)` : "") + ); + + // 매핑 템플릿 저장 + 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 +735,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) { @@ -441,6 +778,8 @@ export const ExcelUploadModal: React.FC = ({ setExcelColumns([]); setSystemColumns([]); setColumnMappings([]); + // 🆕 마스터-디테일 모드 초기화 + setMasterFieldValues({}); } }, [open]); @@ -461,9 +800,21 @@ export const ExcelUploadModal: React.FC = ({ 엑셀 데이터 업로드 + {isMasterDetail && ( + + 마스터-디테일 + + )} - 엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요. + {isMasterDetail && masterDetailRelation ? ( + <> + 마스터({masterDetailRelation.masterTable}) + 디테일({masterDetailRelation.detailTable}) 구조입니다. + 마스터 데이터는 중복 입력 시 병합됩니다. + + ) : ( + "엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요." + )} @@ -518,6 +869,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" + /> + )} +
+ ))} +
+ )} + {/* 파일 선택 영역 */}
- {/* 소스 필드 입력/선택 */} + {/* 🔥 값 생성 유형 선택 */}
- - {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/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index a97d78b3..8d8c4df9 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; +import { Checkbox } from "@/components/ui/checkbox"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Button } from "@/components/ui/button"; @@ -175,9 +176,7 @@ export const ButtonConfigPanel: React.FC = ({ }; const updateBlock = (id: string, updates: Partial) => { - const updatedBlocks = titleBlocks.map((block) => - block.id === id ? { ...block, ...updates } : block - ); + const updatedBlocks = titleBlocks.map((block) => (block.id === id ? { ...block, ...updates } : block)); setTitleBlocks(updatedBlocks); onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks); }; @@ -225,45 +224,45 @@ export const ButtonConfigPanel: React.FC = ({ const fetchAllTables = async () => { try { const response = await apiClient.get("/table-management/tables"); - + if (response.data.success && response.data.data) { const tables = response.data.data.map((table: any) => ({ name: table.tableName, label: table.displayName || table.tableName, })); setAvailableTables(tables); - console.log(`✅ 전체 테이블 목록 로드 성공:`, tables.length); + console.log("✅ 전체 테이블 목록 로드 성공:", tables.length); } } catch (error) { console.error("테이블 목록 로드 실패:", error); } }; - + fetchAllTables(); }, []); // 🆕 특정 테이블의 컬럼 로드 const loadTableColumns = async (tableName: string) => { if (!tableName || tableColumnsMap[tableName]) return; - + try { const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); console.log(`📥 테이블 ${tableName} 컬럼 응답:`, response.data); - + if (response.data.success) { // data가 배열인지 확인 let columnData = response.data.data; - + // data.columns 형태일 수도 있음 if (!Array.isArray(columnData) && columnData?.columns) { columnData = columnData.columns; } - + // data.data 형태일 수도 있음 if (!Array.isArray(columnData) && columnData?.data) { columnData = columnData.data; } - + if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => { const name = col.name || col.columnName; @@ -286,7 +285,7 @@ export const ButtonConfigPanel: React.FC = ({ useEffect(() => { const sourceTable = config.action?.dataTransfer?.sourceTable; const targetTable = config.action?.dataTransfer?.targetTable; - + const loadColumns = async () => { if (sourceTable) { try { @@ -295,7 +294,7 @@ export const ButtonConfigPanel: React.FC = ({ let columnData = response.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - + if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ name: col.name || col.columnName, @@ -308,7 +307,7 @@ export const ButtonConfigPanel: React.FC = ({ console.error("소스 테이블 컬럼 로드 실패:", error); } } - + if (targetTable) { try { const response = await apiClient.get(`/table-management/tables/${targetTable}/columns`); @@ -316,7 +315,7 @@ export const ButtonConfigPanel: React.FC = ({ let columnData = response.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - + if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ name: col.name || col.columnName, @@ -330,7 +329,7 @@ export const ButtonConfigPanel: React.FC = ({ } } }; - + loadColumns(); }, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]); @@ -371,45 +370,43 @@ export const ButtonConfigPanel: React.FC = ({ const loadModalMappingColumns = async () => { // 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지 let sourceTableName: string | null = null; - + console.log("[openModalWithData] 컬럼 로드 시작:", { allComponentsCount: allComponents.length, currentTableName, targetScreenId: config.action?.targetScreenId, }); - + // 모든 컴포넌트 타입 로그 allComponents.forEach((comp, idx) => { const compType = comp.componentType || (comp as any).componentConfig?.type; - console.log(` [${idx}] componentType: ${compType}, tableName: ${(comp as any).componentConfig?.tableName || (comp as any).componentConfig?.leftPanel?.tableName || 'N/A'}`); + console.log( + ` [${idx}] componentType: ${compType}, tableName: ${(comp as any).componentConfig?.tableName || (comp as any).componentConfig?.leftPanel?.tableName || "N/A"}`, + ); }); - + for (const comp of allComponents) { const compType = comp.componentType || (comp as any).componentConfig?.type; const compConfig = (comp as any).componentConfig || {}; - + // 분할 패널 타입들 (다양한 경로에서 테이블명 추출) if (compType === "split-panel-layout" || compType === "screen-split-panel") { - sourceTableName = compConfig?.leftPanel?.tableName || - compConfig?.leftTableName || - compConfig?.tableName; + sourceTableName = compConfig?.leftPanel?.tableName || compConfig?.leftTableName || compConfig?.tableName; if (sourceTableName) { console.log(`✅ [openModalWithData] split-panel-layout에서 소스 테이블 감지: ${sourceTableName}`); break; } } - + // split-panel-layout2 타입 (새로운 분할 패널) if (compType === "split-panel-layout2") { - sourceTableName = compConfig?.leftPanel?.tableName || - compConfig?.tableName || - compConfig?.leftTableName; + sourceTableName = compConfig?.leftPanel?.tableName || compConfig?.tableName || compConfig?.leftTableName; if (sourceTableName) { console.log(`✅ [openModalWithData] split-panel-layout2에서 소스 테이블 감지: ${sourceTableName}`); break; } } - + // 테이블 리스트 타입 if (compType === "table-list") { sourceTableName = compConfig?.tableName; @@ -418,7 +415,7 @@ export const ButtonConfigPanel: React.FC = ({ break; } } - + // 🆕 모든 컴포넌트에서 tableName 찾기 (폴백) if (!sourceTableName && compConfig?.tableName) { sourceTableName = compConfig.tableName; @@ -426,13 +423,13 @@ export const ButtonConfigPanel: React.FC = ({ break; } } - + // 여전히 없으면 currentTableName 사용 (화면 레벨 테이블명) if (!sourceTableName && currentTableName) { sourceTableName = currentTableName; console.log(`✅ [openModalWithData] currentTableName에서 소스 테이블 사용: ${sourceTableName}`); } - + if (!sourceTableName) { console.warn("[openModalWithData] 소스 테이블을 찾을 수 없습니다."); } @@ -445,11 +442,18 @@ export const ButtonConfigPanel: React.FC = ({ let columnData = response.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - + if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ name: col.name || col.columnName || col.column_name, - label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name, + label: + col.displayName || + col.label || + col.columnLabel || + col.display_name || + col.name || + col.columnName || + col.column_name, })); setModalSourceColumns(columns); console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드 완료:`, columns.length); @@ -467,22 +471,29 @@ export const ButtonConfigPanel: React.FC = ({ // 타겟 화면 정보 가져오기 const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`); console.log("[openModalWithData] 타겟 화면 응답:", screenResponse.data); - + if (screenResponse.data.success && screenResponse.data.data) { const targetTableName = screenResponse.data.data.tableName; console.log("[openModalWithData] 타겟 화면 테이블명:", targetTableName); - + if (targetTableName) { const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`); if (columnResponse.data.success) { let columnData = columnResponse.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - + if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ name: col.name || col.columnName || col.column_name, - label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name, + label: + col.displayName || + col.label || + col.columnLabel || + col.display_name || + col.name || + col.columnName || + col.column_name, })); setModalTargetColumns(columns); console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드 완료:`, columns.length); @@ -513,12 +524,12 @@ export const ButtonConfigPanel: React.FC = ({ page: 1, size: 9999, // 매우 큰 값으로 설정하여 전체 목록 가져오기 }; - + // 현재 화면의 회사 코드가 있으면 필터링 파라미터로 전달 if (currentScreenCompanyCode) { params.companyCode = currentScreenCompanyCode; } - + const response = await apiClient.get("/screen-management/screens", { params, }); @@ -698,8 +709,8 @@ export const ButtonConfigPanel: React.FC = ({ {/* 모달 열기 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "modal" && ( -
-

모달 설정

+
+

모달 설정

@@ -727,7 +738,7 @@ export const ButtonConfigPanel: React.FC = ({ onUpdateProperty("componentConfig.action.modalDescription", newValue); }} /> -

모달 제목 아래에 표시됩니다

+

모달 제목 아래에 표시됩니다

@@ -784,15 +795,15 @@ export const ButtonConfigPanel: React.FC = ({ {(() => { const filteredScreens = filterScreens(modalSearchTerm); if (screensLoading) { - return
화면 목록을 불러오는 중...
; + return
화면 목록을 불러오는 중...
; } if (filteredScreens.length === 0) { - return
검색 결과가 없습니다.
; + return
검색 결과가 없습니다.
; } return filteredScreens.map((screen, index) => (
{ onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setModalScreenOpen(false); @@ -807,7 +818,9 @@ export const ButtonConfigPanel: React.FC = ({ />
{screen.name} - {screen.description && {screen.description}} + {screen.description && ( + {screen.description} + )}
)); @@ -823,10 +836,8 @@ export const ButtonConfigPanel: React.FC = ({ {/* 🆕 데이터 전달 + 모달 열기 액션 설정 */} {component.componentConfig?.action?.type === "openModalWithData" && (
-

데이터 전달 + 모달 설정

-

- TableList에서 선택된 데이터를 다음 모달로 전달합니다 -

+

데이터 전달 + 모달 설정

+

TableList에서 선택된 데이터를 다음 모달로 전달합니다

@@ -856,23 +869,11 @@ export const ButtonConfigPanel: React.FC = ({
- - @@ -882,7 +883,7 @@ export const ButtonConfigPanel: React.FC = ({ {/* 블록 목록 */}
{titleBlocks.length === 0 ? ( -
+
텍스트나 필드를 추가하여 제목을 구성하세요
) : ( @@ -914,7 +915,7 @@ export const ButtonConfigPanel: React.FC = ({
{/* 블록 타입 표시 */} -
+
{block.type === "text" ? ( ) : ( @@ -949,15 +950,15 @@ export const ButtonConfigPanel: React.FC = ({ className="h-7 w-full justify-between text-xs" > {block.tableName - ? (availableTables.find((t) => t.name === block.tableName)?.label || block.tableName) + ? availableTables.find((t) => t.name === block.tableName)?.label || block.tableName : "테이블 선택"} - { @@ -991,11 +992,13 @@ export const ButtonConfigPanel: React.FC = ({ {table.label} - ({table.name}) + + ({table.name}) + ))} @@ -1020,15 +1023,16 @@ export const ButtonConfigPanel: React.FC = ({ className="h-7 w-full justify-between text-xs" > {block.value - ? (tableColumnsMap[block.tableName]?.find((c) => c.name === block.value)?.label || block.value) + ? tableColumnsMap[block.tableName]?.find((c) => c.name === block.value) + ?.label || block.value : "컬럼 선택"} - { @@ -1064,11 +1068,13 @@ export const ButtonConfigPanel: React.FC = ({ {col.label} - ({col.name}) + + ({col.name}) + ))} @@ -1107,17 +1113,19 @@ export const ButtonConfigPanel: React.FC = ({ {/* 미리보기 */} {titleBlocks.length > 0 && ( -
+
미리보기: {generateTitlePreview()}
)} -

- • 텍스트: 고정 텍스트 입력 (예: "품목 상세정보 - ")
- • 필드: 이전 화면 데이터로 자동 채워짐 (예: 품목명, 규격)
- • 순서 변경: ↑↓ 버튼으로 자유롭게 배치
- • 데이터가 없으면 "표시 라벨"이 대신 표시됩니다 +

+ • 텍스트: 고정 텍스트 입력 (예: "품목 상세정보 - ") +
+ • 필드: 이전 화면 데이터로 자동 채워짐 (예: 품목명, 규격) +
+ • 순서 변경: ↑↓ 버튼으로 자유롭게 배치 +
• 데이터가 없으면 "표시 라벨"이 대신 표시됩니다

@@ -1175,15 +1183,15 @@ export const ButtonConfigPanel: React.FC = ({ {(() => { const filteredScreens = filterScreens(modalSearchTerm); if (screensLoading) { - return
화면 목록을 불러오는 중...
; + return
화면 목록을 불러오는 중...
; } if (filteredScreens.length === 0) { - return
검색 결과가 없습니다.
; + return
검색 결과가 없습니다.
; } return filteredScreens.map((screen, index) => (
{ onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setModalScreenOpen(false); @@ -1198,7 +1206,9 @@ export const ButtonConfigPanel: React.FC = ({ />
{screen.name} - {screen.description && {screen.description}} + {screen.description && ( + {screen.description} + )}
)); @@ -1207,7 +1217,7 @@ export const ButtonConfigPanel: React.FC = ({
-

+

SelectedItemsDetailInput 컴포넌트가 있는 화면을 선택하세요

@@ -1231,7 +1241,7 @@ export const ButtonConfigPanel: React.FC = ({ 매핑 추가
-

+

소스 테이블의 컬럼명이 타겟 화면의 입력 필드 컬럼명과 다를 때 매핑을 설정하세요.
예: warehouse_code → warehouse_id (분할 패널의 창고코드를 모달의 창고ID에 매핑) @@ -1239,41 +1249,36 @@ export const ButtonConfigPanel: React.FC = ({ {/* 컬럼 로드 상태 표시 */} {modalSourceColumns.length > 0 || modalTargetColumns.length > 0 ? ( -

+
소스 컬럼: {modalSourceColumns.length}개 / 타겟 컬럼: {modalTargetColumns.length}개
) : ( -
+
분할 패널 또는 테이블 컴포넌트와 대상 화면을 설정하면 컬럼 목록이 로드됩니다.
)} {(config.action?.fieldMappings || []).length === 0 ? (
-

- 매핑이 없으면 같은 이름의 컬럼끼리 자동으로 매핑됩니다. -

+

매핑이 없으면 같은 이름의 컬럼끼리 자동으로 매핑됩니다.

) : (
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => ( -
+
{/* 소스 필드 선택 (Combobox) - 세로 배치 */}
- + setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))} > -
@@ -1416,8 +1418,8 @@ export const ButtonConfigPanel: React.FC = ({ {/* 수정 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "edit" && ( -
-

수정 설정

+
+

수정 설정

@@ -1453,15 +1455,15 @@ export const ButtonConfigPanel: React.FC = ({ {(() => { const filteredScreens = filterScreens(modalSearchTerm); if (screensLoading) { - return
화면 목록을 불러오는 중...
; + return
화면 목록을 불러오는 중...
; } if (filteredScreens.length === 0) { - return
검색 결과가 없습니다.
; + return
검색 결과가 없습니다.
; } return filteredScreens.map((screen, index) => (
{ onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setModalScreenOpen(false); @@ -1476,7 +1478,9 @@ export const ButtonConfigPanel: React.FC = ({ />
{screen.name} - {screen.description && {screen.description}} + {screen.description && ( + {screen.description} + )}
)); @@ -1485,7 +1489,7 @@ export const ButtonConfigPanel: React.FC = ({
-

+

선택된 데이터가 이 폼 화면에 자동으로 로드되어 수정할 수 있습니다

@@ -1524,7 +1528,7 @@ export const ButtonConfigPanel: React.FC = ({ onUpdateProperty("webTypeConfig.editModalTitle", newValue); }} /> -

비워두면 기본 제목이 표시됩니다

+

비워두면 기본 제목이 표시됩니다

@@ -1540,7 +1544,7 @@ export const ButtonConfigPanel: React.FC = ({ onUpdateProperty("webTypeConfig.editModalDescription", newValue); }} /> -

비워두면 설명이 표시되지 않습니다

+

비워두면 설명이 표시되지 않습니다

@@ -1580,8 +1584,9 @@ export const ButtonConfigPanel: React.FC = ({ {localInputs.groupByColumn} {currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label && - currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label !== localInputs.groupByColumn && ( - + currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label !== + localInputs.groupByColumn && ( + ({currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label}) )} @@ -1605,14 +1610,14 @@ export const ButtonConfigPanel: React.FC = ({
{currentTableColumns.length === 0 ? ( -
+
{currentTableName ? "컬럼을 불러오는 중..." : "테이블이 설정되지 않았습니다"}
) : ( <> {/* 선택 해제 옵션 */}
{ setLocalInputs((prev) => ({ ...prev, groupByColumn: "" })); onUpdateProperty("componentConfig.action.groupByColumns", undefined); @@ -1620,7 +1625,9 @@ export const ButtonConfigPanel: React.FC = ({ setGroupByColumnSearch(""); }} > - + 선택 안 함
{/* 컬럼 목록 */} @@ -1628,15 +1635,12 @@ export const ButtonConfigPanel: React.FC = ({ .filter((col) => { if (!groupByColumnSearch) return true; const search = groupByColumnSearch.toLowerCase(); - return ( - col.name.toLowerCase().includes(search) || - col.label.toLowerCase().includes(search) - ); + return col.name.toLowerCase().includes(search) || col.label.toLowerCase().includes(search); }) .map((col) => (
{ setLocalInputs((prev) => ({ ...prev, groupByColumn: col.name })); onUpdateProperty("componentConfig.action.groupByColumns", [col.name]); @@ -1645,12 +1649,15 @@ export const ButtonConfigPanel: React.FC = ({ }} >
{col.name} {col.label !== col.name && ( - {col.label} + {col.label} )}
@@ -1661,9 +1668,7 @@ export const ButtonConfigPanel: React.FC = ({
-

- 여러 행을 하나의 그룹으로 묶어서 수정할 때 사용합니다 -

+

여러 행을 하나의 그룹으로 묶어서 수정할 때 사용합니다

)} @@ -1671,7 +1676,7 @@ export const ButtonConfigPanel: React.FC = ({ {/* 복사 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "copy" && (
-

복사 설정 (품목코드 자동 초기화)

+

복사 설정 (품목코드 자동 초기화)

@@ -1707,15 +1712,15 @@ export const ButtonConfigPanel: React.FC = ({ {(() => { const filteredScreens = filterScreens(modalSearchTerm); if (screensLoading) { - return
화면 목록을 불러오는 중...
; + return
화면 목록을 불러오는 중...
; } if (filteredScreens.length === 0) { - return
검색 결과가 없습니다.
; + return
검색 결과가 없습니다.
; } return filteredScreens.map((screen, index) => (
{ onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setModalScreenOpen(false); @@ -1730,7 +1735,9 @@ export const ButtonConfigPanel: React.FC = ({ />
{screen.name} - {screen.description && {screen.description}} + {screen.description && ( + {screen.description} + )}
)); @@ -1739,7 +1746,7 @@ export const ButtonConfigPanel: React.FC = ({
-

+

선택된 데이터가 복사되며, 품목코드는 자동으로 초기화됩니다

@@ -1777,7 +1784,7 @@ export const ButtonConfigPanel: React.FC = ({ onUpdateProperty("webTypeConfig.editModalTitle", newValue); }} /> -

비워두면 기본 제목이 표시됩니다

+

비워두면 기본 제목이 표시됩니다

@@ -1793,7 +1800,7 @@ export const ButtonConfigPanel: React.FC = ({ onUpdateProperty("webTypeConfig.editModalDescription", newValue); }} /> -

비워두면 설명이 표시되지 않습니다

+

비워두면 설명이 표시되지 않습니다

@@ -1852,9 +1859,7 @@ export const ButtonConfigPanel: React.FC = ({ - - 컬럼을 찾을 수 없습니다. - + 컬럼을 찾을 수 없습니다. {tableColumns.map((column) => ( = ({ {/* 페이지 이동 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "navigate" && ( -
-

페이지 이동 설정

+
+

페이지 이동 설정

@@ -1923,15 +1928,15 @@ export const ButtonConfigPanel: React.FC = ({ {(() => { const filteredScreens = filterScreens(navSearchTerm); if (screensLoading) { - return
화면 목록을 불러오는 중...
; + return
화면 목록을 불러오는 중...
; } if (filteredScreens.length === 0) { - return
검색 결과가 없습니다.
; + return
검색 결과가 없습니다.
; } return filteredScreens.map((screen, index) => (
{ onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setNavScreenOpen(false); @@ -1946,7 +1951,9 @@ export const ButtonConfigPanel: React.FC = ({ />
{screen.name} - {screen.description && {screen.description}} + {screen.description && ( + {screen.description} + )}
)); @@ -1955,7 +1962,7 @@ export const ButtonConfigPanel: React.FC = ({
-

+

선택한 화면으로 /screens/{"{"}화면ID{"}"} 형태로 이동합니다

@@ -1973,15 +1980,15 @@ export const ButtonConfigPanel: React.FC = ({ }} className="h-6 w-full px-2 py-0 text-xs" /> -

URL을 입력하면 화면 선택보다 우선 적용됩니다

+

URL을 입력하면 화면 선택보다 우선 적용됩니다

)} {/* 엑셀 다운로드 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "excel_download" && ( -
-

엑셀 다운로드 설정

+
+

엑셀 다운로드 설정

@@ -1992,7 +1999,7 @@ export const ButtonConfigPanel: React.FC = ({ onChange={(e) => onUpdateProperty("componentConfig.action.excelFileName", e.target.value)} className="h-8 text-xs" /> -

확장자(.xlsx)는 자동으로 추가됩니다

+

확장자(.xlsx)는 자동으로 추가됩니다

@@ -2019,48 +2026,13 @@ export const ButtonConfigPanel: React.FC = ({ {/* 엑셀 업로드 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "excel_upload" && ( -
-

📤 엑셀 업로드 설정

- -
- - -
- - {(config.action?.excelUploadMode === "update" || config.action?.excelUploadMode === "upsert") && ( -
- - onUpdateProperty("componentConfig.action.excelKeyColumn", e.target.value)} - className="h-8 text-xs" - /> -

UPDATE/UPSERT 시 기준이 되는 컬럼명

-
- )} -
+ )} {/* 바코드 스캔 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "barcode_scan" && ( -
-

📷 바코드 스캔 설정

+
+

📷 바코드 스캔 설정

@@ -2106,8 +2078,8 @@ export const ButtonConfigPanel: React.FC = ({ {/* 코드 병합 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "code_merge" && ( -
-

🔀 코드 병합 설정

+
+

🔀 코드 병합 설정

@@ -2128,7 +2100,7 @@ export const ButtonConfigPanel: React.FC = ({
-

영향받을 테이블과 행 수를 미리 확인합니다

+

영향받을 테이블과 행 수를 미리 확인합니다

= ({ {/* 운행알림 및 종료 설정 */} {(component.componentConfig?.action?.type || "save") === "operation_control" && ( -
-

🚗 운행알림 및 종료 설정

+
+

🚗 운행알림 및 종료 설정

@@ -2202,7 +2172,7 @@ export const ButtonConfigPanel: React.FC = ({ onChange={(e) => onUpdateProperty("componentConfig.action.updateTargetField", e.target.value)} className="h-8 text-xs" /> -

변경할 DB 컬럼

+

변경할 DB 컬럼

{/* 🆕 키 필드 설정 (레코드 식별용) */}
-
레코드 식별 설정
+
레코드 식별 설정
@@ -2278,7 +2248,7 @@ export const ButtonConfigPanel: React.FC = ({
-

버튼 클릭 시 즉시 DB에 저장

+

버튼 클릭 시 즉시 DB에 저장

= ({ onChange={(e) => onUpdateProperty("componentConfig.action.confirmMessage", e.target.value)} className="h-8 text-xs" /> -

입력하면 변경 전 확인 창이 표시됩니다

+

입력하면 변경 전 확인 창이 표시됩니다

@@ -2327,7 +2297,7 @@ export const ButtonConfigPanel: React.FC = ({
-

상태 변경과 함께 현재 GPS 좌표를 수집합니다

+

상태 변경과 함께 현재 GPS 좌표를 수집합니다

= ({ onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateWithGeolocation", checked)} />
- + {config.action?.updateWithGeolocation && (
- + onUpdateProperty("componentConfig.action.updateGeolocationLatField", e.target.value)} + onChange={(e) => + onUpdateProperty("componentConfig.action.updateGeolocationLatField", e.target.value) + } className="h-8 text-xs" />
- + onUpdateProperty("componentConfig.action.updateGeolocationLngField", e.target.value)} + onChange={(e) => + onUpdateProperty("componentConfig.action.updateGeolocationLngField", e.target.value) + } className="h-8 text-xs" />
@@ -2364,7 +2342,9 @@ export const ButtonConfigPanel: React.FC = ({ onUpdateProperty("componentConfig.action.updateGeolocationAccuracyField", e.target.value)} + onChange={(e) => + onUpdateProperty("componentConfig.action.updateGeolocationAccuracyField", e.target.value) + } className="h-8 text-xs" />
@@ -2373,7 +2353,9 @@ export const ButtonConfigPanel: React.FC = ({ onUpdateProperty("componentConfig.action.updateGeolocationTimestampField", e.target.value)} + onChange={(e) => + onUpdateProperty("componentConfig.action.updateGeolocationTimestampField", e.target.value) + } className="h-8 text-xs" />
@@ -2389,7 +2371,7 @@ export const ButtonConfigPanel: React.FC = ({
-

10초마다 위치를 경로 테이블에 저장합니다

+

10초마다 위치를 경로 테이블에 저장합니다

= ({ onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateWithTracking", checked)} />
- + {config.action?.updateWithTracking && (
- +
- + {config.action?.updateTrackingMode === "start" && (
@@ -2423,17 +2407,22 @@ export const ButtonConfigPanel: React.FC = ({ type="number" placeholder="10" value={(config.action?.updateTrackingInterval || 10000) / 1000} - onChange={(e) => onUpdateProperty("componentConfig.action.updateTrackingInterval", parseInt(e.target.value) * 1000 || 10000)} + onChange={(e) => + onUpdateProperty( + "componentConfig.action.updateTrackingInterval", + parseInt(e.target.value) * 1000 || 10000, + ) + } className="h-8 text-xs" min={5} max={300} /> -

5초 ~ 300초 사이로 설정 (기본: 10초)

+

5초 ~ 300초 사이로 설정 (기본: 10초)

)} - +

- {config.action?.updateTrackingMode === "start" + {config.action?.updateTrackingMode === "start" ? "버튼 클릭 시 연속 위치 추적이 시작되고, vehicle_location_history 테이블에 경로가 저장됩니다." : "버튼 클릭 시 진행 중인 위치 추적이 종료됩니다."}

@@ -2442,13 +2431,13 @@ export const ButtonConfigPanel: React.FC = ({ {/* 🆕 버튼 활성화 조건 설정 */}
-
버튼 활성화 조건
- +
버튼 활성화 조건
+ {/* 출발지/도착지 필수 체크 */}
-

선택하지 않으면 버튼 비활성화

+

선택하지 않으면 버튼 비활성화

= ({ onUpdateProperty("componentConfig.action.trackingDepartureField", e.target.value)} + onChange={(e) => + onUpdateProperty("componentConfig.action.trackingDepartureField", e.target.value) + } className="h-8 text-xs" />
@@ -2486,7 +2477,7 @@ export const ButtonConfigPanel: React.FC = ({
-

특정 상태일 때만 버튼 활성화

+

특정 상태일 때만 버튼 활성화

= ({ ))} -

- 상태를 조회할 테이블 (기본: vehicles) -

+

상태를 조회할 테이블 (기본: vehicles)

@@ -2526,7 +2515,7 @@ export const ButtonConfigPanel: React.FC = ({ onChange={(e) => onUpdateProperty("componentConfig.action.statusCheckKeyField", e.target.value)} className="h-8 text-xs" /> -

+

현재 로그인 사용자 ID로 조회할 필드 (기본: user_id)

@@ -2538,9 +2527,7 @@ export const ButtonConfigPanel: React.FC = ({ onChange={(e) => onUpdateProperty("componentConfig.action.statusCheckField", e.target.value)} className="h-8 text-xs" /> -

- 상태 값이 저장된 컬럼명 (기본: status) -

+

상태 값이 저장된 컬럼명 (기본: status)

@@ -2565,9 +2552,7 @@ export const ButtonConfigPanel: React.FC = ({ onChange={(e) => onUpdateProperty("componentConfig.action.statusConditionValues", e.target.value)} className="h-8 text-xs" /> -

- 여러 상태값은 쉼표(,)로 구분 -

+

여러 상태값은 쉼표(,)로 구분

)} @@ -2580,8 +2565,7 @@ export const ButtonConfigPanel: React.FC = ({ - 운행 시작: status를 "active"로 + 연속 추적 시작
- 운행 종료: status를 "completed"로 + 연속 추적 종료 -
- - 공차등록: status를 "inactive"로 + 1회성 위치정보 수집 +
- 공차등록: status를 "inactive"로 + 1회성 위치정보 수집

@@ -2589,8 +2573,8 @@ export const ButtonConfigPanel: React.FC = ({ {/* 데이터 전달 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "transferData" && ( -
-

📦 데이터 전달 설정

+
+

📦 데이터 전달 설정

{/* 소스 컴포넌트 선택 (Combobox) */}
@@ -2599,7 +2583,9 @@ export const ButtonConfigPanel: React.FC = ({ -

- 테이블, 반복 필드 그룹 등 데이터를 제공하는 컴포넌트 -

+

테이블, 반복 필드 그룹 등 데이터를 제공하는 컴포넌트

@@ -2655,12 +2641,15 @@ export const ButtonConfigPanel: React.FC = ({ 같은 화면의 컴포넌트 분할 패널 반대편 화면 - 다른 화면 (구현 예정) + + 다른 화면 (구현 예정) + {config.action?.dataTransfer?.targetType === "splitPanel" && ( -

- 이 버튼이 분할 패널 내부에 있어야 합니다. 좌측 화면에서 우측으로, 또는 우측에서 좌측으로 데이터가 전달됩니다. +

+ 이 버튼이 분할 패널 내부에 있어야 합니다. 좌측 화면에서 우측으로, 또는 우측에서 좌측으로 데이터가 + 전달됩니다.

)}
@@ -2673,7 +2662,9 @@ export const ButtonConfigPanel: React.FC = ({ -

- 테이블, 반복 필드 그룹 등 데이터를 받는 컴포넌트 -

+

테이블, 반복 필드 그룹 등 데이터를 받는 컴포넌트

)} {/* 분할 패널 반대편 타겟 설정 */} {config.action?.dataTransfer?.targetType === "splitPanel" && (
- + onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value)} + onChange={(e) => + onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value) + } placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달" className="h-8 text-xs" /> -

+

반대편 화면의 특정 컴포넌트 ID를 지정하거나, 비워두면 자동으로 첫 번째 수신 가능 컴포넌트로 전달됩니다.

@@ -2752,32 +2743,34 @@ export const ButtonConfigPanel: React.FC = ({ 병합 (Merge) -

- 기존 데이터를 어떻게 처리할지 선택 -

+

기존 데이터를 어떻게 처리할지 선택

-

데이터 전달 후 소스의 선택을 해제합니다

+

데이터 전달 후 소스의 선택을 해제합니다

onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked)} + onCheckedChange={(checked) => + onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked) + } />
-

데이터 전달 전 확인 다이얼로그를 표시합니다

+

데이터 전달 전 확인 다이얼로그를 표시합니다

onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked)} + onCheckedChange={(checked) => + onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked) + } />
@@ -2806,7 +2799,12 @@ export const ButtonConfigPanel: React.FC = ({ type="number" placeholder="0" value={config.action?.dataTransfer?.validation?.minSelection || ""} - onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.validation.minSelection", parseInt(e.target.value) || 0)} + onChange={(e) => + onUpdateProperty( + "componentConfig.action.dataTransfer.validation.minSelection", + parseInt(e.target.value) || 0, + ) + } className="h-8 w-20 text-xs" />
@@ -2819,7 +2817,12 @@ export const ButtonConfigPanel: React.FC = ({ type="number" placeholder="제한없음" value={config.action?.dataTransfer?.validation?.maxSelection || ""} - onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.validation.maxSelection", parseInt(e.target.value) || undefined)} + onChange={(e) => + onUpdateProperty( + "componentConfig.action.dataTransfer.validation.maxSelection", + parseInt(e.target.value) || undefined, + ) + } className="h-8 w-20 text-xs" />
@@ -2828,7 +2831,7 @@ export const ButtonConfigPanel: React.FC = ({
-

+

조건부 컨테이너의 카테고리 값 등 추가 데이터를 함께 전달할 수 있습니다

@@ -2859,8 +2862,8 @@ export const ButtonConfigPanel: React.FC = ({ .filter((comp: any) => { const type = comp.componentType || comp.type || ""; // 소스/타겟과 다른 컴포넌트 중 값을 제공할 수 있는 타입 - return ["conditional-container", "select-basic", "select", "combobox"].some( - (t) => type.includes(t) + return ["conditional-container", "select-basic", "select", "combobox"].some((t) => + type.includes(t), ); }) .map((comp: any) => { @@ -2870,14 +2873,14 @@ export const ButtonConfigPanel: React.FC = ({
{compLabel} - ({compType}) + ({compType})
); })} -

+

조건부 컨테이너, 셀렉트박스 등 (카테고리 값 전달용)

@@ -2901,9 +2904,7 @@ export const ButtonConfigPanel: React.FC = ({ }} className="h-8 text-xs" /> -

- 타겟 테이블에 저장될 필드명 -

+

타겟 테이블에 저장될 필드명

@@ -2911,18 +2912,14 @@ export const ButtonConfigPanel: React.FC = ({ {/* 필드 매핑 규칙 */}
- + {/* 소스/타겟 테이블 선택 */}
-
- +
-
- + {/* 필드 매핑 규칙 */}
@@ -3031,26 +3024,24 @@ export const ButtonConfigPanel: React.FC = ({ 매핑 추가
-

+

소스 필드를 타겟 필드에 매핑합니다. 비워두면 같은 이름의 필드로 자동 매핑됩니다.

- - {(!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable) ? ( + + {!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable ? (
-

- 먼저 소스 테이블과 타겟 테이블을 선택하세요. -

+

먼저 소스 테이블과 타겟 테이블을 선택하세요.

) : (config.action?.dataTransfer?.mappingRules || []).length === 0 ? (
-

+

매핑 규칙이 없습니다. 같은 이름의 필드로 자동 매핑됩니다.

) : (
{(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => ( -
+
{/* 소스 필드 선택 (Combobox) */}
= ({ onOpenChange={(open) => setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))} > - @@ -3075,10 +3063,14 @@ export const ButtonConfigPanel: React.FC = ({ placeholder="컬럼 검색..." className="h-8 text-xs" value={mappingSourceSearch[index] || ""} - onValueChange={(value) => setMappingSourceSearch((prev) => ({ ...prev, [index]: value }))} + onValueChange={(value) => + setMappingSourceSearch((prev) => ({ ...prev, [index]: value })) + } /> - 컬럼을 찾을 수 없습니다 + + 컬럼을 찾을 수 없습니다 + {mappingSourceColumns.map((col) => ( = ({ {col.label} {col.label !== col.name && ( - ({col.name}) + ({col.name}) )} ))} @@ -3110,9 +3102,9 @@ export const ButtonConfigPanel: React.FC = ({
- - - + + + {/* 타겟 필드 선택 (Combobox) */}
= ({ onOpenChange={(open) => setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))} > - @@ -3137,10 +3126,14 @@ export const ButtonConfigPanel: React.FC = ({ placeholder="컬럼 검색..." className="h-8 text-xs" value={mappingTargetSearch[index] || ""} - onValueChange={(value) => setMappingTargetSearch((prev) => ({ ...prev, [index]: value }))} + onValueChange={(value) => + setMappingTargetSearch((prev) => ({ ...prev, [index]: value })) + } /> - 컬럼을 찾을 수 없습니다 + + 컬럼을 찾을 수 없습니다 + {mappingTargetColumns.map((col) => ( = ({ {col.label} {col.label !== col.name && ( - ({col.name}) + ({col.name}) )} ))} @@ -3172,12 +3165,12 @@ export const ButtonConfigPanel: React.FC = ({
- + + + + + + + 검색 결과 없음 + + { + updateMasterDetailConfig({ numberingRuleId: undefined }); + setNumberingRuleOpen(false); + }} + className="text-xs" + > + + 채번 없음 (수동 입력) + + {numberingRules + .filter((rule) => rule.table_name === masterTable || !rule.table_name) + .map((rule, idx) => { + const ruleId = String(rule.rule_id || rule.ruleId || `rule-${idx}`); + const ruleName = rule.rule_name || rule.ruleName || "(이름 없음)"; + return ( + { + updateMasterDetailConfig({ numberingRuleId: ruleId }); + setNumberingRuleOpen(false); + }} + className="text-xs" + > + + {ruleName} + + ); + })} + + + + + +

+ 마스터 테이블의 {relationInfo.masterKeyColumn} 값을 자동 생성합니다 +

+
+ )} + + {/* 마스터 필드 선택 - 사용자가 엑셀 업로드 시 입력할 필드 */} + {relationInfo && masterColumns.length > 0 && ( +
+ +

+ 엑셀 업로드 시 사용자가 직접 선택/입력할 마스터 테이블 필드를 선택하세요. +

+
+ {masterColumns + .filter((col) => col.columnName !== relationInfo.masterKeyColumn) // 채번으로 자동 생성되는 키는 제외 + .map((col) => { + const selectedFields = masterDetailConfig.masterSelectFields || []; + const isSelected = selectedFields.some((f: any) => f.columnName === col.columnName); + return ( +
+ { + const checked = e.target.checked; + let newFields = [...selectedFields]; + if (checked) { + newFields.push({ + columnName: col.columnName, + columnLabel: col.columnLabel, + inputType: col.inputType, + referenceTable: col.referenceTable, + referenceColumn: col.referenceColumn, + displayColumn: col.displayColumn, + required: true, + }); + } else { + newFields = newFields.filter((f: any) => f.columnName !== col.columnName); + } + updateMasterDetailConfig({ masterSelectFields: newFields }); + }} + className="h-4 w-4 rounded border-gray-300" + /> + +
+ ); + })} +
+ {(masterDetailConfig.masterSelectFields?.length || 0) > 0 && ( +

+ 선택된 필드: {masterDetailConfig.masterSelectFields.length}개 +

+ )} + + {/* 엔티티 필드의 표시컬럼 설정 */} + {masterDetailConfig.masterSelectFields?.filter((f: any) => f.inputType === "entity").length > 0 && ( +
+ + {masterDetailConfig.masterSelectFields + .filter((f: any) => f.inputType === "entity") + .map((field: any) => { + const availableColumns = refTableColumns[field.referenceTable] || []; + return ( +
+ {field.columnLabel}: + +
+ ); + })} +

참조 테이블에서 사용자에게 표시할 컬럼을 선택하세요.

+
+ )} + + {/* 업로드 후 제어 실행 설정 */} + +
+ )} +
+ ); +}; + +/** + * 업로드 후 제어 실행 설정 컴포넌트 + * 여러 개의 제어를 순서대로 실행할 수 있도록 지원 + */ +const AfterUploadControlConfig: React.FC<{ + config: any; + onUpdateProperty: (path: string, value: any) => void; + masterDetailConfig: any; + updateMasterDetailConfig: (updates: any) => void; +}> = ({ masterDetailConfig, updateMasterDetailConfig }) => { + const [nodeFlows, setNodeFlows] = useState< + Array<{ flowId: number; flowName: string; flowDescription?: string }> + >([]); + const [flowSelectOpen, setFlowSelectOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + // 선택된 제어 목록 (배열로 관리) + const selectedFlows: Array<{ flowId: string; order: number }> = masterDetailConfig.afterUploadFlows || []; + + // 노드 플로우 목록 로드 + useEffect(() => { + const loadNodeFlows = async () => { + setIsLoading(true); + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get("/dataflow/node-flows"); + if (response.data?.success && response.data?.data) { + setNodeFlows(response.data.data); + } + } catch (error) { + console.error("노드 플로우 목록 로드 실패:", error); + } finally { + setIsLoading(false); + } + }; + + loadNodeFlows(); + }, []); + + // 제어 추가 + const addFlow = (flowId: string) => { + if (selectedFlows.some((f) => f.flowId === flowId)) return; + const newFlows = [...selectedFlows, { flowId, order: selectedFlows.length + 1 }]; + updateMasterDetailConfig({ afterUploadFlows: newFlows }); + setFlowSelectOpen(false); + }; + + // 제어 제거 + const removeFlow = (flowId: string) => { + const newFlows = selectedFlows + .filter((f) => f.flowId !== flowId) + .map((f, idx) => ({ ...f, order: idx + 1 })); + updateMasterDetailConfig({ afterUploadFlows: newFlows }); + }; + + // 순서 변경 (위로) + const moveUp = (index: number) => { + if (index === 0) return; + const newFlows = [...selectedFlows]; + [newFlows[index - 1], newFlows[index]] = [newFlows[index], newFlows[index - 1]]; + updateMasterDetailConfig({ + afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })), + }); + }; + + // 순서 변경 (아래로) + const moveDown = (index: number) => { + if (index === selectedFlows.length - 1) return; + const newFlows = [...selectedFlows]; + [newFlows[index], newFlows[index + 1]] = [newFlows[index + 1], newFlows[index]]; + updateMasterDetailConfig({ + afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })), + }); + }; + + // 선택되지 않은 플로우만 필터링 + const availableFlows = nodeFlows.filter((f) => !selectedFlows.some((s) => s.flowId === String(f.flowId))); + + return ( +
+ +

+ 엑셀 업로드 완료 후 순서대로 실행할 제어를 추가하세요. +

+ + {/* 선택된 제어 목록 */} + {selectedFlows.length > 0 && ( +
+ {selectedFlows.map((selected, index) => { + const flow = nodeFlows.find((f) => String(f.flowId) === selected.flowId); + return ( +
+ {index + 1} + {flow?.flowName || `Flow ${selected.flowId}`} + + + +
+ ); + })} +
+ )} + + {/* 제어 추가 버튼 */} + + + + + + + + + 검색 결과 없음 + + {availableFlows.map((flow) => ( + addFlow(String(flow.flowId))} + className="text-xs" + > +
+ {flow.flowName} + {flow.flowDescription && ( + {flow.flowDescription} + )} +
+
+ ))} +
+
+
+
+
+ + {selectedFlows.length > 0 && ( +

+ 업로드 완료 후 위 순서대로 {selectedFlows.length}개의 제어가 실행됩니다. +

+ )} +
+ ); +}; + +/** + * 엑셀 업로드 설정 섹션 컴포넌트 + * 마스터-디테일 설정은 분할 패널 자동 감지 + */ +const ExcelUploadConfigSection: React.FC<{ + config: any; + onUpdateProperty: (path: string, value: any) => void; + allComponents: ComponentData[]; +}> = ({ config, onUpdateProperty, allComponents }) => { + return ( +
+

엑셀 업로드 설정

+ +
+ + +
+ + {(config.action?.excelUploadMode === "update" || config.action?.excelUploadMode === "upsert") && ( +
+ + onUpdateProperty("componentConfig.action.excelKeyColumn", e.target.value)} + className="h-8 text-xs" + /> +

UPDATE/UPSERT 시 기준이 되는 컬럼명

+
+ )} + + {/* 마스터-디테일 설정 (분할 패널 자동 감지) */} + +
+ ); +}; diff --git a/frontend/lib/api/dynamicForm.ts b/frontend/lib/api/dynamicForm.ts index d2433c48..c9e4cf33 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("✅ 실제 테이블에서 폼 데이터 삭제 성공"); @@ -556,6 +561,192 @@ 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, + }; + } + } + + /** + * 마스터-디테일 간단 모드 엑셀 업로드 + * - 마스터 정보는 UI에서 선택 + * - 디테일 정보만 엑셀에서 업로드 + * - 채번 규칙을 통해 마스터 키 자동 생성 + * @param screenId 화면 ID + * @param detailData 디테일 데이터 배열 + * @param masterFieldValues UI에서 선택한 마스터 필드 값 + * @param numberingRuleId 채번 규칙 ID (optional) + * @param afterUploadFlowId 업로드 후 실행할 제어 ID (optional, 하위 호환성) + * @param afterUploadFlows 업로드 후 실행할 제어 목록 (optional) + * @returns 업로드 결과 + */ + static async uploadMasterDetailSimple( + screenId: number, + detailData: Record[], + masterFieldValues: Record, + numberingRuleId?: string, + afterUploadFlowId?: string, + afterUploadFlows?: Array<{ flowId: string; order: number }> + ): Promise> { + try { + console.log("📤 마스터-디테일 간단 모드 업로드:", { + screenId, + detailRowCount: detailData.length, + masterFieldValues, + numberingRuleId, + afterUploadFlows: afterUploadFlows?.length || 0, + }); + + const response = await apiClient.post(`/data/master-detail/upload-simple`, { + screenId, + detailData, + masterFieldValues, + numberingRuleId, + afterUploadFlowId, + afterUploadFlows, + }); + + 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 interface MasterDetailSimpleUploadResult { + success: boolean; + masterInserted: number; + detailInserted: number; + generatedKey: string; // 생성된 마스터 키 + errors?: string[]; } // 편의를 위한 기본 export diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index f43cde4a..9da76559 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -136,6 +136,13 @@ export const SplitPanelLayoutComponent: React.FC if (item[underscoreKey] !== undefined) { return item[underscoreKey]; } + + // 6️⃣ 🆕 모든 키에서 _fieldName으로 끝나는 키 찾기 + // 예: partner_id_customer_name (프론트엔드가 customer_id로 추론했지만 실제는 partner_id인 경우) + const matchingKey = Object.keys(item).find((key) => key.endsWith(`_${fieldName}`)); + if (matchingKey && item[matchingKey] !== undefined) { + return item[matchingKey]; + } } return undefined; diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 3c5c7116..9810388f 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -3795,3 +3795,4 @@ export const SplitPanelLayoutConfigPanel: React.FC ); }; + diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 4af6988b..5f9e843f 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); @@ -4481,8 +4484,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); @@ -4574,8 +4636,7 @@ export class ButtonActionExecutor { const includeHeaders = config.excelIncludeHeaders !== false; // 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기 - let visibleColumns: string[] | undefined = undefined; - let columnLabels: Record | undefined = undefined; + // visibleColumns, columnLabels는 함수 상단에서 이미 선언됨 try { // 화면 레이아웃 데이터 가져오기 (별도 API 사용) @@ -4776,8 +4837,53 @@ export class ButtonActionExecutor { context, userId: context.userId, tableName: context.tableName, + screenId: context.screenId, }); + // 🆕 마스터-디테일 구조 확인 (화면에 분할 패널이 있으면 자동 감지) + let isMasterDetail = false; + let masterDetailRelation: any = null; + let masterDetailExcelConfig: any = undefined; + + // 화면 레이아웃에서 분할 패널 자동 감지 + 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; + + // 버튼 설정에서 채번 규칙 등 추가 설정 가져오기 + if (config.masterDetailExcel) { + masterDetailExcelConfig = { + ...config.masterDetailExcel, + // 분할 패널에서 감지한 테이블 정보로 덮어쓰기 + masterTable: relationResponse.data.masterTable, + detailTable: relationResponse.data.detailTable, + masterKeyColumn: relationResponse.data.masterKeyColumn, + detailFkColumn: relationResponse.data.detailFkColumn, + }; + } else { + // 버튼 설정이 없으면 분할 패널 정보만 사용 + masterDetailExcelConfig = { + masterTable: relationResponse.data.masterTable, + detailTable: relationResponse.data.detailTable, + masterKeyColumn: relationResponse.data.masterKeyColumn, + detailFkColumn: relationResponse.data.detailFkColumn, + simpleMode: true, // 기본값으로 간단 모드 사용 + }; + } + + console.log("📊 마스터-디테일 구조 자동 감지:", { + masterTable: relationResponse.data.masterTable, + detailTable: relationResponse.data.detailTable, + masterKeyColumn: relationResponse.data.masterKeyColumn, + numberingRuleId: masterDetailExcelConfig?.numberingRuleId, + }); + } + } + // 동적 import로 모달 컴포넌트 로드 const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal"); const { createRoot } = await import("react-dom/client"); @@ -4820,6 +4926,11 @@ export class ButtonActionExecutor { uploadMode: config.excelUploadMode || "insert", keyColumn: config.excelKeyColumn, userId: context.userId, + // 🆕 마스터-디테일 관련 props + screenId: context.screenId, + isMasterDetail, + masterDetailRelation, + masterDetailExcelConfig, onSuccess: () => { // 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨 context.onRefresh?.(); 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;