diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index b2dc1e8c..0845b1cb 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -126,29 +126,41 @@ export class BatchManagementController { */ static async createBatchConfig(req: AuthenticatedRequest, res: Response) { try { - const { batchName, description, cronSchedule, mappings, isActive } = - req.body; + const { + batchName, description, cronSchedule, mappings, isActive, + executionType, nodeFlowId, nodeFlowContext, + } = req.body; + const companyCode = req.user?.companyCode; - if ( - !batchName || - !cronSchedule || - !mappings || - !Array.isArray(mappings) - ) { + if (!batchName || !cronSchedule) { return res.status(400).json({ success: false, - message: - "필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)", + message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)", }); } - const batchConfig = await BatchService.createBatchConfig({ - batchName, - description, - cronSchedule, - mappings, - isActive: isActive !== undefined ? isActive : true, - } as CreateBatchConfigRequest); + // 노드 플로우 타입은 매핑 없이 생성 가능 + if (executionType !== "node_flow" && (!mappings || !Array.isArray(mappings))) { + return res.status(400).json({ + success: false, + message: "매핑 타입은 mappings 배열이 필요합니다.", + }); + } + + const batchConfig = await BatchService.createBatchConfig( + { + batchName, + description, + cronSchedule, + mappings: mappings || [], + isActive: isActive === false || isActive === "N" ? "N" : "Y", + companyCode: companyCode || "", + executionType: executionType || "mapping", + nodeFlowId: nodeFlowId || null, + nodeFlowContext: nodeFlowContext || null, + } as CreateBatchConfigRequest, + req.user?.userId + ); return res.status(201).json({ success: true, @@ -769,6 +781,55 @@ export class BatchManagementController { } } + /** + * 노드 플로우 목록 조회 (배치 설정에서 플로우 선택용) + * GET /api/batch-management/node-flows + */ + static async getNodeFlows(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + + let flowQuery: string; + let flowParams: any[] = []; + + if (companyCode === "*") { + flowQuery = ` + SELECT flow_id, flow_name, flow_description AS description, company_code, + COALESCE(jsonb_array_length( + CASE WHEN flow_data IS NOT NULL AND flow_data::text != '' + THEN (flow_data::jsonb -> 'nodes') + ELSE '[]'::jsonb END + ), 0) AS node_count + FROM node_flows + ORDER BY flow_name + `; + } else { + flowQuery = ` + SELECT flow_id, flow_name, flow_description AS description, company_code, + COALESCE(jsonb_array_length( + CASE WHEN flow_data IS NOT NULL AND flow_data::text != '' + THEN (flow_data::jsonb -> 'nodes') + ELSE '[]'::jsonb END + ), 0) AS node_count + FROM node_flows + WHERE company_code = $1 + ORDER BY flow_name + `; + flowParams = [companyCode]; + } + + const result = await query(flowQuery, flowParams); + return res.json({ success: true, data: result }); + } catch (error) { + console.error("노드 플로우 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "노드 플로우 목록 조회 실패", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + /** * 배치 대시보드 통계 조회 * GET /api/batch-management/stats diff --git a/backend-node/src/routes/batchManagementRoutes.ts b/backend-node/src/routes/batchManagementRoutes.ts index 6f57cb12..372113c0 100644 --- a/backend-node/src/routes/batchManagementRoutes.ts +++ b/backend-node/src/routes/batchManagementRoutes.ts @@ -14,6 +14,12 @@ const router = Router(); */ router.get("/stats", authenticateToken, BatchManagementController.getBatchStats); +/** + * GET /api/batch-management/node-flows + * 배치 설정에서 노드 플로우 선택용 목록 조회 + */ +router.get("/node-flows", authenticateToken, BatchManagementController.getNodeFlows); + /** * GET /api/batch-management/connections * 사용 가능한 커넥션 목록 조회 diff --git a/backend-node/src/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts index 30fffd7b..4180f977 100644 --- a/backend-node/src/routes/dataflow/node-flows.ts +++ b/backend-node/src/routes/dataflow/node-flows.ts @@ -13,7 +13,54 @@ import { auditLogService, getClientIp } from "../../services/auditLogService"; const router = Router(); /** - * 플로우 목록 조회 + * flow_data에서 요약 정보 추출 + */ +function extractFlowSummary(flowData: any) { + try { + const parsed = typeof flowData === "string" ? JSON.parse(flowData) : flowData; + const nodes = parsed?.nodes || []; + const edges = parsed?.edges || []; + + const nodeTypes: Record = {}; + nodes.forEach((n: any) => { + const t = n.type || "unknown"; + nodeTypes[t] = (nodeTypes[t] || 0) + 1; + }); + + // 미니 토폴로지용 간소화된 좌표 (0~1 정규화) + let topology = null; + if (nodes.length > 0) { + const xs = nodes.map((n: any) => n.position?.x || 0); + const ys = nodes.map((n: any) => n.position?.y || 0); + const minX = Math.min(...xs), maxX = Math.max(...xs); + const minY = Math.min(...ys), maxY = Math.max(...ys); + const rangeX = maxX - minX || 1; + const rangeY = maxY - minY || 1; + + topology = { + nodes: nodes.map((n: any) => ({ + id: n.id, + type: n.type, + x: (((n.position?.x || 0) - minX) / rangeX), + y: (((n.position?.y || 0) - minY) / rangeY), + })), + edges: edges.map((e: any) => [e.source, e.target]), + }; + } + + return { + nodeCount: nodes.length, + edgeCount: edges.length, + nodeTypes, + topology, + }; + } catch { + return { nodeCount: 0, edgeCount: 0, nodeTypes: {}, topology: null }; + } +} + +/** + * 플로우 목록 조회 (summary 포함) */ router.get("/", async (req: AuthenticatedRequest, res: Response) => { try { @@ -24,6 +71,7 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => { flow_id as "flowId", flow_name as "flowName", flow_description as "flowDescription", + flow_data as "flowData", company_code as "companyCode", created_at as "createdAt", updated_at as "updatedAt" @@ -32,7 +80,6 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => { const params: any[] = []; - // 슈퍼 관리자가 아니면 회사별 필터링 if (userCompanyCode && userCompanyCode !== "*") { sqlQuery += ` WHERE company_code = $1`; params.push(userCompanyCode); @@ -42,9 +89,15 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => { const flows = await query(sqlQuery, params); + const flowsWithSummary = flows.map((flow: any) => { + const summary = extractFlowSummary(flow.flowData); + const { flowData, ...rest } = flow; + return { ...rest, summary }; + }); + return res.json({ success: true, - data: flows, + data: flowsWithSummary, }); } catch (error) { logger.error("플로우 목록 조회 실패:", error); diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index f6fe56a1..8feba9d9 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -122,20 +122,22 @@ export class BatchSchedulerService { } /** - * 배치 설정 실행 + * 배치 설정 실행 - execution_type에 따라 매핑 또는 노드 플로우 실행 */ static async executeBatchConfig(config: any) { const startTime = new Date(); let executionLog: any = null; try { - logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`); + logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id}, type: ${config.execution_type || "mapping"})`); - // 매핑 정보가 없으면 상세 조회로 다시 가져오기 - if (!config.batch_mappings || config.batch_mappings.length === 0) { - const fullConfig = await BatchService.getBatchConfigById(config.id); - if (fullConfig.success && fullConfig.data) { - config = fullConfig.data; + // 상세 조회 (매핑 또는 노드플로우 정보가 없을 수 있음) + if (!config.execution_type || config.execution_type === "mapping") { + if (!config.batch_mappings || config.batch_mappings.length === 0) { + const fullConfig = await BatchService.getBatchConfigById(config.id); + if (fullConfig.success && fullConfig.data) { + config = fullConfig.data; + } } } @@ -165,12 +167,17 @@ export class BatchSchedulerService { executionLog = executionLogResponse.data; - // 실제 배치 실행 로직 (수동 실행과 동일한 로직 사용) - const result = await this.executeBatchMappings(config); + let result: { totalRecords: number; successRecords: number; failedRecords: number }; + + if (config.execution_type === "node_flow") { + result = await this.executeNodeFlow(config); + } else { + result = await this.executeBatchMappings(config); + } // 실행 로그 업데이트 (성공) await BatchExecutionLogService.updateExecutionLog(executionLog.id, { - execution_status: "SUCCESS", + execution_status: result.failedRecords > 0 ? "PARTIAL" : "SUCCESS", end_time: new Date(), duration_ms: Date.now() - startTime.getTime(), total_records: result.totalRecords, @@ -182,12 +189,10 @@ export class BatchSchedulerService { `배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})` ); - // 성공 결과 반환 return result; } catch (error) { logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error); - // 실행 로그 업데이트 (실패) if (executionLog) { await BatchExecutionLogService.updateExecutionLog(executionLog.id, { execution_status: "FAILED", @@ -198,7 +203,6 @@ export class BatchSchedulerService { }); } - // 실패 결과 반환 return { totalRecords: 0, successRecords: 0, @@ -207,6 +211,43 @@ export class BatchSchedulerService { } } + /** + * 노드 플로우 실행 - NodeFlowExecutionService에 위임 + */ + private static async executeNodeFlow(config: any) { + if (!config.node_flow_id) { + throw new Error("노드 플로우 ID가 설정되지 않았습니다."); + } + + const { NodeFlowExecutionService } = await import( + "./nodeFlowExecutionService" + ); + + const contextData: Record = { + companyCode: config.company_code, + batchConfigId: config.id, + batchName: config.batch_name, + executionSource: "batch_scheduler", + ...(config.node_flow_context || {}), + }; + + logger.info( + `노드 플로우 실행: flowId=${config.node_flow_id}, batch=${config.batch_name}` + ); + + const flowResult = await NodeFlowExecutionService.executeFlow( + config.node_flow_id, + contextData + ); + + // 노드 플로우 실행 결과를 배치 로그 형식으로 변환 + return { + totalRecords: flowResult.summary.total, + successRecords: flowResult.summary.success, + failedRecords: flowResult.summary.failed, + }; + } + /** * 배치 매핑 실행 (수동 실행과 동일한 로직) */ diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index 31ee2001..c8b6ecbe 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -72,9 +72,12 @@ export class BatchService { const total = parseInt(countResult[0].count); const totalPages = Math.ceil(total / limit); - // 목록 조회 + // 목록 조회 (최근 실행 정보 포함) const configs = await query( - `SELECT bc.* + `SELECT bc.*, + (SELECT bel.execution_status FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_status, + (SELECT bel.start_time FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_executed_at, + (SELECT bel.total_records FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_total_records FROM batch_configs bc ${whereClause} ORDER BY bc.created_date DESC @@ -82,9 +85,6 @@ export class BatchService { [...values, limit, offset] ); - // 매핑 정보 조회 (N+1 문제 해결을 위해 별도 쿼리 대신 여기서는 생략하고 상세 조회에서 처리) - // 하지만 목록에서도 간단한 정보는 필요할 수 있음 - return { success: true, data: configs as BatchConfig[], @@ -176,8 +176,8 @@ export class BatchService { // 배치 설정 생성 const batchConfigResult = await client.query( `INSERT INTO batch_configs - (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, created_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()) + (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, execution_type, node_flow_id, node_flow_context, created_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW()) RETURNING *`, [ data.batchName, @@ -189,6 +189,9 @@ export class BatchService { data.conflictKey || null, data.authServiceName || null, data.dataArrayPath || null, + data.executionType || "mapping", + data.nodeFlowId || null, + data.nodeFlowContext ? JSON.stringify(data.nodeFlowContext) : null, userId, ] ); @@ -332,6 +335,22 @@ export class BatchService { updateFields.push(`data_array_path = $${paramIndex++}`); updateValues.push(data.dataArrayPath || null); } + if (data.executionType !== undefined) { + updateFields.push(`execution_type = $${paramIndex++}`); + updateValues.push(data.executionType); + } + if (data.nodeFlowId !== undefined) { + updateFields.push(`node_flow_id = $${paramIndex++}`); + updateValues.push(data.nodeFlowId || null); + } + if (data.nodeFlowContext !== undefined) { + updateFields.push(`node_flow_context = $${paramIndex++}`); + updateValues.push( + data.nodeFlowContext + ? JSON.stringify(data.nodeFlowContext) + : null + ); + } // 배치 설정 업데이트 const batchConfigResult = await client.query( diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index a6404036..9933194b 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -79,6 +79,9 @@ export interface BatchMapping { created_date?: Date; } +// 배치 실행 타입: 기존 매핑 방식 또는 노드 플로우 실행 +export type BatchExecutionType = "mapping" | "node_flow"; + // 배치 설정 타입 export interface BatchConfig { id?: number; @@ -87,15 +90,21 @@ export interface BatchConfig { cron_schedule: string; is_active: "Y" | "N"; company_code?: string; - save_mode?: "INSERT" | "UPSERT"; // 저장 모드 (기본: INSERT) - conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명 - auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명 - data_array_path?: string; // REST API 응답에서 데이터 배열 경로 (예: response, data.items) + save_mode?: "INSERT" | "UPSERT"; + conflict_key?: string; + auth_service_name?: string; + data_array_path?: string; + execution_type?: BatchExecutionType; + node_flow_id?: number; + node_flow_context?: Record; created_by?: string; created_date?: Date; updated_by?: string; updated_date?: Date; batch_mappings?: BatchMapping[]; + last_status?: string; + last_executed_at?: string; + last_total_records?: number; } export interface BatchConnectionInfo { @@ -149,7 +158,10 @@ export interface CreateBatchConfigRequest { saveMode?: "INSERT" | "UPSERT"; conflictKey?: string; authServiceName?: string; - dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 + dataArrayPath?: string; + executionType?: BatchExecutionType; + nodeFlowId?: number; + nodeFlowContext?: Record; mappings: BatchMappingRequest[]; } @@ -161,7 +173,10 @@ export interface UpdateBatchConfigRequest { saveMode?: "INSERT" | "UPSERT"; conflictKey?: string; authServiceName?: string; - dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 + dataArrayPath?: string; + executionType?: BatchExecutionType; + nodeFlowId?: number; + nodeFlowContext?: Record; mappings?: BatchMappingRequest[]; } diff --git a/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx index fcd61c1b..e8b90461 100644 --- a/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx @@ -1,34 +1,101 @@ "use client"; -import React, { useState, useEffect } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import React, { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; -import { ArrowLeft, Save, RefreshCw, ArrowRight, Trash2 } from "lucide-react"; +import { + ArrowLeft, Save, RefreshCw, Trash2, Search, + Database, Workflow, Clock, ChevronRight, +} from "lucide-react"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; -import { useRouter } from "next/navigation"; +import { useTabStore } from "@/stores/tabStore"; import { BatchAPI, BatchMapping, ConnectionInfo, ColumnInfo, BatchMappingRequest, + type NodeFlowInfo, + type BatchExecutionType, } from "@/lib/api/batch"; +const SCHEDULE_PRESETS = [ + { label: "5분마다", cron: "*/5 * * * *", preview: "5분마다 실행돼요" }, + { label: "30분마다", cron: "*/30 * * * *", preview: "30분마다 실행돼요" }, + { label: "매시간", cron: "0 * * * *", preview: "매시간 정각에 실행돼요" }, + { label: "매일 오전 7시", cron: "0 7 * * *", preview: "매일 오전 7시에 실행돼요" }, + { label: "매일 오전 9시", cron: "0 9 * * *", preview: "매일 오전 9시에 실행돼요" }, + { label: "매일 자정", cron: "0 0 * * *", preview: "매일 밤 12시에 실행돼요" }, + { label: "매주 월요일", cron: "0 9 * * 1", preview: "매주 월요일 오전 9시에 실행돼요" }, + { label: "매월 1일", cron: "0 9 1 * *", preview: "매월 1일 오전 9시에 실행돼요" }, +]; + +function buildCustomCron(repeat: string, dow: string, hour: string, minute: string): string { + const h = hour; + const m = minute; + if (repeat === "daily") return `${m} ${h} * * *`; + if (repeat === "weekly") return `${m} ${h} * * ${dow}`; + if (repeat === "monthly") return `${m} ${h} 1 * *`; + return `${m} ${h} * * *`; +} + +function customCronPreview(repeat: string, dow: string, hour: string, minute: string): string { + const dowNames: Record = { "1": "월요일", "2": "화요일", "3": "수요일", "4": "목요일", "5": "금요일", "6": "토요일", "0": "일요일" }; + const h = Number(hour); + const ampm = h < 12 ? "오전" : "오후"; + const displayH = h === 0 ? 12 : h > 12 ? h - 12 : h; + const time = `${ampm} ${displayH}시${minute !== "0" ? ` ${minute}분` : ""}`; + if (repeat === "daily") return `매일 ${time}에 실행돼요`; + if (repeat === "weekly") return `매주 ${dowNames[dow] || dow} ${time}에 실행돼요`; + if (repeat === "monthly") return `매월 1일 ${time}에 실행돼요`; + return `매일 ${time}에 실행돼요`; +} + export default function BatchCreatePage() { - const router = useRouter(); - - // 기본 정보 + const { openTab } = useTabStore(); + + const [executionType, setExecutionType] = useState(() => { + if (typeof window !== "undefined") { + const stored = sessionStorage.getItem("batch_create_type"); + if (stored === "node_flow") { + sessionStorage.removeItem("batch_create_type"); + return "node_flow"; + } + sessionStorage.removeItem("batch_create_type"); + } + return "mapping"; + }); + const [nodeFlows, setNodeFlows] = useState([]); + const [selectedFlowId, setSelectedFlowId] = useState(null); + const [nodeFlowContext, setNodeFlowContext] = useState(""); + const [flowSearch, setFlowSearch] = useState(""); + const [batchName, setBatchName] = useState(""); - const [cronSchedule, setCronSchedule] = useState("0 12 * * *"); const [description, setDescription] = useState(""); - - // 커넥션 및 데이터 + + // 스케줄 관련 + const [scheduleMode, setScheduleMode] = useState<"preset" | "custom">("preset"); + const [selectedPresetIndex, setSelectedPresetIndex] = useState(3); // 매일 오전 7시 + const [customRepeat, setCustomRepeat] = useState("daily"); + const [customDow, setCustomDow] = useState("1"); + const [customHour, setCustomHour] = useState("9"); + const [customMinute, setCustomMinute] = useState("0"); + + const cronSchedule = useMemo(() => { + if (scheduleMode === "preset") return SCHEDULE_PRESETS[selectedPresetIndex].cron; + return buildCustomCron(customRepeat, customDow, customHour, customMinute); + }, [scheduleMode, selectedPresetIndex, customRepeat, customDow, customHour, customMinute]); + + const schedulePreview = useMemo(() => { + if (scheduleMode === "preset") return SCHEDULE_PRESETS[selectedPresetIndex].preview; + return customCronPreview(customRepeat, customDow, customHour, customMinute); + }, [scheduleMode, selectedPresetIndex, customRepeat, customDow, customHour, customMinute]); + const [connections, setConnections] = useState([]); const [fromConnection, setFromConnection] = useState(null); const [toConnection, setToConnection] = useState(null); @@ -38,19 +105,20 @@ export default function BatchCreatePage() { const [toTable, setToTable] = useState(""); const [fromColumns, setFromColumns] = useState([]); const [toColumns, setToColumns] = useState([]); - - // 매핑 상태 + const [selectedFromColumn, setSelectedFromColumn] = useState(null); const [mappings, setMappings] = useState([]); - - // 로딩 상태 + const [loading, setLoading] = useState(false); const [loadingConnections, setLoadingConnections] = useState(false); - // 커넥션 목록 로드 useEffect(() => { - loadConnections(); - }, []); + if (executionType === "node_flow") { + BatchAPI.getNodeFlows().then(setNodeFlows); + } + }, [executionType]); + + useEffect(() => { loadConnections(); }, []); const loadConnections = async () => { setLoadingConnections(true); @@ -59,487 +127,533 @@ export default function BatchCreatePage() { setConnections(Array.isArray(data) ? data : []); } catch (error) { console.error("커넥션 로드 실패:", error); - toast.error("커넥션 목록을 불러오는데 실패했습니다."); + toast.error("커넥션 목록을 불러올 수 없어요"); setConnections([]); } finally { setLoadingConnections(false); } }; - // FROM 커넥션 변경 const handleFromConnectionChange = async (connectionId: string) => { - if (connectionId === 'unknown') return; - - const connection = connections.find(conn => { - if (conn.type === 'internal') { - return connectionId === 'internal'; - } - return conn.id ? conn.id.toString() === connectionId : false; - }); - + if (connectionId === "unknown") return; + const connection = connections.find(conn => conn.type === "internal" ? connectionId === "internal" : conn.id?.toString() === connectionId); if (!connection) return; - setFromConnection(connection); - setFromTable(""); - setFromTables([]); - setFromColumns([]); - setSelectedFromColumn(null); - + setFromTable(""); setFromTables([]); setFromColumns([]); setSelectedFromColumn(null); try { const tables = await BatchAPI.getTablesFromConnection(connection); setFromTables(Array.isArray(tables) ? tables : []); - } catch (error) { - console.error("FROM 테이블 목록 로드 실패:", error); - toast.error("테이블 목록을 불러오는데 실패했습니다."); - } + } catch { toast.error("테이블 목록을 불러올 수 없어요"); } }; - // TO 커넥션 변경 const handleToConnectionChange = async (connectionId: string) => { - if (connectionId === 'unknown') return; - - const connection = connections.find(conn => { - if (conn.type === 'internal') { - return connectionId === 'internal'; - } - return conn.id ? conn.id.toString() === connectionId : false; - }); - + if (connectionId === "unknown") return; + const connection = connections.find(conn => conn.type === "internal" ? connectionId === "internal" : conn.id?.toString() === connectionId); if (!connection) return; - - setToConnection(connection); - setToTable(""); - setToTables([]); - setToColumns([]); - + setToConnection(connection); setToTable(""); setToTables([]); setToColumns([]); try { const tables = await BatchAPI.getTablesFromConnection(connection); setToTables(Array.isArray(tables) ? tables : []); - } catch (error) { - console.error("TO 테이블 목록 로드 실패:", error); - toast.error("테이블 목록을 불러오는데 실패했습니다."); - } + } catch { toast.error("테이블 목록을 불러올 수 없어요"); } }; - // FROM 테이블 변경 const handleFromTableChange = async (tableName: string) => { - setFromTable(tableName); - setFromColumns([]); - setSelectedFromColumn(null); - + setFromTable(tableName); setFromColumns([]); setSelectedFromColumn(null); if (!fromConnection || !tableName) return; - try { const columns = await BatchAPI.getTableColumns(fromConnection, tableName); setFromColumns(Array.isArray(columns) ? columns : []); } catch (error) { - console.error("FROM 컬럼 목록 로드 실패:", error); - showErrorToast("컬럼 목록을 불러오는 데 실패했습니다", error, { guidance: "테이블 정보를 확인해 주세요." }); + showErrorToast("컬럼 목록을 불러올 수 없어요", error, { guidance: "테이블 정보를 확인해 주세요." }); } }; - // TO 테이블 변경 const handleToTableChange = async (tableName: string) => { - setToTable(tableName); - setToColumns([]); - + setToTable(tableName); setToColumns([]); if (!toConnection || !tableName) return; - try { const columns = await BatchAPI.getTableColumns(toConnection, tableName); setToColumns(Array.isArray(columns) ? columns : []); - } catch (error) { - console.error("TO 컬럼 목록 로드 실패:", error); - toast.error("컬럼 목록을 불러오는데 실패했습니다."); - } + } catch { toast.error("컬럼 목록을 불러올 수 없어요"); } }; - // FROM 컬럼 선택 const handleFromColumnClick = (column: ColumnInfo) => { setSelectedFromColumn(column); - toast.info(`FROM 컬럼 선택됨: ${column.column_name}`); + toast.info(`FROM 컬럼 선택: ${column.column_name}`); }; - // TO 컬럼 선택 (매핑 생성) const handleToColumnClick = (toColumn: ColumnInfo) => { if (!selectedFromColumn || !fromConnection || !toConnection) { - toast.error("먼저 FROM 컬럼을 선택해주세요."); + toast.error("먼저 왼쪽(FROM)에서 컬럼을 선택해주세요"); return; } + const toKey = `${toConnection.type}:${toConnection.id || "internal"}:${toTable}:${toColumn.column_name}`; + const existingMapping = mappings.find(m => `${m.to_connection_type}:${m.to_connection_id || "internal"}:${m.to_table_name}:${m.to_column_name}` === toKey); + if (existingMapping) { toast.error("같은 대상 컬럼에 중복 매핑할 수 없어요"); return; } - // n:1 매핑 검사 - const toKey = `${toConnection.type}:${toConnection.id || 'internal'}:${toTable}:${toColumn.column_name}`; - const existingMapping = mappings.find(mapping => { - const existingToKey = `${mapping.to_connection_type}:${mapping.to_connection_id || 'internal'}:${mapping.to_table_name}:${mapping.to_column_name}`; - return existingToKey === toKey; - }); - - if (existingMapping) { - toast.error("동일한 TO 컬럼에 중복 매핑할 수 없습니다. (n:1 매핑 방지)"); - return; - } - - const newMapping: BatchMapping = { + setMappings([...mappings, { from_connection_type: fromConnection.type, from_connection_id: fromConnection.id ?? undefined, from_table_name: fromTable, from_column_name: selectedFromColumn.column_name, - from_column_type: selectedFromColumn.data_type || '', + from_column_type: selectedFromColumn.data_type || "", to_connection_type: toConnection.type, to_connection_id: toConnection.id ?? undefined, to_table_name: toTable, to_column_name: toColumn.column_name, - to_column_type: toColumn.data_type || '', + to_column_type: toColumn.data_type || "", mapping_order: mappings.length + 1, - }; - - setMappings([...mappings, newMapping]); + }]); setSelectedFromColumn(null); - toast.success(`매핑 생성: ${selectedFromColumn.column_name} → ${toColumn.column_name}`); + toast.success(`매핑 완료: ${selectedFromColumn.column_name} → ${toColumn.column_name}`); }; - // 매핑 삭제 const removeMapping = (index: number) => { - const newMappings = mappings.filter((_, i) => i !== index); - const reorderedMappings = newMappings.map((mapping, i) => ({ - ...mapping, - mapping_order: i + 1 - })); - setMappings(reorderedMappings); - toast.success("매핑이 삭제되었습니다."); + setMappings(mappings.filter((_, i) => i !== index).map((m, i) => ({ ...m, mapping_order: i + 1 }))); + toast.success("매핑을 삭제했어요"); }; - // 배치 설정 저장 + const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" }); + const saveBatchConfig = async () => { - if (!batchName.trim()) { - toast.error("배치명을 입력해주세요."); - return; - } - - if (!cronSchedule.trim()) { - toast.error("실행 스케줄을 입력해주세요."); - return; - } - - if (mappings.length === 0) { - toast.error("최소 하나 이상의 매핑을 추가해주세요."); + if (!batchName.trim()) { toast.error("배치 이름을 입력해주세요"); return; } + + if (executionType === "node_flow") { + if (!selectedFlowId) { toast.error("실행할 플로우를 선택해주세요"); return; } + let parsedContext: Record | undefined; + if (nodeFlowContext.trim()) { + try { parsedContext = JSON.parse(nodeFlowContext); } catch { toast.error("추가 데이터의 JSON 형식이 올바르지 않아요"); return; } + } + setLoading(true); + try { + await BatchAPI.createBatchConfig({ batchName, description: description || undefined, cronSchedule, mappings: [], isActive: true, executionType: "node_flow", nodeFlowId: selectedFlowId, nodeFlowContext: parsedContext }); + toast.success("배치를 저장했어요!"); + goBack(); + } catch (error) { showErrorToast("저장에 실패했어요", error, { guidance: "입력 내용을 확인하고 다시 시도해 주세요." }); } finally { setLoading(false); } return; } + if (mappings.length === 0) { toast.error("컬럼 매핑을 하나 이상 추가해주세요"); return; } setLoading(true); try { - const request = { - batchName: batchName, - description: description || undefined, - cronSchedule: cronSchedule, - mappings: mappings, - isActive: true - }; - - await BatchAPI.createBatchConfig(request); - toast.success("배치 설정이 성공적으로 저장되었습니다!"); - - // 목록 페이지로 이동 - router.push("/admin/batchmng"); - } catch (error) { - console.error("배치 설정 저장 실패:", error); - showErrorToast("배치 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); - } finally { - setLoading(false); - } + await BatchAPI.createBatchConfig({ batchName, description: description || undefined, cronSchedule, mappings, isActive: true }); + toast.success("배치를 저장했어요!"); + goBack(); + } catch (error) { showErrorToast("저장에 실패했어요", error, { guidance: "입력 내용을 확인하고 다시 시도해 주세요." }); } finally { setLoading(false); } }; + const selectedFlow = nodeFlows.find(f => f.flow_id === selectedFlowId); + return ( -
+
{/* 헤더 */} -
-
- +
+ +
-

배치관리 매핑 시스템

-

새로운 배치 매핑을 생성합니다.

+

새 배치 등록

+

데이터를 자동으로 처리하는 배치를 만들어 보세요

+ +
+
+ + {/* 실행 방식 선택 */} +
+

어떤 방식으로 실행할까요?

+
+ +
{/* 기본 정보 */} - - - 기본 정보 - - -
-
- - setBatchName(e.target.value)} - placeholder="배치명을 입력하세요" - /> -
-
- - setCronSchedule(e.target.value)} - placeholder="0 12 * * * (매일 12시)" - /> -
+
+

기본 정보

+
+
+ + setBatchName(e.target.value)} placeholder="예: 매출 데이터 동기화" className="h-10 text-sm" /> +

어떤 작업인지 한눈에 알 수 있게 적어주세요

-
- -