From 43cf91e748c1b9ec850d3cad2c68bd4a647c7100 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 19 Mar 2026 15:07:07 +0900 Subject: [PATCH] Enhance batch management functionality by adding node flow execution support and improving batch configuration creation. Introduce new API endpoint for retrieving node flows and update existing batch services to handle execution types. Update frontend components to support new scheduling options and node flow selection. --- .../controllers/batchManagementController.ts | 95 +- .../src/routes/batchManagementRoutes.ts | 6 + .../src/routes/dataflow/node-flows.ts | 59 +- .../src/services/batchSchedulerService.ts | 67 +- backend-node/src/services/batchService.ts | 33 +- backend-node/src/types/batchTypes.ts | 27 +- .../automaticMng/batchmngList/create/page.tsx | 936 ++++++++++-------- .../batchmngList/edit/[id]/page.tsx | 532 +++++++--- .../admin/automaticMng/batchmngList/page.tsx | 913 +++++++++++------ .../admin/batch-management-new/page.tsx | 140 ++- .../(main)/admin/systemMng/dataflow/page.tsx | 58 +- .../admin/systemMng/tableMngList/page.tsx | 35 +- .../admin/table-type/ColumnDetailPanel.tsx | 55 +- frontend/components/dataflow/DataFlowList.tsx | 614 ++++++++---- .../dataflow/node-editor/CommandPalette.tsx | 218 ++++ .../dataflow/node-editor/FlowBreadcrumb.tsx | 42 + .../dataflow/node-editor/FlowEditor.tsx | 559 ++++++----- .../dataflow/node-editor/FlowToolbar.tsx | 228 +++-- .../dataflow/node-editor/NodeContextMenu.tsx | 67 ++ .../dataflow/node-editor/SlideOverSheet.tsx | 96 ++ .../node-editor/nodes/AggregateNode.tsx | 121 +-- .../node-editor/nodes/CommentNode.tsx | 30 +- .../node-editor/nodes/CompactNodeShell.tsx | 103 ++ .../node-editor/nodes/ConditionNode.tsx | 150 +-- .../node-editor/nodes/DataTransformNode.tsx | 101 +- .../node-editor/nodes/DeleteActionNode.tsx | 80 +- .../node-editor/nodes/EmailActionNode.tsx | 110 +- .../nodes/ExternalDBSourceNode.tsx | 90 +- .../nodes/FormulaTransformNode.tsx | 169 +--- .../nodes/HttpRequestActionNode.tsx | 132 +-- .../node-editor/nodes/InsertActionNode.tsx | 93 +- .../dataflow/node-editor/nodes/LogNode.tsx | 64 +- .../nodes/ProcedureCallActionNode.tsx | 131 +-- .../node-editor/nodes/RestAPISourceNode.tsx | 89 +- .../node-editor/nodes/ScriptActionNode.tsx | 127 +-- .../node-editor/nodes/TableSourceNode.tsx | 79 +- .../node-editor/nodes/UpdateActionNode.tsx | 103 +- .../node-editor/nodes/UpsertActionNode.tsx | 99 +- .../node-editor/panels/PropertiesPanel.tsx | 90 +- .../numbering-rule/NumberingRuleDesigner.tsx | 308 +++--- frontend/lib/api/batch.ts | 126 ++- 41 files changed, 4020 insertions(+), 3155 deletions(-) create mode 100644 frontend/components/dataflow/node-editor/CommandPalette.tsx create mode 100644 frontend/components/dataflow/node-editor/FlowBreadcrumb.tsx create mode 100644 frontend/components/dataflow/node-editor/NodeContextMenu.tsx create mode 100644 frontend/components/dataflow/node-editor/SlideOverSheet.tsx create mode 100644 frontend/components/dataflow/node-editor/nodes/CompactNodeShell.tsx 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" /> +

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

-
- -