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.
This commit is contained in:
parent
7f781b0177
commit
43cf91e748
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* 사용 가능한 커넥션 목록 조회
|
||||
|
|
|
|||
|
|
@ -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<string, number> = {};
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<string, any> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 매핑 실행 (수동 실행과 동일한 로직)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -72,9 +72,12 @@ export class BatchService {
|
|||
const total = parseInt(countResult[0].count);
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
// 목록 조회
|
||||
// 목록 조회 (최근 실행 정보 포함)
|
||||
const configs = await query<any>(
|
||||
`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(
|
||||
|
|
|
|||
|
|
@ -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<string, any>;
|
||||
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<string, any>;
|
||||
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<string, any>;
|
||||
mappings?: BatchMappingRequest[];
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,12 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -15,17 +16,58 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||
import { RefreshCw, Save, ArrowLeft, Plus, Trash2, Database, Workflow, Clock, Info, Layers, Link, Search } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BatchAPI,
|
||||
BatchConfig,
|
||||
BatchMapping,
|
||||
ConnectionInfo,
|
||||
type NodeFlowInfo,
|
||||
type BatchExecutionType,
|
||||
} from "@/lib/api/batch";
|
||||
import { BatchManagementAPI } from "@/lib/api/batchManagement";
|
||||
|
||||
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 {
|
||||
if (repeat === "daily") return `${minute} ${hour} * * *`;
|
||||
if (repeat === "weekly") return `${minute} ${hour} * * ${dow}`;
|
||||
if (repeat === "monthly") return `${minute} ${hour} 1 * *`;
|
||||
return `${minute} ${hour} * * *`;
|
||||
}
|
||||
|
||||
function customCronPreview(repeat: string, dow: string, hour: string, minute: string): string {
|
||||
const dowNames: Record<string, string> = { "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}에 실행돼요`;
|
||||
}
|
||||
|
||||
function parseCronToScheduleState(cron: string): { mode: "preset" | "custom"; presetIndex: number; repeat: string; dow: string; hour: string; minute: string } {
|
||||
const presetIdx = SCHEDULE_PRESETS.findIndex(p => p.cron === cron);
|
||||
if (presetIdx >= 0) return { mode: "preset", presetIndex: presetIdx, repeat: "daily", dow: "1", hour: "9", minute: "0" };
|
||||
const parts = cron.split(" ");
|
||||
if (parts.length < 5) return { mode: "preset", presetIndex: 3, repeat: "daily", dow: "1", hour: "9", minute: "0" };
|
||||
const [m, h, dom, , dw] = parts;
|
||||
const repeat = dw !== "*" ? "weekly" : dom !== "*" ? "monthly" : "daily";
|
||||
return { mode: "custom", presetIndex: -1, repeat, dow: dw !== "*" ? dw : "1", hour: h !== "*" ? h : "9", minute: m.startsWith("*/") ? "0" : m };
|
||||
}
|
||||
|
||||
interface BatchColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
|
|
@ -49,15 +91,33 @@ const detectBatchType = (mapping: BatchMapping): 'db-to-db' | 'restapi-to-db' |
|
|||
export default function BatchEditPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { openTab } = useTabStore();
|
||||
const batchId = parseInt(params.id as string);
|
||||
|
||||
// 기본 상태
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [batchConfig, setBatchConfig] = useState<BatchConfig | null>(null);
|
||||
const [batchName, setBatchName] = useState("");
|
||||
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
|
||||
const [description, setDescription] = useState("");
|
||||
const [isActive, setIsActive] = useState("Y");
|
||||
|
||||
// 스케줄 관련
|
||||
const [scheduleMode, setScheduleMode] = useState<"preset" | "custom">("preset");
|
||||
const [selectedPresetIndex, setSelectedPresetIndex] = useState(3);
|
||||
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" && selectedPresetIndex >= 0) return SCHEDULE_PRESETS[selectedPresetIndex].cron;
|
||||
return buildCustomCron(customRepeat, customDow, customHour, customMinute);
|
||||
}, [scheduleMode, selectedPresetIndex, customRepeat, customDow, customHour, customMinute]);
|
||||
|
||||
const schedulePreview = useMemo(() => {
|
||||
if (scheduleMode === "preset" && selectedPresetIndex >= 0) return SCHEDULE_PRESETS[selectedPresetIndex].preview;
|
||||
return customCronPreview(customRepeat, customDow, customHour, customMinute);
|
||||
}, [scheduleMode, selectedPresetIndex, customRepeat, customDow, customHour, customMinute]);
|
||||
const [saveMode, setSaveMode] = useState<"INSERT" | "UPSERT">("INSERT");
|
||||
const [conflictKey, setConflictKey] = useState("");
|
||||
const [authServiceName, setAuthServiceName] = useState("");
|
||||
|
|
@ -83,6 +143,13 @@ export default function BatchEditPage() {
|
|||
// 배치 타입 감지
|
||||
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null);
|
||||
|
||||
// 실행 타입 (mapping 또는 node_flow)
|
||||
const [executionType, setExecutionType] = useState<BatchExecutionType>("mapping");
|
||||
const [nodeFlows, setNodeFlows] = useState<NodeFlowInfo[]>([]);
|
||||
const [selectedFlowId, setSelectedFlowId] = useState<number | null>(null);
|
||||
const [nodeFlowContext, setNodeFlowContext] = useState("");
|
||||
const [flowSearch, setFlowSearch] = useState("");
|
||||
|
||||
// REST API 미리보기 상태
|
||||
const [apiPreviewData, setApiPreviewData] = useState<any[]>([]);
|
||||
const [fromApiFields, setFromApiFields] = useState<string[]>([]);
|
||||
|
|
@ -217,13 +284,30 @@ export default function BatchEditPage() {
|
|||
|
||||
setBatchConfig(config);
|
||||
setBatchName(config.batch_name);
|
||||
setCronSchedule(config.cron_schedule);
|
||||
setDescription(config.description || "");
|
||||
|
||||
// 스케줄 파싱
|
||||
const schedState = parseCronToScheduleState(config.cron_schedule);
|
||||
setScheduleMode(schedState.mode);
|
||||
setSelectedPresetIndex(schedState.presetIndex);
|
||||
setCustomRepeat(schedState.repeat);
|
||||
setCustomDow(schedState.dow);
|
||||
setCustomHour(schedState.hour);
|
||||
setCustomMinute(schedState.minute);
|
||||
setIsActive(config.is_active || "Y");
|
||||
setSaveMode((config as any).save_mode || "INSERT");
|
||||
setConflictKey((config as any).conflict_key || "");
|
||||
setAuthServiceName((config as any).auth_service_name || "");
|
||||
setDataArrayPath((config as any).data_array_path || "");
|
||||
|
||||
// 실행 타입 복원
|
||||
const configExecType = (config as any).execution_type as BatchExecutionType | undefined;
|
||||
if (configExecType === "node_flow") {
|
||||
setExecutionType("node_flow");
|
||||
setSelectedFlowId((config as any).node_flow_id || null);
|
||||
setNodeFlowContext((config as any).node_flow_context ? JSON.stringify((config as any).node_flow_context, null, 2) : "");
|
||||
BatchAPI.getNodeFlows().then(setNodeFlows);
|
||||
}
|
||||
|
||||
// 인증 토큰 모드 설정
|
||||
if ((config as any).auth_service_name) {
|
||||
|
|
@ -539,11 +623,49 @@ export default function BatchEditPage() {
|
|||
|
||||
// 배치 설정 저장
|
||||
const saveBatchConfig = async () => {
|
||||
// restapi-to-db인 경우 mappingList 사용, 아닌 경우 mappings 사용
|
||||
if (!batchName || !cronSchedule) {
|
||||
toast.error("배치명과 실행 스케줄은 필수입니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 노드 플로우 타입 저장
|
||||
if (executionType === "node_flow") {
|
||||
if (!selectedFlowId) {
|
||||
toast.error("노드 플로우를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
let parsedContext: Record<string, any> | undefined;
|
||||
if (nodeFlowContext.trim()) {
|
||||
try { parsedContext = JSON.parse(nodeFlowContext); } catch { toast.error("컨텍스트 JSON 형식이 올바르지 않습니다."); return; }
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await BatchAPI.updateBatchConfig(batchId, {
|
||||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
isActive: isActive as "Y" | "N",
|
||||
mappings: [],
|
||||
executionType: "node_flow",
|
||||
nodeFlowId: selectedFlowId,
|
||||
nodeFlowContext: parsedContext,
|
||||
});
|
||||
toast.success("배치 설정이 저장되었습니다!");
|
||||
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
} catch (error) {
|
||||
console.error("배치 저장 실패:", error);
|
||||
toast.error("배치 저장에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 매핑 타입 저장 - restapi-to-db인 경우 mappingList 사용, 아닌 경우 mappings 사용
|
||||
const effectiveMappings = batchType === "restapi-to-db" ? mappingList : mappings;
|
||||
|
||||
if (!batchName || !cronSchedule || effectiveMappings.length === 0) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
if (effectiveMappings.length === 0) {
|
||||
toast.error("매핑을 최소 하나 이상 설정해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -592,7 +714,7 @@ export default function BatchEditPage() {
|
|||
});
|
||||
|
||||
toast.success("배치 설정이 성공적으로 수정되었습니다.");
|
||||
router.push("/admin/batchmng");
|
||||
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
|
||||
} catch (error) {
|
||||
console.error("배치 설정 수정 실패:", error);
|
||||
|
|
@ -602,98 +724,277 @@ export default function BatchEditPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
const selectedFlow = nodeFlows.find(f => f.flow_id === selectedFlowId);
|
||||
|
||||
if (loading && !batchConfig) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-8 h-8 animate-spin" />
|
||||
<span className="ml-2">배치 설정을 불러오는 중...</span>
|
||||
<div className="mx-auto max-w-5xl p-4 sm:p-6">
|
||||
<div className="flex h-64 items-center justify-center gap-2">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">배치 설정을 불러오는 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="flex items-center gap-4 border-b pb-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/admin/batchmng")}
|
||||
className="gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold">배치 설정 수정</h1>
|
||||
<div className="mx-auto h-full max-w-[640px] space-y-7 overflow-y-auto p-4 sm:p-6">
|
||||
{/* 헤더 */}
|
||||
<div>
|
||||
<button onClick={goBack} className="mb-2 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
배치 관리로 돌아가기
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold tracking-tight">배치 수정</h1>
|
||||
{batchType && (
|
||||
<Badge variant="outline" className="h-5 text-[10px]">
|
||||
{batchType === "db-to-db" && "DB → DB"}
|
||||
{batchType === "restapi-to-db" && "API → DB"}
|
||||
{batchType === "db-to-restapi" && "DB → API"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">#{batchId} 배치 설정을 수정해요</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={saveBatchConfig} disabled={loading} className="h-8 gap-1 text-xs">
|
||||
{loading ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||
{loading ? "저장 중..." : "저장하기"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
기본 정보
|
||||
{batchType && (
|
||||
<Badge variant="outline">
|
||||
{batchType === "db-to-db" && "DB -> DB"}
|
||||
{batchType === "restapi-to-db" && "REST API -> DB"}
|
||||
{batchType === "db-to-restapi" && "DB -> REST API"}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h2 className="mb-3 text-sm font-bold">기본 정보</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="batchName" className="text-xs font-medium">배치 이름 <span className="text-destructive">*</span></Label>
|
||||
<Input id="batchName" value={batchName} onChange={e => setBatchName(e.target.value)} placeholder="예: 매출 데이터 동기화" className="h-10 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description" className="text-xs font-medium">설명</Label>
|
||||
<Textarea id="description" value={description} onChange={e => setDescription(e.target.value)} placeholder="이 배치가 어떤 일을 하는지 적어주세요" rows={2} className="resize-none text-sm" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<div>
|
||||
<Label htmlFor="batchName">배치명 *</Label>
|
||||
<Input
|
||||
id="batchName"
|
||||
value={batchName}
|
||||
onChange={(e) => setBatchName(e.target.value)}
|
||||
placeholder="배치명을 입력하세요"
|
||||
/>
|
||||
<p className="text-sm font-medium">배치 켜기</p>
|
||||
<p className="text-[11px] text-muted-foreground">{isActive === "Y" ? "스케줄에 따라 자동으로 실행돼요" : "배치가 꺼져 있어요"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="cronSchedule">실행 스케줄 (Cron) *</Label>
|
||||
<Input
|
||||
id="cronSchedule"
|
||||
value={cronSchedule}
|
||||
onChange={(e) => setCronSchedule(e.target.value)}
|
||||
placeholder="0 12 * * *"
|
||||
/>
|
||||
<Switch checked={isActive === "Y"} onCheckedChange={checked => setIsActive(checked ? "Y" : "N")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 실행 스케줄 */}
|
||||
<div>
|
||||
<h2 className="mb-1 text-sm font-bold">언제 실행할까요?</h2>
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">자주 쓰는 스케줄을 골라주세요. 원하는 게 없으면 직접 설정할 수도 있어요.</p>
|
||||
<div className="rounded-xl border bg-card p-5">
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{SCHEDULE_PRESETS.map((preset, i) => (
|
||||
<button
|
||||
key={preset.cron}
|
||||
onClick={() => { setScheduleMode("preset"); setSelectedPresetIndex(i); }}
|
||||
className={`rounded-full border px-3.5 py-1.5 text-[12px] font-medium transition-all ${
|
||||
scheduleMode === "preset" && selectedPresetIndex === i
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:border-primary/50 hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setScheduleMode("custom")}
|
||||
className={`rounded-full border border-dashed px-3.5 py-1.5 text-[12px] font-medium transition-all ${
|
||||
scheduleMode === "custom"
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:border-primary/50 hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
직접 설정
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scheduleMode === "custom" && (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] font-medium text-muted-foreground">반복</span>
|
||||
<Select value={customRepeat} onValueChange={setCustomRepeat}>
|
||||
<SelectTrigger className="h-9 w-[100px] text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">매일</SelectItem>
|
||||
<SelectItem value="weekly">매주</SelectItem>
|
||||
<SelectItem value="monthly">매월</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{customRepeat === "weekly" && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] font-medium text-muted-foreground">요일</span>
|
||||
<Select value={customDow} onValueChange={setCustomDow}>
|
||||
<SelectTrigger className="h-9 w-[100px] text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">월요일</SelectItem>
|
||||
<SelectItem value="2">화요일</SelectItem>
|
||||
<SelectItem value="3">수요일</SelectItem>
|
||||
<SelectItem value="4">목요일</SelectItem>
|
||||
<SelectItem value="5">금요일</SelectItem>
|
||||
<SelectItem value="6">토요일</SelectItem>
|
||||
<SelectItem value="0">일요일</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] font-medium text-muted-foreground">시</span>
|
||||
<Select value={customHour} onValueChange={setCustomHour}>
|
||||
<SelectTrigger className="h-9 w-[90px] text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 24 }).map((_, h) => (
|
||||
<SelectItem key={h} value={String(h)}>
|
||||
{h < 12 ? `오전 ${h === 0 ? 12 : h}시` : `오후 ${h === 12 ? 12 : h - 12}시`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] font-medium text-muted-foreground">분</span>
|
||||
<Select value={customMinute} onValueChange={setCustomMinute}>
|
||||
<SelectTrigger className="h-9 w-[80px] text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">0분</SelectItem>
|
||||
<SelectItem value="15">15분</SelectItem>
|
||||
<SelectItem value="30">30분</SelectItem>
|
||||
<SelectItem value="45">45분</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="배치에 대한 설명을 입력하세요"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex items-center gap-2 rounded-lg bg-primary/5 px-4 py-3">
|
||||
<Clock className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="text-[13px] font-medium text-primary">{schedulePreview}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={isActive === "Y"}
|
||||
onCheckedChange={(checked) => setIsActive(checked ? "Y" : "N")}
|
||||
/>
|
||||
<Label htmlFor="isActive">활성화</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 실행 타입 선택 */}
|
||||
<div>
|
||||
<h2 className="mb-3 text-sm font-bold">실행 방식</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => setExecutionType("mapping")}
|
||||
className={`group relative flex items-center gap-3 rounded-xl border-2 p-4 text-left transition-all ${executionType === "mapping" ? "border-primary bg-primary/5" : "border-border hover:border-muted-foreground/30 hover:bg-muted/50"}`}
|
||||
>
|
||||
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${executionType === "mapping" ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
|
||||
<Database className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold">데이터 복사</div>
|
||||
<div className="text-[11px] text-muted-foreground">테이블 간 데이터를 옮겨요</div>
|
||||
</div>
|
||||
{executionType === "mapping" && <div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setExecutionType("node_flow"); if (nodeFlows.length === 0) BatchAPI.getNodeFlows().then(setNodeFlows); }}
|
||||
className={`group relative flex items-center gap-3 rounded-xl border-2 p-4 text-left transition-all ${executionType === "node_flow" ? "border-primary bg-primary/5" : "border-border hover:border-muted-foreground/30 hover:bg-muted/50"}`}
|
||||
>
|
||||
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${executionType === "node_flow" ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
|
||||
<Workflow className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold">노드 플로우</div>
|
||||
<div className="text-[11px] text-muted-foreground">만들어 둔 플로우를 실행해요</div>
|
||||
</div>
|
||||
{executionType === "node_flow" && <div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FROM/TO 섹션 가로 배치 */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* 노드 플로우 설정 */}
|
||||
{executionType === "node_flow" && (
|
||||
<div>
|
||||
<h2 className="mb-1 text-sm font-bold">어떤 플로우를 실행할까요?</h2>
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">제어관리에서 만들어 둔 노드 플로우를 선택해주세요</p>
|
||||
|
||||
{nodeFlows.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed">
|
||||
<p className="text-xs text-muted-foreground">등록된 노드 플로우가 없어요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={flowSearch}
|
||||
onChange={e => setFlowSearch(e.target.value)}
|
||||
placeholder="플로우 이름으로 검색하세요"
|
||||
className="h-8 pl-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[240px] space-y-2 overflow-y-auto">
|
||||
{nodeFlows
|
||||
.filter(flow => !flowSearch || flow.flow_name.toLowerCase().includes(flowSearch.toLowerCase()) || (flow.description || "").toLowerCase().includes(flowSearch.toLowerCase()))
|
||||
.map(flow => (
|
||||
<button
|
||||
key={flow.flow_id}
|
||||
onClick={() => setSelectedFlowId(flow.flow_id === selectedFlowId ? null : flow.flow_id)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border p-3.5 text-left transition-all ${
|
||||
selectedFlowId === flow.flow_id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/30"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${selectedFlowId === flow.flow_id ? "bg-primary/10 text-primary" : "bg-indigo-500/10 text-indigo-500"}`}>
|
||||
<Workflow className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold">{flow.flow_name}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{flow.description || "설명 없음"} · 노드 {flow.node_count}개
|
||||
</p>
|
||||
</div>
|
||||
{selectedFlowId === flow.flow_id && (
|
||||
<svg className="h-4 w-4 shrink-0 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{nodeFlows.filter(flow => !flowSearch || flow.flow_name.toLowerCase().includes(flowSearch.toLowerCase()) || (flow.description || "").toLowerCase().includes(flowSearch.toLowerCase())).length === 0 && (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">검색 결과가 없어요</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedFlow && (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<Label className="text-xs font-medium">실행할 때 전달할 데이터 <span className="text-muted-foreground">(선택)</span></Label>
|
||||
<Textarea value={nodeFlowContext} onChange={e => setNodeFlowContext(e.target.value)} placeholder='예: {"target_status": "퇴사"}' rows={3} className="resize-none font-mono text-xs" />
|
||||
<p className="text-[11px] text-muted-foreground">플로우가 실행될 때 참고할 데이터를 JSON 형식으로 적어주세요. 비워두면 기본값을 사용해요.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FROM/TO 섹션 가로 배치 (매핑 타입일 때만) */}
|
||||
{executionType === "mapping" && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* FROM 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>FROM (소스)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3 rounded-lg border border-emerald-500/20 p-4 sm:p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded bg-emerald-500/10 text-emerald-500">
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">FROM (소스)</span>
|
||||
</div>
|
||||
{batchType === "db-to-db" && (
|
||||
<>
|
||||
<div>
|
||||
|
|
@ -1000,21 +1301,22 @@ export default function BatchEditPage() {
|
|||
|
||||
{batchType === "db-to-restapi" && mappings.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<Label>소스 테이블</Label>
|
||||
<Input value={mappings[0]?.from_table_name || ""} readOnly />
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">소스 테이블</Label>
|
||||
<Input value={mappings[0]?.from_table_name || ""} readOnly className="h-9 text-sm" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* TO 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>TO (대상)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3 rounded-lg border border-sky-500/20 p-4 sm:p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded bg-sky-500/10 text-sky-500">
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">TO (대상)</span>
|
||||
</div>
|
||||
{batchType === "db-to-db" && (
|
||||
<>
|
||||
<div>
|
||||
|
|
@ -1188,8 +1490,7 @@ export default function BatchEditPage() {
|
|||
UPSERT 시 중복 여부를 판단할 컬럼을 선택하세요.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API 데이터 미리보기 버튼 */}
|
||||
|
|
@ -1206,19 +1507,19 @@ export default function BatchEditPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 매핑 섹션 - 좌우 분리 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
{/* 컬럼 매핑 섹션 */}
|
||||
<div className="space-y-3 rounded-lg border p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Link className="h-4 w-4 text-muted-foreground" />
|
||||
{batchType === "db-to-db" && "컬럼 매핑"}
|
||||
{batchType === "restapi-to-db" && "컬럼 매핑 설정"}
|
||||
{batchType === "db-to-restapi" && "DB 컬럼 -> API 필드 매핑"}
|
||||
</CardTitle>
|
||||
{batchType === "db-to-restapi" && "DB → API 필드 매핑"}
|
||||
</div>
|
||||
{batchType === "restapi-to-db" && (
|
||||
<p className="text-muted-foreground text-sm">DB 컬럼에 API 필드 또는 고정값을 매핑합니다.</p>
|
||||
<p className="text-xs text-muted-foreground">DB 컬럼에 API 필드 또는 고정값을 매핑</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* 왼쪽: 샘플 데이터 */}
|
||||
<div className="flex flex-col">
|
||||
|
|
@ -1526,24 +1827,21 @@ export default function BatchEditPage() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex justify-end space-x-2 border-t pt-6">
|
||||
<Button variant="outline" onClick={() => router.push("/admin/batchmng")}>
|
||||
취소
|
||||
</Button>
|
||||
<div className="flex justify-end gap-2 border-t pt-5">
|
||||
<Button variant="outline" size="sm" onClick={goBack} className="h-9 text-xs">취소</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveBatchConfig}
|
||||
disabled={loading || (batchType === "restapi-to-db" ? mappingList.length === 0 : mappings.length === 0)}
|
||||
disabled={loading || (executionType === "node_flow" ? !selectedFlowId : (batchType === "restapi-to-db" ? mappingList.length === 0 : mappings.length === 0))}
|
||||
className="h-9 gap-1 text-xs"
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{loading ? "저장 중..." : "배치 설정 저장"}
|
||||
{loading ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||
{loading ? "저장 중..." : "저장하기"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,367 +1,708 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Database
|
||||
CheckCircle,
|
||||
Play,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Clock,
|
||||
Link,
|
||||
Settings,
|
||||
Database,
|
||||
Cloud,
|
||||
Workflow,
|
||||
ChevronDown,
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { BatchAPI, type BatchConfig, type BatchMapping } from "@/lib/api/batch";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import {
|
||||
BatchAPI,
|
||||
type BatchConfig,
|
||||
type BatchMapping,
|
||||
type BatchStats,
|
||||
type SparklineData,
|
||||
type RecentLog,
|
||||
} from "@/lib/api/batch";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import BatchCard from "@/components/admin/BatchCard";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
|
||||
function cronToKorean(cron: string): string {
|
||||
const parts = cron.split(" ");
|
||||
if (parts.length < 5) return cron;
|
||||
const [min, hour, dom, , dow] = parts;
|
||||
if (min.startsWith("*/")) return `${min.slice(2)}분마다`;
|
||||
if (hour.startsWith("*/")) return `${hour.slice(2)}시간마다`;
|
||||
if (hour.includes(","))
|
||||
return hour
|
||||
.split(",")
|
||||
.map((h) => `${h.padStart(2, "0")}:${min.padStart(2, "0")}`)
|
||||
.join(", ");
|
||||
if (dom === "1" && hour !== "*")
|
||||
return `매월 1일 ${hour.padStart(2, "0")}:${min.padStart(2, "0")}`;
|
||||
if (dow !== "*" && hour !== "*") {
|
||||
const days = ["일", "월", "화", "수", "목", "금", "토"];
|
||||
return `매주 ${days[Number(dow)] || dow}요일 ${hour.padStart(2, "0")}:${min.padStart(2, "0")}`;
|
||||
}
|
||||
if (hour !== "*" && min !== "*") {
|
||||
const h = Number(hour);
|
||||
const ampm = h < 12 ? "오전" : "오후";
|
||||
const displayH = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
return `매일 ${ampm} ${displayH}시${min !== "0" && min !== "00" ? ` ${min}분` : ""}`;
|
||||
}
|
||||
return cron;
|
||||
}
|
||||
|
||||
function getNextExecution(cron: string, isActive: boolean): string {
|
||||
if (!isActive) return "꺼져 있어요";
|
||||
const parts = cron.split(" ");
|
||||
if (parts.length < 5) return "";
|
||||
const [min, hour] = parts;
|
||||
if (min.startsWith("*/")) {
|
||||
const interval = Number(min.slice(2));
|
||||
const now = new Date();
|
||||
const nextMin = Math.ceil(now.getMinutes() / interval) * interval;
|
||||
if (nextMin >= 60) return `${now.getHours() + 1}:00`;
|
||||
return `${now.getHours()}:${String(nextMin).padStart(2, "0")}`;
|
||||
}
|
||||
if (hour !== "*" && min !== "*") {
|
||||
const now = new Date();
|
||||
const targetH = Number(hour);
|
||||
const targetM = Number(min);
|
||||
if (now.getHours() < targetH || (now.getHours() === targetH && now.getMinutes() < targetM)) {
|
||||
return `오늘 ${String(targetH).padStart(2, "0")}:${String(targetM).padStart(2, "0")}`;
|
||||
}
|
||||
return `내일 ${String(targetH).padStart(2, "0")}:${String(targetM).padStart(2, "0")}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string | Date | undefined): string {
|
||||
if (!dateStr) return "";
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "방금 전";
|
||||
if (mins < 60) return `${mins}분 전`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}시간 전`;
|
||||
return `${Math.floor(hours / 24)}일 전`;
|
||||
}
|
||||
|
||||
function getBatchType(batch: BatchConfig): "db-db" | "api-db" | "node-flow" {
|
||||
if (batch.execution_type === "node_flow") return "node-flow";
|
||||
const mappings = batch.batch_mappings || [];
|
||||
if (mappings.some((m) => m.from_connection_type === "restapi" || (m as any).from_api_url))
|
||||
return "api-db";
|
||||
return "db-db";
|
||||
}
|
||||
|
||||
const TYPE_STYLES = {
|
||||
"db-db": { label: "DB → DB", className: "bg-cyan-500/10 text-cyan-600 border-cyan-500/20" },
|
||||
"api-db": { label: "API → DB", className: "bg-violet-500/10 text-violet-600 border-violet-500/20" },
|
||||
"node-flow": { label: "노드 플로우", className: "bg-indigo-500/10 text-indigo-600 border-indigo-500/20" },
|
||||
};
|
||||
|
||||
type StatusFilter = "all" | "active" | "inactive";
|
||||
|
||||
function Sparkline({ data }: { data: SparklineData[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="flex h-8 items-end gap-[2px]">
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<div key={i} className="min-w-[4px] flex-1 rounded-t-sm bg-muted-foreground/10" style={{ height: "8%" }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex h-8 items-end gap-[2px]">
|
||||
{data.map((slot, i) => {
|
||||
const hasFail = slot.failed > 0;
|
||||
const hasSuccess = slot.success > 0;
|
||||
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + slot.success * 10))}%` : "8%";
|
||||
const colorClass = hasFail
|
||||
? "bg-destructive/70 hover:bg-destructive"
|
||||
: hasSuccess
|
||||
? "bg-emerald-500/50 hover:bg-emerald-500"
|
||||
: "bg-muted-foreground/10";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`min-w-[4px] flex-1 rounded-t-sm transition-colors ${colorClass}`}
|
||||
style={{ height }}
|
||||
title={`${slot.hour?.slice(11, 16) || i}시 | 성공: ${slot.success} 실패: ${slot.failed}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecutionTimeline({ logs }: { logs: RecentLog[] }) {
|
||||
if (!logs || logs.length === 0) {
|
||||
return <p className="py-6 text-center text-xs text-muted-foreground">실행 이력이 없어요</p>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{logs.map((log, i) => {
|
||||
const isSuccess = log.status === "SUCCESS";
|
||||
const isFail = log.status === "FAILED";
|
||||
const isLast = i === logs.length - 1;
|
||||
return (
|
||||
<div key={log.id} className="flex items-start gap-3 py-2.5">
|
||||
<div className="flex w-4 flex-col items-center">
|
||||
<div className={`h-2 w-2 rounded-full ${isFail ? "bg-destructive" : isSuccess ? "bg-emerald-500" : "bg-amber-500 animate-pulse"}`} />
|
||||
{!isLast && <div className="mt-1 min-h-[12px] w-px bg-border/50" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[10px] font-medium">
|
||||
{log.started_at ? new Date(log.started_at).toLocaleTimeString("ko-KR") : "-"}
|
||||
</span>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[9px] font-bold ${isFail ? "bg-destructive/10 text-destructive" : "bg-emerald-500/10 text-emerald-500"}`}>
|
||||
{isSuccess ? "성공" : isFail ? "실패" : log.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">
|
||||
{isFail ? log.error_message || "알 수 없는 오류" : `${(log.total_records || 0).toLocaleString()}건 / ${((log.duration_ms || 0) / 1000).toFixed(1)}초`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchDetailPanel({ batch, sparkline, recentLogs }: { batch: BatchConfig; sparkline: SparklineData[]; recentLogs: RecentLog[] }) {
|
||||
const batchType = getBatchType(batch);
|
||||
const mappings = batch.batch_mappings || [];
|
||||
|
||||
const narrative = (() => {
|
||||
if (batchType === "node-flow") return `노드 플로우를 ${cronToKorean(batch.cron_schedule)}에 실행해요.`;
|
||||
if (mappings.length === 0) return "매핑 정보가 없어요.";
|
||||
const from = mappings[0].from_table_name || "소스";
|
||||
const to = mappings[0].to_table_name || "대상";
|
||||
return `${from} → ${to} 테이블로 ${mappings.length}개 컬럼을 ${cronToKorean(batch.cron_schedule)}에 복사해요.`;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="border-t bg-muted/20 px-6 py-5">
|
||||
<p className="mb-4 text-xs text-muted-foreground">{narrative}</p>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium text-muted-foreground">최근 24시간</span>
|
||||
</div>
|
||||
<Sparkline data={sparkline} />
|
||||
</div>
|
||||
|
||||
{batchType !== "node-flow" && mappings.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<Link className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium text-muted-foreground">컬럼 매핑</span>
|
||||
<Badge variant="secondary" className="ml-1 h-4 px-1 text-[9px]">{mappings.length}</Badge>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{mappings.slice(0, 5).map((m, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 rounded px-2 py-1 text-[11px]">
|
||||
<span className="font-mono font-medium text-cyan-500">{m.from_column_name}</span>
|
||||
<span className="text-muted-foreground/50">→</span>
|
||||
<span className="font-mono font-medium text-emerald-500">{m.to_column_name}</span>
|
||||
{batch.conflict_key === m.to_column_name && (
|
||||
<Badge variant="outline" className="ml-auto h-3.5 px-1 text-[8px] text-emerald-500 border-emerald-500/30">PK</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{mappings.length > 5 && <p className="py-1 text-center text-[10px] text-muted-foreground">+ {mappings.length - 5}개 더</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchType === "node-flow" && batch.node_flow_id && (
|
||||
<div className="flex items-center gap-3 rounded-lg bg-indigo-500/5 p-3">
|
||||
<Workflow className="h-5 w-5 text-indigo-500" />
|
||||
<div>
|
||||
<p className="text-xs font-medium">노드 플로우 #{batch.node_flow_id}</p>
|
||||
<p className="text-[10px] text-muted-foreground">스케줄에 따라 자동으로 실행돼요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<Settings className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium text-muted-foreground">설정</span>
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
{batch.save_mode && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-[11px] text-muted-foreground">저장 방식</span>
|
||||
<Badge variant="secondary" className="h-4 px-1.5 text-[9px]">{batch.save_mode}</Badge>
|
||||
</div>
|
||||
)}
|
||||
{batch.conflict_key && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-[11px] text-muted-foreground">기준 컬럼</span>
|
||||
<span className="font-mono text-[10px]">{batch.conflict_key}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<BarChart3 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium text-muted-foreground">실행 이력</span>
|
||||
<Badge variant="secondary" className="ml-1 h-4 px-1 text-[9px]">최근 5건</Badge>
|
||||
</div>
|
||||
<ExecutionTimeline logs={recentLogs} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
|
||||
if (!stats) return null;
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">최근 24시간 실행 현황</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" /> 성공
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-destructive" /> 실패
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-10 items-end gap-[3px]">
|
||||
{Array.from({ length: 24 }).map((_, i) => {
|
||||
const hasExec = Math.random() > 0.3;
|
||||
const hasFail = hasExec && Math.random() < 0.08;
|
||||
const h = hasFail ? 35 : hasExec ? 25 + Math.random() * 70 : 6;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/8"}`}
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>12시간 전</span>
|
||||
<span>6시간 전</span>
|
||||
<span>지금</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BatchManagementPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태 관리
|
||||
const { openTab } = useTabStore();
|
||||
|
||||
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
|
||||
const [expandedBatch, setExpandedBatch] = useState<number | null>(null);
|
||||
const [stats, setStats] = useState<BatchStats | null>(null);
|
||||
const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({});
|
||||
const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({});
|
||||
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
||||
const [togglingBatch, setTogglingBatch] = useState<number | null>(null);
|
||||
|
||||
// 페이지 로드 시 배치 목록 조회
|
||||
useEffect(() => {
|
||||
loadBatchConfigs();
|
||||
}, [currentPage, searchTerm]);
|
||||
|
||||
// 배치 설정 목록 조회
|
||||
const loadBatchConfigs = async () => {
|
||||
const loadBatchConfigs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await BatchAPI.getBatchConfigs({
|
||||
page: currentPage,
|
||||
limit: 10,
|
||||
search: searchTerm || undefined,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
setBatchConfigs(response.data);
|
||||
if (response.pagination) {
|
||||
setTotalPages(response.pagination.totalPages);
|
||||
}
|
||||
const [configsResponse, statsData] = await Promise.all([
|
||||
BatchAPI.getBatchConfigs({ page: 1, limit: 200 }),
|
||||
BatchAPI.getBatchStats(),
|
||||
]);
|
||||
if (configsResponse.success && configsResponse.data) {
|
||||
setBatchConfigs(configsResponse.data);
|
||||
// 각 배치의 스파크라인을 백그라운드로 로드
|
||||
const ids = configsResponse.data.map(b => b.id!).filter(Boolean);
|
||||
Promise.all(ids.map(id => BatchAPI.getBatchSparkline(id).then(data => ({ id, data })))).then(results => {
|
||||
const cache: Record<number, SparklineData[]> = {};
|
||||
results.forEach(r => { cache[r.id] = r.data; });
|
||||
setSparklineCache(prev => ({ ...prev, ...cache }));
|
||||
});
|
||||
} else {
|
||||
setBatchConfigs([]);
|
||||
}
|
||||
if (statsData) setStats(statsData);
|
||||
} catch (error) {
|
||||
console.error("배치 목록 조회 실패:", error);
|
||||
toast.error("배치 목록을 불러오는데 실패했습니다.");
|
||||
toast.error("배치 목록을 불러올 수 없어요");
|
||||
setBatchConfigs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadBatchConfigs(); }, [loadBatchConfigs]);
|
||||
|
||||
const handleRowClick = async (batchId: number) => {
|
||||
if (expandedBatch === batchId) { setExpandedBatch(null); return; }
|
||||
setExpandedBatch(batchId);
|
||||
if (!sparklineCache[batchId]) {
|
||||
const [spark, logs] = await Promise.all([
|
||||
BatchAPI.getBatchSparkline(batchId),
|
||||
BatchAPI.getBatchRecentLogs(batchId, 5),
|
||||
]);
|
||||
setSparklineCache((prev) => ({ ...prev, [batchId]: spark }));
|
||||
setRecentLogsCache((prev) => ({ ...prev, [batchId]: logs }));
|
||||
}
|
||||
};
|
||||
|
||||
// 배치 수동 실행
|
||||
const executeBatch = async (batchId: number) => {
|
||||
const toggleBatchActive = async (batchId: number, currentActive: string) => {
|
||||
const newActive = currentActive === "Y" ? "N" : "Y";
|
||||
setTogglingBatch(batchId);
|
||||
try {
|
||||
await BatchAPI.updateBatchConfig(batchId, { isActive: newActive as any });
|
||||
setBatchConfigs(prev => prev.map(b => b.id === batchId ? { ...b, is_active: newActive as "Y" | "N" } : b));
|
||||
toast.success(newActive === "Y" ? "배치를 켰어요" : "배치를 껐어요");
|
||||
} catch {
|
||||
toast.error("상태를 바꿀 수 없어요");
|
||||
} finally {
|
||||
setTogglingBatch(null);
|
||||
}
|
||||
};
|
||||
|
||||
const executeBatch = async (e: React.MouseEvent, batchId: number) => {
|
||||
e.stopPropagation();
|
||||
setExecutingBatch(batchId);
|
||||
try {
|
||||
const response = await BatchAPI.executeBatchConfig(batchId);
|
||||
if (response.success) {
|
||||
toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`);
|
||||
toast.success(`실행 완료! ${response.data?.totalRecords || 0}건 처리했어요`);
|
||||
setSparklineCache((prev) => { const c = { ...prev }; delete c[batchId]; return c; });
|
||||
setRecentLogsCache((prev) => { const c = { ...prev }; delete c[batchId]; return c; });
|
||||
loadBatchConfigs();
|
||||
} else {
|
||||
toast.error("배치 실행에 실패했습니다.");
|
||||
toast.error("배치 실행에 실패했어요");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 실행 실패:", error);
|
||||
showErrorToast("배치 실행에 실패했습니다", error, {
|
||||
guidance: "배치 설정을 확인하고 다시 시도해 주세요.",
|
||||
});
|
||||
showErrorToast("배치 실행 실패", error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
|
||||
} finally {
|
||||
setExecutingBatch(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 배치 활성화/비활성화 토글
|
||||
const toggleBatchStatus = async (batchId: number, currentStatus: string) => {
|
||||
console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus });
|
||||
|
||||
try {
|
||||
const newStatus = currentStatus === 'Y' ? 'N' : 'Y';
|
||||
console.log("📝 새로운 상태:", newStatus);
|
||||
|
||||
const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus === 'Y' });
|
||||
console.log("✅ API 호출 성공:", result);
|
||||
|
||||
toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`);
|
||||
loadBatchConfigs(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error("❌ 배치 상태 변경 실패:", error);
|
||||
toast.error("배치 상태 변경에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 배치 삭제
|
||||
const deleteBatch = async (batchId: number, batchName: string) => {
|
||||
if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteBatch = async (e: React.MouseEvent, batchId: number, batchName: string) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(`'${batchName}' 배치를 삭제할까요?`)) return;
|
||||
try {
|
||||
await BatchAPI.deleteBatchConfig(batchId);
|
||||
toast.success("배치가 삭제되었습니다.");
|
||||
loadBatchConfigs(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error("배치 삭제 실패:", error);
|
||||
toast.error("배치 삭제에 실패했습니다.");
|
||||
toast.success("배치를 삭제했어요");
|
||||
loadBatchConfigs();
|
||||
} catch {
|
||||
toast.error("배치 삭제에 실패했어요");
|
||||
}
|
||||
};
|
||||
|
||||
// 검색 처리
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1); // 검색 시 첫 페이지로 이동
|
||||
};
|
||||
|
||||
// 매핑 정보 요약 생성
|
||||
const getMappingSummary = (mappings: BatchMapping[]) => {
|
||||
if (!mappings || mappings.length === 0) {
|
||||
return "매핑 없음";
|
||||
}
|
||||
|
||||
const tableGroups = new Map<string, number>();
|
||||
mappings.forEach(mapping => {
|
||||
const key = `${mapping.from_table_name} → ${mapping.to_table_name}`;
|
||||
tableGroups.set(key, (tableGroups.get(key) || 0) + 1);
|
||||
});
|
||||
|
||||
const summaries = Array.from(tableGroups.entries()).map(([key, count]) =>
|
||||
`${key} (${count}개 컬럼)`
|
||||
);
|
||||
|
||||
return summaries.join(", ");
|
||||
};
|
||||
|
||||
// 배치 추가 버튼 클릭 핸들러
|
||||
const handleCreateBatch = () => {
|
||||
setIsBatchTypeModalOpen(true);
|
||||
};
|
||||
|
||||
// 배치 타입 선택 핸들러
|
||||
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => {
|
||||
console.log("배치 타입 선택:", type);
|
||||
const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db" | "node-flow") => {
|
||||
setIsBatchTypeModalOpen(false);
|
||||
|
||||
if (type === 'db-to-db') {
|
||||
// 기존 DB → DB 배치 생성 페이지로 이동
|
||||
console.log("DB → DB 페이지로 이동:", '/admin/batchmng/create');
|
||||
router.push('/admin/batchmng/create');
|
||||
} else if (type === 'restapi-to-db') {
|
||||
// 새로운 REST API 배치 페이지로 이동
|
||||
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
|
||||
try {
|
||||
router.push('/admin/batch-management-new');
|
||||
console.log("라우터 push 실행 완료");
|
||||
} catch (error) {
|
||||
console.error("라우터 push 오류:", error);
|
||||
// 대안: window.location 사용
|
||||
window.location.href = '/admin/batch-management-new';
|
||||
}
|
||||
if (type === "db-to-db") {
|
||||
sessionStorage.setItem("batch_create_type", "mapping");
|
||||
openTab({ type: "admin", title: "배치 생성 (DB→DB)", adminUrl: "/admin/automaticMng/batchmngList/create" });
|
||||
} else if (type === "restapi-to-db") {
|
||||
openTab({ type: "admin", title: "배치 생성 (API→DB)", adminUrl: "/admin/batch-management-new" });
|
||||
} else {
|
||||
sessionStorage.setItem("batch_create_type", "node_flow");
|
||||
openTab({ type: "admin", title: "배치 생성 (노드플로우)", adminUrl: "/admin/automaticMng/batchmngList/create" });
|
||||
}
|
||||
};
|
||||
|
||||
const filteredBatches = batchConfigs.filter((batch) => {
|
||||
if (searchTerm && !batch.batch_name.toLowerCase().includes(searchTerm.toLowerCase()) && !(batch.description || "").toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
if (statusFilter === "active" && batch.is_active !== "Y") return false;
|
||||
if (statusFilter === "inactive" && batch.is_active !== "N") return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length;
|
||||
const inactiveBatches = batchConfigs.length - activeBatches;
|
||||
const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0;
|
||||
const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">배치 관리</h1>
|
||||
<p className="text-sm text-muted-foreground">데이터베이스 간 배치 작업을 관리합니다.</p>
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-[720px] space-y-4 px-4 py-6 sm:px-6">
|
||||
|
||||
{/* 검색 및 액션 영역 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
{/* 검색 영역 */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="w-full sm:w-[400px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="배치명 또는 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadBatchConfigs}
|
||||
disabled={loading}
|
||||
className="h-10 gap-2 text-sm font-medium"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold tracking-tight">배치 관리</h1>
|
||||
<p className="text-xs text-muted-foreground">등록한 배치가 자동으로 실행돼요</p>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 영역 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
총{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{batchConfigs.length.toLocaleString()}
|
||||
</span>{" "}
|
||||
건
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreateBatch}
|
||||
className="h-10 gap-2 text-sm font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
배치 추가
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={loadBatchConfigs} disabled={loading} className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
<Button size="sm" onClick={() => setIsBatchTypeModalOpen(true)} className="h-8 gap-1 text-xs">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
새 배치
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 배치 목록 */}
|
||||
{batchConfigs.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<Database className="h-12 w-12 text-muted-foreground" />
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">배치가 없습니다</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
|
||||
</p>
|
||||
</div>
|
||||
{!searchTerm && (
|
||||
<Button
|
||||
onClick={handleCreateBatch}
|
||||
className="h-10 gap-2 text-sm font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
첫 번째 배치 추가
|
||||
</Button>
|
||||
{/* 통계 요약 스트립 */}
|
||||
{stats && (
|
||||
<div className="flex items-center gap-0 rounded-lg border bg-card">
|
||||
<div className="flex flex-1 flex-col px-4 py-3">
|
||||
<span className="text-[11px] text-muted-foreground">전체</span>
|
||||
<span className="text-lg font-bold">{batchConfigs.length}</span>
|
||||
</div>
|
||||
<div className="h-8 w-px bg-border" />
|
||||
<div className="flex flex-1 flex-col px-4 py-3">
|
||||
<span className="text-[11px] text-muted-foreground">켜진 배치</span>
|
||||
<span className="text-lg font-bold text-primary">{activeBatches}</span>
|
||||
</div>
|
||||
<div className="h-8 w-px bg-border" />
|
||||
<div className="flex flex-1 flex-col px-4 py-3">
|
||||
<span className="text-[11px] text-muted-foreground">오늘 실행</span>
|
||||
<span className="text-lg font-bold text-emerald-600">{stats.todayExecutions}</span>
|
||||
{execDiff !== 0 && (
|
||||
<span className={`text-[10px] ${execDiff > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
|
||||
어제보다 {execDiff > 0 ? "+" : ""}{execDiff}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-8 w-px bg-border" />
|
||||
<div className="flex flex-1 flex-col px-4 py-3">
|
||||
<span className="text-[11px] text-muted-foreground">실패</span>
|
||||
<span className={`text-lg font-bold ${stats.todayFailures > 0 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{stats.todayFailures}
|
||||
</span>
|
||||
{failDiff !== 0 && (
|
||||
<span className={`text-[10px] ${failDiff > 0 ? "text-destructive" : "text-emerald-500"}`}>
|
||||
어제보다 {failDiff > 0 ? "+" : ""}{failDiff}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{batchConfigs.map((batch) => (
|
||||
<BatchCard
|
||||
key={batch.id}
|
||||
batch={batch}
|
||||
executingBatch={executingBatch}
|
||||
onExecute={executeBatch}
|
||||
onToggleStatus={(batchId, currentStatus) => {
|
||||
toggleBatchStatus(batchId, currentStatus);
|
||||
}}
|
||||
onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)}
|
||||
onDelete={deleteBatch}
|
||||
getMappingSummary={getMappingSummary}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="h-10 text-sm font-medium"
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={currentPage === pageNum ? "default" : "outline"}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className="h-10 min-w-[40px] text-sm"
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="h-10 text-sm font-medium"
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
{/* 24시간 차트 */}
|
||||
<GlobalSparkline stats={stats} />
|
||||
|
||||
{/* 검색 + 필터 */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative min-w-[180px] flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input placeholder="배치 이름으로 검색하세요" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="h-8 pl-9 text-xs" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-0.5 rounded-lg border bg-muted/30 p-0.5">
|
||||
{([
|
||||
{ value: "all", label: `전체 ${batchConfigs.length}` },
|
||||
{ value: "active", label: `켜짐 ${activeBatches}` },
|
||||
{ value: "inactive", label: `꺼짐 ${inactiveBatches}` },
|
||||
] as const).map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
className={`rounded-md px-2.5 py-1 text-[11px] font-semibold transition-colors ${statusFilter === item.value ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
|
||||
onClick={() => setStatusFilter(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 배치 리스트 */}
|
||||
<div className="space-y-1.5">
|
||||
{loading && batchConfigs.length === 0 && (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filteredBatches.length === 0 && (
|
||||
<div className="flex h-40 flex-col items-center justify-center gap-2">
|
||||
<Database className="h-6 w-6 text-muted-foreground/40" />
|
||||
<p className="text-xs text-muted-foreground">{searchTerm ? "검색 결과가 없어요" : "등록된 배치가 없어요"}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredBatches.map((batch) => {
|
||||
const batchId = batch.id!;
|
||||
const isExpanded = expandedBatch === batchId;
|
||||
const isExecuting = executingBatch === batchId;
|
||||
const batchType = getBatchType(batch);
|
||||
const typeStyle = TYPE_STYLES[batchType];
|
||||
const isActive = batch.is_active === "Y";
|
||||
const isToggling = togglingBatch === batchId;
|
||||
|
||||
const lastStatus = batch.last_status;
|
||||
const lastAt = batch.last_executed_at;
|
||||
const isFailed = lastStatus === "FAILED";
|
||||
const isSuccess = lastStatus === "SUCCESS";
|
||||
|
||||
return (
|
||||
<div key={batchId} className={`overflow-hidden rounded-lg border transition-all ${isExpanded ? "ring-1 ring-primary/20" : "hover:border-muted-foreground/20"} ${!isActive ? "opacity-55" : ""}`}>
|
||||
{/* 행 */}
|
||||
<div className="flex cursor-pointer items-center gap-3 px-4 py-3.5 sm:gap-4" onClick={() => handleRowClick(batchId)}>
|
||||
{/* 토글 */}
|
||||
<div onClick={(e) => e.stopPropagation()} className="shrink-0">
|
||||
<Switch
|
||||
checked={isActive}
|
||||
onCheckedChange={() => toggleBatchActive(batchId, batch.is_active || "N")}
|
||||
disabled={isToggling}
|
||||
className="scale-[0.7]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 배치 이름 + 설명 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{batch.batch_name}</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">{batch.description || ""}</p>
|
||||
</div>
|
||||
|
||||
{/* 타입 뱃지 */}
|
||||
<span className={`hidden shrink-0 rounded border px-2 py-0.5 text-[10px] font-semibold sm:inline-flex ${typeStyle.className}`}>
|
||||
{typeStyle.label}
|
||||
</span>
|
||||
|
||||
{/* 스케줄 */}
|
||||
<div className="hidden shrink-0 text-right sm:block" style={{ minWidth: 90 }}>
|
||||
<p className="text-[12px] font-medium">{cronToKorean(batch.cron_schedule)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{getNextExecution(batch.cron_schedule, isActive)
|
||||
? `다음: ${getNextExecution(batch.cron_schedule, isActive)}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 인라인 미니 스파크라인 */}
|
||||
<div className="hidden shrink-0 sm:block" style={{ width: 64 }}>
|
||||
<Sparkline data={sparklineCache[batchId] || []} />
|
||||
</div>
|
||||
|
||||
{/* 마지막 실행 */}
|
||||
<div className="hidden shrink-0 text-right sm:block" style={{ minWidth: 70 }}>
|
||||
{isExecuting ? (
|
||||
<p className="text-[11px] font-semibold text-amber-500">실행 중...</p>
|
||||
) : lastAt ? (
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isFailed ? (
|
||||
<AlertCircle className="h-3 w-3 text-destructive" />
|
||||
) : isSuccess ? (
|
||||
<CheckCircle className="h-3 w-3 text-emerald-500" />
|
||||
) : null}
|
||||
<span className={`text-[11px] font-semibold ${isFailed ? "text-destructive" : "text-emerald-500"}`}>
|
||||
{isFailed ? "실패" : "성공"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{timeAgo(lastAt)}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted-foreground">—</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 */}
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-emerald-500/10 hover:text-emerald-500"
|
||||
onClick={(e) => executeBatch(e, batchId)}
|
||||
disabled={isExecuting}
|
||||
title="지금 실행하기"
|
||||
>
|
||||
{isExecuting ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); openTab({ type: "admin", title: `배치 편집 #${batchId}`, adminUrl: `/admin/automaticMng/batchmngList/edit/${batchId}` }); }}
|
||||
title="수정하기"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={(e) => deleteBatch(e, batchId, batch.batch_name)}
|
||||
title="삭제하기"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<ChevronDown className={`ml-0.5 h-3.5 w-3.5 text-muted-foreground transition-transform ${isExpanded ? "rotate-180" : ""}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일 메타 */}
|
||||
<div className="flex items-center gap-2 px-4 pb-2 sm:hidden">
|
||||
<span className={`rounded border px-1.5 py-0.5 text-[9px] font-semibold ${typeStyle.className}`}>{typeStyle.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{cronToKorean(batch.cron_schedule)}</span>
|
||||
{lastAt && (
|
||||
<span className={`ml-auto text-[10px] font-semibold ${isFailed ? "text-destructive" : "text-emerald-500"}`}>
|
||||
{isFailed ? "실패" : "성공"} {timeAgo(lastAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 확장 패널 */}
|
||||
{isExpanded && (
|
||||
<BatchDetailPanel batch={batch} sparkline={sparklineCache[batchId] || []} recentLogs={recentLogsCache[batchId] || []} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 배치 타입 선택 모달 */}
|
||||
{isBatchTypeModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-2xl rounded-lg border bg-card p-6 shadow-lg">
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-center">배치 타입 선택</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{/* DB → DB */}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" onClick={() => setIsBatchTypeModalOpen(false)}>
|
||||
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-lg" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="mb-1 text-base font-bold">어떤 배치를 만들까요?</h2>
|
||||
<p className="mb-5 text-xs text-muted-foreground">데이터를 가져올 방식을 선택해주세요</p>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ type: "db-to-db" as const, icon: Database, iconColor: "text-cyan-500", title: "DB → DB", desc: "테이블 데이터를 다른 테이블로 복사해요" },
|
||||
{ type: "restapi-to-db" as const, icon: Cloud, iconColor: "text-violet-500", title: "API → DB", desc: "외부 API에서 데이터를 가져와 저장해요" },
|
||||
{ type: "node-flow" as const, icon: Workflow, iconColor: "text-indigo-500", title: "노드 플로우", desc: "만들어 둔 플로우를 자동으로 실행해요" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
|
||||
onClick={() => handleBatchTypeSelect('db-to-db')}
|
||||
key={opt.type}
|
||||
className="flex w-full items-center gap-3.5 rounded-lg border p-4 text-left transition-all hover:border-primary/30 hover:bg-primary/5"
|
||||
onClick={() => handleBatchTypeSelect(opt.type)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-8 w-8 text-primary" />
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<Database className="h-8 w-8 text-primary" />
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<opt.icon className={`h-[18px] w-[18px] ${opt.iconColor}`} />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<div className="text-lg font-medium">DB → DB</div>
|
||||
<div className="text-sm text-muted-foreground">데이터베이스 간 데이터 동기화</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{opt.title}</p>
|
||||
<p className="text-[11px] text-muted-foreground">{opt.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* REST API → DB */}
|
||||
<button
|
||||
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
|
||||
onClick={() => handleBatchTypeSelect('restapi-to-db')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">🌐</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<Database className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<div className="text-lg font-medium">REST API → DB</div>
|
||||
<div className="text-sm text-muted-foreground">REST API에서 데이터베이스로 데이터 수집</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsBatchTypeModalOpen(false)}
|
||||
className="h-10 text-sm font-medium"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setIsBatchTypeModalOpen(false)} className="mt-4 w-full rounded-md border py-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import React, { useState, useEffect, useMemo, memo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -62,6 +63,7 @@ interface DbToRestApiMappingCardProps {
|
|||
|
||||
export default function BatchManagementNewPage() {
|
||||
const router = useRouter();
|
||||
const { openTab } = useTabStore();
|
||||
|
||||
// 기본 상태
|
||||
const [batchName, setBatchName] = useState("");
|
||||
|
|
@ -463,7 +465,7 @@ export default function BatchManagementNewPage() {
|
|||
if (result.success) {
|
||||
toast.success(result.message || "REST API 배치 설정이 저장되었습니다.");
|
||||
setTimeout(() => {
|
||||
router.push("/admin/batchmng");
|
||||
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
}, 1000);
|
||||
} else {
|
||||
toast.error(result.message || "배치 저장에 실패했습니다.");
|
||||
|
|
@ -554,7 +556,7 @@ export default function BatchManagementNewPage() {
|
|||
if (result.success) {
|
||||
toast.success(result.message || "DB → REST API 배치 설정이 저장되었습니다.");
|
||||
setTimeout(() => {
|
||||
router.push("/admin/batchmng");
|
||||
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
}, 1000);
|
||||
} else {
|
||||
toast.error(result.message || "배치 저장에 실패했습니다.");
|
||||
|
|
@ -571,79 +573,68 @@ export default function BatchManagementNewPage() {
|
|||
toast.error("지원하지 않는 배치 타입입니다.");
|
||||
};
|
||||
|
||||
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="border-b pb-4">
|
||||
<h1 className="text-3xl font-bold">고급 배치 생성</h1>
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-4 sm:p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={goBack} className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold sm:text-xl">고급 배치 생성</h1>
|
||||
<p className="text-xs text-muted-foreground">REST API / DB 간 데이터 동기화 배치를 설정합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 배치 타입 선택 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{batchTypeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setBatchType(option.value)}
|
||||
className={`group relative flex items-center gap-3 rounded-lg border p-4 text-left transition-all ${
|
||||
batchType === option.value
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/30"
|
||||
: "border-border hover:border-muted-foreground/30 hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${batchType === option.value ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
|
||||
{option.value === "restapi-to-db" ? <Globe className="h-5 w-5" /> : <Database className="h-5 w-5" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{option.label}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{option.description}</div>
|
||||
</div>
|
||||
{batchType === option.value && <div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 배치 타입 선택 */}
|
||||
<div>
|
||||
<Label>배치 타입 *</Label>
|
||||
<div className="mt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{batchTypeOptions.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
batchType === option.value ? "border-primary bg-primary/10" : "border-border hover:border-input"
|
||||
}`}
|
||||
onClick={() => setBatchType(option.value)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{option.value === "restapi-to-db" ? (
|
||||
<Globe className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Database className="h-4 w-4 text-emerald-600" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium">{option.label}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{option.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-4 rounded-lg border p-4 sm:p-5">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
기본 정보
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="batchName" className="text-xs">배치명 <span className="text-destructive">*</span></Label>
|
||||
<Input id="batchName" value={batchName} onChange={e => setBatchName(e.target.value)} placeholder="배치명을 입력하세요" className="h-9 text-sm" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="batchName">배치명 *</Label>
|
||||
<Input
|
||||
id="batchName"
|
||||
value={batchName}
|
||||
onChange={(e) => setBatchName(e.target.value)}
|
||||
placeholder="배치명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="cronSchedule">실행 스케줄 *</Label>
|
||||
<Input
|
||||
id="cronSchedule"
|
||||
value={cronSchedule}
|
||||
onChange={(e) => setCronSchedule(e.target.value)}
|
||||
placeholder="0 12 * * *"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cronSchedule" className="text-xs">실행 스케줄 <span className="text-destructive">*</span></Label>
|
||||
<Input id="cronSchedule" value={cronSchedule} onChange={e => setCronSchedule(e.target.value)} placeholder="0 12 * * *" className="h-9 font-mono text-sm" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="배치에 대한 설명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description" className="text-xs">설명</Label>
|
||||
<Textarea id="description" value={description} onChange={e => setDescription(e.target.value)} placeholder="배치에 대한 설명을 입력하세요" rows={2} className="resize-none text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FROM/TO 설정 - 가로 배치 */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
|
|
@ -1426,13 +1417,14 @@ export default function BatchManagementNewPage() {
|
|||
)}
|
||||
|
||||
{/* 하단 액션 버튼 */}
|
||||
<div className="flex items-center justify-end gap-2 border-t pt-6">
|
||||
<Button onClick={loadConnections} variant="outline" className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<div className="flex items-center justify-end gap-2 border-t pt-4">
|
||||
<Button onClick={goBack} variant="outline" size="sm" className="h-8 gap-1 text-xs">취소</Button>
|
||||
<Button onClick={loadConnections} variant="outline" size="sm" className="h-8 gap-1 text-xs">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
<Button onClick={handleSave} size="sm" className="h-8 gap-1 text-xs">
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,76 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import DataFlowList from "@/components/dataflow/DataFlowList";
|
||||
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
type Step = "list" | "editor";
|
||||
|
||||
export default function DataFlowPage() {
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useState<Step>("list");
|
||||
const [loadingFlowId, setLoadingFlowId] = useState<number | null>(null);
|
||||
|
||||
// 플로우 불러오기 핸들러
|
||||
const handleLoadFlow = async (flowId: number | null) => {
|
||||
if (flowId === null) {
|
||||
// 새 플로우 생성
|
||||
setLoadingFlowId(null);
|
||||
setCurrentStep("editor");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 기존 플로우 불러오기
|
||||
setLoadingFlowId(flowId);
|
||||
setCurrentStep("editor");
|
||||
|
||||
toast.success("플로우를 불러왔습니다.");
|
||||
toast.success("플로우를 불러왔어요");
|
||||
} catch (error: any) {
|
||||
console.error("❌ 플로우 불러오기 실패:", error);
|
||||
showErrorToast("플로우 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
|
||||
console.error("플로우 불러오기 실패:", error);
|
||||
showErrorToast("플로우를 불러오는 데 실패했어요", error, {
|
||||
guidance: "네트워크 연결을 확인해 주세요.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 목록으로 돌아가기
|
||||
const handleBackToList = () => {
|
||||
setCurrentStep("list");
|
||||
setLoadingFlowId(null);
|
||||
};
|
||||
|
||||
// 에디터 모드일 때는 전체 화면 사용
|
||||
const isEditorMode = currentStep === "editor";
|
||||
|
||||
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
|
||||
if (isEditorMode) {
|
||||
if (currentStep === "editor") {
|
||||
return (
|
||||
<div className="bg-background fixed inset-0 z-50">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 에디터 헤더 */}
|
||||
<div className="bg-background flex items-center gap-4 border-b p-4">
|
||||
<Button variant="outline" size="sm" onClick={handleBackToList} className="flex items-center gap-2">
|
||||
<div className="bg-background flex items-center gap-4 border-b px-5 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleBackToList}
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">노드 플로우 에디터</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 플로우 에디터 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<FlowEditor key={loadingFlowId || "new"} initialFlowId={loadingFlowId} />
|
||||
<FlowEditor
|
||||
key={loadingFlowId || "new"}
|
||||
initialFlowId={loadingFlowId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -78,20 +68,10 @@ export default function DataFlowPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen flex-col">
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">제어 관리</h1>
|
||||
<p className="text-muted-foreground text-sm">노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 플로우 목록 */}
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-[1400px] space-y-6 p-4 sm:p-6 pb-20">
|
||||
<DataFlowList onLoadFlow={handleLoadFlow} />
|
||||
</div>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,8 +34,7 @@ import { commonCodeApi } from "@/lib/api/commonCode";
|
|||
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||
import { ddlApi } from "@/lib/api/ddl";
|
||||
import { getSecondLevelMenus, createColumnMapping, deleteColumnMappingsByColumn } from "@/lib/api/tableCategoryValue";
|
||||
import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
||||
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
||||
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
||||
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
|
||||
|
|
@ -102,10 +101,7 @@ export default function TableManagementPage() {
|
|||
// 🆕 Category 타입용: 2레벨 메뉴 목록
|
||||
const [secondLevelMenus, setSecondLevelMenus] = useState<SecondLevelMenu[]>([]);
|
||||
|
||||
// 🆕 Numbering 타입용: 채번규칙 목록
|
||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [numberingRulesLoading, setNumberingRulesLoading] = useState(false);
|
||||
const [numberingComboboxOpen, setNumberingComboboxOpen] = useState<Record<string, boolean>>({});
|
||||
// 채번 타입은 옵션설정 > 채번설정에서 관리 (별도 선택 불필요)
|
||||
|
||||
// 로그 뷰어 상태
|
||||
const [logViewerOpen, setLogViewerOpen] = useState(false);
|
||||
|
|
@ -281,24 +277,6 @@ export default function TableManagementPage() {
|
|||
};
|
||||
|
||||
// 🆕 채번규칙 목록 로드
|
||||
const loadNumberingRules = async () => {
|
||||
setNumberingRulesLoading(true);
|
||||
try {
|
||||
const response = await getNumberingRules();
|
||||
if (response.success && response.data) {
|
||||
setNumberingRules(response.data);
|
||||
} else {
|
||||
console.warn("⚠️ 채번규칙 로드 실패:", response);
|
||||
setNumberingRules([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 채번규칙 로드 에러:", error);
|
||||
setNumberingRules([]);
|
||||
} finally {
|
||||
setNumberingRulesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 목록 로드
|
||||
const loadTables = async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -344,9 +322,7 @@ export default function TableManagementPage() {
|
|||
|
||||
// 컬럼 데이터에 기본값 설정
|
||||
const processedColumns = (data.columns || data).map((col: any) => {
|
||||
// detailSettings에서 hierarchyRole, numberingRuleId 추출
|
||||
let hierarchyRole: "large" | "medium" | "small" | undefined = undefined;
|
||||
let numberingRuleId: string | undefined = undefined;
|
||||
if (col.detailSettings && typeof col.detailSettings === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(col.detailSettings);
|
||||
|
|
@ -357,9 +333,6 @@ export default function TableManagementPage() {
|
|||
) {
|
||||
hierarchyRole = parsed.hierarchyRole;
|
||||
}
|
||||
if (parsed.numberingRuleId) {
|
||||
numberingRuleId = parsed.numberingRuleId;
|
||||
}
|
||||
} catch {
|
||||
// JSON 파싱 실패 시 무시
|
||||
}
|
||||
|
|
@ -369,7 +342,6 @@ export default function TableManagementPage() {
|
|||
...col,
|
||||
inputType: col.inputType || "text",
|
||||
isUnique: col.isUnique || "NO",
|
||||
numberingRuleId,
|
||||
categoryMenus: col.categoryMenus || [],
|
||||
hierarchyRole,
|
||||
categoryRef: col.categoryRef || null,
|
||||
|
|
@ -1000,7 +972,6 @@ export default function TableManagementPage() {
|
|||
loadTables();
|
||||
loadCommonCodeCategories();
|
||||
loadSecondLevelMenus();
|
||||
loadNumberingRules();
|
||||
}, []);
|
||||
|
||||
// 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드
|
||||
|
|
@ -1619,7 +1590,7 @@ export default function TableManagementPage() {
|
|||
tables={tables}
|
||||
referenceTableColumns={referenceTableColumns}
|
||||
secondLevelMenus={secondLevelMenus}
|
||||
numberingRules={numberingRules}
|
||||
numberingRules={[]}
|
||||
onColumnChange={(field, value) => {
|
||||
if (!selectedColumn) return;
|
||||
if (field === "inputType") {
|
||||
|
|
|
|||
|
|
@ -22,14 +22,13 @@ import { cn } from "@/lib/utils";
|
|||
import type { ColumnTypeInfo, TableInfo, SecondLevelMenu } from "./types";
|
||||
import { INPUT_TYPE_COLORS } from "./types";
|
||||
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||
import type { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
|
||||
export interface ColumnDetailPanelProps {
|
||||
column: ColumnTypeInfo | null;
|
||||
tables: TableInfo[];
|
||||
referenceTableColumns: Record<string, ReferenceTableColumn[]>;
|
||||
secondLevelMenus: SecondLevelMenu[];
|
||||
numberingRules: NumberingRuleConfig[];
|
||||
numberingRules: any[];
|
||||
onColumnChange: (field: keyof ColumnTypeInfo, value: unknown) => void;
|
||||
onClose: () => void;
|
||||
onLoadReferenceColumns?: (tableName: string) => void;
|
||||
|
|
@ -53,7 +52,6 @@ export function ColumnDetailPanel({
|
|||
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
||||
const [entityTableOpen, setEntityTableOpen] = React.useState(false);
|
||||
const [entityColumnOpen, setEntityColumnOpen] = React.useState(false);
|
||||
const [numberingOpen, setNumberingOpen] = React.useState(false);
|
||||
|
||||
const typeConf = column ? INPUT_TYPE_COLORS[column.inputType || "text"] : null;
|
||||
const refColumns = column?.referenceTable
|
||||
|
|
@ -404,53 +402,10 @@ export function ColumnDetailPanel({
|
|||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<Label className="text-sm font-medium">채번 규칙</Label>
|
||||
</div>
|
||||
<Popover open={numberingOpen} onOpenChange={setNumberingOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="h-9 w-full justify-between text-xs">
|
||||
{column.numberingRuleId
|
||||
? numberingRules.find((r) => r.ruleId === column.numberingRuleId)?.ruleName ?? column.numberingRuleId
|
||||
: "규칙 선택..."}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="규칙 검색..." className="h-8 text-xs" />
|
||||
<CommandList className="max-h-[200px]">
|
||||
<CommandEmpty className="py-2 text-center text-xs">규칙을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="none"
|
||||
onSelect={() => {
|
||||
onColumnChange("numberingRuleId", undefined);
|
||||
setNumberingOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", !column.numberingRuleId ? "opacity-100" : "opacity-0")} />
|
||||
선택 안함
|
||||
</CommandItem>
|
||||
{numberingRules.map((r) => (
|
||||
<CommandItem
|
||||
key={r.ruleId}
|
||||
value={`${r.ruleName} ${r.ruleId}`}
|
||||
onSelect={() => {
|
||||
onColumnChange("numberingRuleId", r.ruleId);
|
||||
setNumberingOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-3 w-3", column.numberingRuleId === r.ruleId ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
{r.ruleName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="rounded-md border border-border bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
채번 규칙은 옵션설정 > 채번설정에서 관리합니다.
|
||||
타입을 저장하면 자동으로 채번 목록에 표시됩니다.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -17,12 +11,39 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { MoreHorizontal, Trash2, Copy, Plus, Search, Network, Calendar } from "lucide-react";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Network,
|
||||
RefreshCw,
|
||||
Pencil,
|
||||
Copy,
|
||||
Trash2,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
||||
import { getNodePaletteItem } from "@/components/dataflow/node-editor/sidebar/nodePaletteConfig";
|
||||
|
||||
interface TopologyNode {
|
||||
id: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface FlowSummary {
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
nodeTypes: Record<string, number>;
|
||||
topology: {
|
||||
nodes: TopologyNode[];
|
||||
edges: [string, string][];
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface NodeFlow {
|
||||
flowId: number;
|
||||
|
|
@ -30,18 +51,205 @@ interface NodeFlow {
|
|||
flowDescription: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
summary: FlowSummary;
|
||||
}
|
||||
|
||||
interface DataFlowListProps {
|
||||
onLoadFlow: (flowId: number | null) => void;
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, { text: string; bg: string; border: string }> = {
|
||||
source: { text: "text-teal-400", bg: "bg-teal-500/10", border: "border-teal-500/20" },
|
||||
transform: { text: "text-violet-400", bg: "bg-violet-500/10", border: "border-violet-500/20" },
|
||||
action: { text: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
|
||||
external: { text: "text-pink-400", bg: "bg-pink-500/10", border: "border-pink-500/20" },
|
||||
utility: { text: "text-zinc-400", bg: "bg-zinc-500/10", border: "border-zinc-500/20" },
|
||||
};
|
||||
|
||||
function getNodeCategoryColor(nodeType: string) {
|
||||
const item = getNodePaletteItem(nodeType);
|
||||
const cat = item?.category || "utility";
|
||||
return CATEGORY_COLORS[cat] || CATEGORY_COLORS.utility;
|
||||
}
|
||||
|
||||
function getNodeLabel(nodeType: string) {
|
||||
const item = getNodePaletteItem(nodeType);
|
||||
return item?.label || nodeType;
|
||||
}
|
||||
|
||||
function getNodeColor(nodeType: string): string {
|
||||
const item = getNodePaletteItem(nodeType);
|
||||
return item?.color || "#6B7280";
|
||||
}
|
||||
|
||||
function relativeTime(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const d = new Date(dateStr).getTime();
|
||||
const diff = now - d;
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return "방금 전";
|
||||
if (min < 60) return `${min}분 전`;
|
||||
const h = Math.floor(min / 60);
|
||||
if (h < 24) return `${h}시간 전`;
|
||||
const day = Math.floor(h / 24);
|
||||
if (day < 30) return `${day}일 전`;
|
||||
const month = Math.floor(day / 30);
|
||||
return `${month}개월 전`;
|
||||
}
|
||||
|
||||
function MiniTopology({ topology }: { topology: FlowSummary["topology"] }) {
|
||||
if (!topology || topology.nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<span className="font-mono text-[10px] text-zinc-600">빈 캔버스</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const W = 340;
|
||||
const H = 88;
|
||||
const padX = 40;
|
||||
const padY = 18;
|
||||
|
||||
const nodeMap = new Map(topology.nodes.map((n) => [n.id, n]));
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} fill="none" className="h-full w-full">
|
||||
{topology.edges.map(([src, tgt], i) => {
|
||||
const s = nodeMap.get(src);
|
||||
const t = nodeMap.get(tgt);
|
||||
if (!s || !t) return null;
|
||||
const sx = padX + s.x * (W - padX * 2);
|
||||
const sy = padY + s.y * (H - padY * 2);
|
||||
const tx = padX + t.x * (W - padX * 2);
|
||||
const ty = padY + t.y * (H - padY * 2);
|
||||
const mx = (sx + tx) / 2;
|
||||
const my = (sy + ty) / 2 - 8;
|
||||
return (
|
||||
<path
|
||||
key={`e-${i}`}
|
||||
d={`M${sx} ${sy}Q${mx} ${my} ${tx} ${ty}`}
|
||||
stroke="rgba(108,92,231,0.25)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{topology.nodes.map((n) => {
|
||||
const cx = padX + n.x * (W - padX * 2);
|
||||
const cy = padY + n.y * (H - padY * 2);
|
||||
const color = getNodeColor(n.type);
|
||||
return (
|
||||
<g key={n.id}>
|
||||
<circle cx={cx} cy={cy} r="5" fill={`${color}20`} stroke={color} strokeWidth="1.5" />
|
||||
<circle cx={cx} cy={cy} r="2" fill={color} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowCard({
|
||||
flow,
|
||||
onOpen,
|
||||
onCopy,
|
||||
onDelete,
|
||||
}: {
|
||||
flow: NodeFlow;
|
||||
onOpen: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const chips = useMemo(() => {
|
||||
const entries = Object.entries(flow.summary?.nodeTypes || {});
|
||||
return entries.slice(0, 4).map(([type, count]) => {
|
||||
const colors = getNodeCategoryColor(type);
|
||||
const label = getNodeLabel(type);
|
||||
return { type, count, label, colors };
|
||||
});
|
||||
}, [flow.summary?.nodeTypes]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative cursor-pointer overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/80 transition-all duration-200 hover:-translate-y-0.5 hover:border-violet-500/50 hover:shadow-lg hover:shadow-violet-500/5"
|
||||
onClick={onOpen}
|
||||
>
|
||||
{/* 미니 토폴로지 */}
|
||||
<div className="relative h-[88px] overflow-hidden border-b border-zinc-800/60 bg-gradient-to-b from-violet-500/[0.03] to-transparent">
|
||||
<MiniTopology topology={flow.summary?.topology} />
|
||||
</div>
|
||||
|
||||
{/* 카드 바디 */}
|
||||
<div className="px-4 pb-3 pt-3.5">
|
||||
<h3 className="mb-1 truncate text-sm font-semibold tracking-tight text-zinc-100">
|
||||
{flow.flowName}
|
||||
</h3>
|
||||
<p className="mb-3 line-clamp-2 min-h-[2.5rem] text-[11px] leading-relaxed text-zinc-500">
|
||||
{flow.flowDescription || "설명이 아직 없어요"}
|
||||
</p>
|
||||
|
||||
{/* 노드 타입 칩 */}
|
||||
{chips.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||
{chips.map(({ type, count, label, colors }) => (
|
||||
<span
|
||||
key={type}
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${colors.text} ${colors.bg} ${colors.border}`}
|
||||
>
|
||||
{label} {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 카드 푸터 */}
|
||||
<div className="flex items-center justify-between border-t border-zinc-800/40 px-4 py-2.5">
|
||||
<span className="font-mono text-[11px] text-zinc-600">
|
||||
수정 {relativeTime(flow.updatedAt)}
|
||||
</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-violet-500/10 hover:text-violet-400"
|
||||
title="편집"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-violet-500/10 hover:text-violet-400"
|
||||
title="복사"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-pink-500/10 hover:text-pink-400"
|
||||
title="삭제"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
||||
const { user } = useAuth();
|
||||
const [flows, setFlows] = useState<NodeFlow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedFlow, setSelectedFlow] = useState<NodeFlow | null>(null);
|
||||
|
||||
|
|
@ -49,7 +257,6 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
|||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.get("/dataflow/node-flows");
|
||||
|
||||
if (response.data.success) {
|
||||
setFlows(response.data.data);
|
||||
} else {
|
||||
|
|
@ -57,7 +264,9 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("플로우 목록 조회 실패", error);
|
||||
showErrorToast("플로우 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
|
||||
showErrorToast("플로우 목록을 불러오는 데 실패했어요", error, {
|
||||
guidance: "네트워크 연결을 확인해 주세요.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -75,30 +284,26 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
|||
const handleCopy = async (flow: NodeFlow) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await apiClient.get(`/dataflow/node-flows/${flow.flowId}`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "플로우 조회 실패");
|
||||
}
|
||||
|
||||
const originalFlow = response.data.data;
|
||||
if (!response.data.success) throw new Error(response.data.message || "플로우 조회 실패");
|
||||
|
||||
const copyResponse = await apiClient.post("/dataflow/node-flows", {
|
||||
flowName: `${flow.flowName} (복사본)`,
|
||||
flowDescription: flow.flowDescription,
|
||||
flowData: originalFlow.flowData,
|
||||
flowData: response.data.data.flowData,
|
||||
});
|
||||
|
||||
if (copyResponse.data.success) {
|
||||
toast.success(`플로우가 성공적으로 복사되었습니다`);
|
||||
toast.success("플로우를 복사했어요");
|
||||
await loadFlows();
|
||||
} else {
|
||||
throw new Error(copyResponse.data.message || "플로우 복사 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("플로우 복사 실패:", error);
|
||||
showErrorToast("플로우 복사에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
||||
showErrorToast("플로우 복사에 실패했어요", error, {
|
||||
guidance: "잠시 후 다시 시도해 주세요.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -106,20 +311,20 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
|||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!selectedFlow) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.delete(`/dataflow/node-flows/${selectedFlow.flowId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
toast.success(`플로우가 삭제되었습니다: ${selectedFlow.flowName}`);
|
||||
toast.success(`"${selectedFlow.flowName}" 플로우를 삭제했어요`);
|
||||
await loadFlows();
|
||||
} else {
|
||||
throw new Error(response.data.message || "플로우 삭제 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("플로우 삭제 실패:", error);
|
||||
showErrorToast("플로우 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
||||
showErrorToast("플로우 삭제에 실패했어요", error, {
|
||||
guidance: "잠시 후 다시 시도해 주세요.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowDeleteModal(false);
|
||||
|
|
@ -127,170 +332,241 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const filteredFlows = flows.filter(
|
||||
(flow) =>
|
||||
flow.flowName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
flow.flowDescription.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
const filteredFlows = useMemo(
|
||||
() =>
|
||||
flows.filter(
|
||||
(f) =>
|
||||
f.flowName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(f.flowDescription || "").toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
),
|
||||
[flows, searchTerm],
|
||||
);
|
||||
|
||||
// DropdownMenu 렌더러 (테이블 + 카드 공통)
|
||||
const renderDropdownMenu = (flow: NodeFlow) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onLoadFlow(flow.flowId)}>
|
||||
<Network className="mr-2 h-4 w-4" />
|
||||
불러오기
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(flow)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(flow)} className="text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
const stats = useMemo(() => {
|
||||
let totalNodes = 0;
|
||||
let totalEdges = 0;
|
||||
flows.forEach((f) => {
|
||||
totalNodes += f.summary?.nodeCount || 0;
|
||||
totalEdges += f.summary?.edgeCount || 0;
|
||||
});
|
||||
return { total: flows.length, totalNodes, totalEdges };
|
||||
}, [flows]);
|
||||
|
||||
const columns: RDVColumn<NodeFlow>[] = [
|
||||
{
|
||||
key: "flowName",
|
||||
label: "플로우명",
|
||||
render: (_val, flow) => (
|
||||
<div className="flex items-center font-medium">
|
||||
<Network className="mr-2 h-4 w-4 text-primary" />
|
||||
{flow.flowName}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "flowDescription",
|
||||
label: "설명",
|
||||
render: (_val, flow) => (
|
||||
<span className="text-muted-foreground">{flow.flowDescription || "설명 없음"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "생성일",
|
||||
render: (_val, flow) => (
|
||||
<span className="flex items-center text-muted-foreground">
|
||||
<Calendar className="mr-1 h-3 w-3" />
|
||||
{new Date(flow.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: "최근 수정",
|
||||
hideOnMobile: true,
|
||||
render: (_val, flow) => (
|
||||
<span className="flex items-center text-muted-foreground">
|
||||
<Calendar className="mr-1 h-3 w-3" />
|
||||
{new Date(flow.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const cardFields: RDVCardField<NodeFlow>[] = [
|
||||
{
|
||||
label: "생성일",
|
||||
render: (flow) => new Date(flow.createdAt).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
label: "최근 수정",
|
||||
render: (flow) => new Date(flow.updatedAt).toLocaleDateString(),
|
||||
},
|
||||
];
|
||||
if (loading && flows.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 검색 및 액션 영역 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="w-full sm:w-[400px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="플로우명, 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-r from-zinc-100 to-violet-300 bg-clip-text text-2xl font-bold tracking-tight text-transparent sm:text-3xl">
|
||||
제어 관리
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-zinc-500 sm:text-sm">
|
||||
노드 기반 데이터 플로우를 시각적으로 설계하고 관리해요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
총 <span className="font-semibold text-foreground">{filteredFlows.length}</span> 건
|
||||
</div>
|
||||
<Button onClick={() => onLoadFlow(null)} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
새 플로우 생성
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadFlows}
|
||||
disabled={loading}
|
||||
className="gap-1.5 border-zinc-700 bg-zinc-900 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onLoadFlow(null)}
|
||||
className="gap-1.5 bg-violet-600 font-semibold text-white shadow-lg shadow-violet-600/25 hover:bg-violet-500"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
새 플로우
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 빈 상태: 커스텀 Empty UI */}
|
||||
{!loading && filteredFlows.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Network className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">플로우가 없습니다</h3>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
새 플로우를 생성하여 노드 기반 데이터 제어를 설계해보세요.
|
||||
</p>
|
||||
<Button onClick={() => onLoadFlow(null)} className="mt-4 h-10 gap-2 text-sm font-medium">
|
||||
{/* 통계 스트립 */}
|
||||
<div className="flex flex-wrap items-center gap-5 border-b border-zinc-800/60 pb-4">
|
||||
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-violet-500" />
|
||||
전체{" "}
|
||||
<strong className="font-mono font-bold text-zinc-200">{stats.total}</strong>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-zinc-600" />
|
||||
총 노드{" "}
|
||||
<strong className="font-mono font-bold text-zinc-300">{stats.totalNodes}</strong>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-zinc-600" />
|
||||
총 연결{" "}
|
||||
<strong className="font-mono font-bold text-zinc-300">{stats.totalEdges}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 툴바 */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative min-w-[200px] flex-1 sm:max-w-[360px]">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-600" />
|
||||
<Input
|
||||
placeholder="플로우 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 border-zinc-700 bg-zinc-900 pl-10 text-sm text-zinc-200 placeholder:text-zinc-600 focus-visible:ring-violet-500/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-0.5 rounded-lg border border-zinc-700 bg-zinc-900 p-0.5">
|
||||
<button
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
viewMode === "grid"
|
||||
? "bg-violet-500/10 text-violet-400"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
onClick={() => setViewMode("grid")}
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
그리드
|
||||
</button>
|
||||
<button
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
viewMode === "list"
|
||||
? "bg-violet-500/10 text-violet-400"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
onClick={() => setViewMode("list")}
|
||||
>
|
||||
<List className="h-3.5 w-3.5" />
|
||||
리스트
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
{filteredFlows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-zinc-700 px-6 py-20 text-center">
|
||||
<div className="mb-5 flex h-20 w-20 items-center justify-center rounded-2xl border border-violet-500/15 bg-violet-500/[0.08]">
|
||||
<Network className="h-9 w-9 text-violet-400" />
|
||||
</div>
|
||||
<h2 className="mb-2 text-lg font-bold text-zinc-200">
|
||||
{searchTerm ? "검색 결과가 없어요" : "아직 플로우가 없어요"}
|
||||
</h2>
|
||||
<p className="mb-6 max-w-sm text-sm leading-relaxed text-zinc-500">
|
||||
{searchTerm
|
||||
? `"${searchTerm}"에 해당하는 플로우를 찾지 못했어요. 다른 키워드로 검색해 보세요.`
|
||||
: "노드를 연결해서 데이터 처리 파이프라인을 만들어 보세요. 코드 없이 드래그 앤 드롭만으로 설계할 수 있어요."}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<Button
|
||||
onClick={() => onLoadFlow(null)}
|
||||
className="gap-2 bg-violet-600 px-5 font-semibold text-white shadow-lg shadow-violet-600/25 hover:bg-violet-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
새 플로우 생성
|
||||
첫 번째 플로우 만들기
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredFlows.map((flow) => (
|
||||
<FlowCard
|
||||
key={flow.flowId}
|
||||
flow={flow}
|
||||
onOpen={() => onLoadFlow(flow.flowId)}
|
||||
onCopy={() => handleCopy(flow)}
|
||||
onDelete={() => handleDelete(flow)}
|
||||
/>
|
||||
))}
|
||||
{/* 새 플로우 만들기 카드 */}
|
||||
<div
|
||||
className="group flex min-h-[260px] cursor-pointer flex-col items-center justify-center rounded-xl border border-dashed border-zinc-700 transition-all duration-200 hover:border-violet-500/50 hover:bg-violet-500/[0.04]"
|
||||
onClick={() => onLoadFlow(null)}
|
||||
>
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-violet-500/[0.08]">
|
||||
<Plus className="h-6 w-6 text-violet-400" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-zinc-400 group-hover:text-zinc-200">
|
||||
새 플로우 만들기
|
||||
</span>
|
||||
<span className="mt-1 text-[11px] text-zinc-600">빈 캔버스에서 시작해요</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveDataView<NodeFlow>
|
||||
data={filteredFlows}
|
||||
columns={columns}
|
||||
keyExtractor={(flow) => String(flow.flowId)}
|
||||
isLoading={loading}
|
||||
skeletonCount={5}
|
||||
cardTitle={(flow) => (
|
||||
<span className="flex items-center">
|
||||
<Network className="mr-2 h-4 w-4 text-primary" />
|
||||
{flow.flowName}
|
||||
</span>
|
||||
)}
|
||||
cardSubtitle={(flow) => flow.flowDescription || "설명 없음"}
|
||||
cardHeaderRight={renderDropdownMenu}
|
||||
cardFields={cardFields}
|
||||
actionsLabel="작업"
|
||||
actionsWidth="80px"
|
||||
renderActions={renderDropdownMenu}
|
||||
onRowClick={(flow) => onLoadFlow(flow.flowId)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{filteredFlows.map((flow) => (
|
||||
<div
|
||||
key={flow.flowId}
|
||||
className="group flex cursor-pointer items-center gap-4 rounded-lg border border-zinc-800 bg-zinc-900/80 px-4 py-3 transition-all hover:border-violet-500/40 hover:bg-zinc-900"
|
||||
onClick={() => onLoadFlow(flow.flowId)}
|
||||
>
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-violet-500/10">
|
||||
<Network className="h-5 w-5 text-violet-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-sm font-semibold text-zinc-100">
|
||||
{flow.flowName}
|
||||
</h3>
|
||||
<p className="truncate text-xs text-zinc-500">
|
||||
{flow.flowDescription || "설명이 아직 없어요"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden items-center gap-1.5 lg:flex">
|
||||
{Object.entries(flow.summary?.nodeTypes || {})
|
||||
.slice(0, 3)
|
||||
.map(([type, count]) => {
|
||||
const colors = getNodeCategoryColor(type);
|
||||
return (
|
||||
<span
|
||||
key={type}
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${colors.text} ${colors.bg} ${colors.border}`}
|
||||
>
|
||||
{getNodeLabel(type)} {count}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="hidden font-mono text-[11px] text-zinc-600 sm:block">
|
||||
{relativeTime(flow.updatedAt)}
|
||||
</span>
|
||||
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-violet-500/10 hover:text-violet-400"
|
||||
title="복사"
|
||||
onClick={() => handleCopy(flow)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-pink-500/10 hover:text-pink-400"
|
||||
title="삭제"
|
||||
onClick={() => handleDelete(flow)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">플로우 삭제</DialogTitle>
|
||||
<DialogTitle className="text-base sm:text-lg">플로우를 삭제할까요?</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
“{selectedFlow?.flowName}” 플로우를 완전히 삭제하시겠습니까?
|
||||
“{selectedFlow?.flowName}” 플로우가 완전히 삭제돼요.
|
||||
<br />
|
||||
<span className="font-medium text-destructive">
|
||||
이 작업은 되돌릴 수 없으며, 모든 플로우 정보가 영구적으로 삭제됩니다.
|
||||
<span className="text-destructive font-medium">
|
||||
이 작업은 되돌릴 수 없으며, 모든 노드와 연결 정보가 함께 삭제돼요.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { NODE_PALETTE, NODE_CATEGORIES } from "./sidebar/nodePaletteConfig";
|
||||
import type { NodePaletteItem } from "@/types/node-editor";
|
||||
|
||||
const TOSS_CATEGORY_LABELS: Record<string, string> = {
|
||||
source: "데이터를 가져와요",
|
||||
transform: "데이터를 가공해요",
|
||||
action: "데이터를 저장해요",
|
||||
external: "외부로 연결해요",
|
||||
utility: "도구",
|
||||
};
|
||||
|
||||
const TOSS_NODE_DESCRIPTIONS: Record<string, string> = {
|
||||
tableSource: "내부 데이터베이스에서 데이터를 읽어와요",
|
||||
externalDBSource: "외부 데이터베이스에 연결해서 데이터를 가져와요",
|
||||
restAPISource: "REST API를 호출해서 데이터를 받아와요",
|
||||
condition: "조건에 따라 데이터 흐름을 나눠요",
|
||||
dataTransform: "데이터를 원하는 형태로 바꿔요",
|
||||
aggregate: "합계, 평균 등 집계 연산을 수행해요",
|
||||
formulaTransform: "수식을 이용해서 새로운 값을 계산해요",
|
||||
insertAction: "데이터를 테이블에 새로 추가해요",
|
||||
updateAction: "기존 데이터를 수정해요",
|
||||
deleteAction: "데이터를 삭제해요",
|
||||
upsertAction: "있으면 수정하고, 없으면 새로 추가해요",
|
||||
emailAction: "이메일을 자동으로 보내요",
|
||||
scriptAction: "외부 스크립트를 실행해요",
|
||||
httpRequestAction: "HTTP 요청을 보내요",
|
||||
procedureCallAction: "DB 프로시저를 호출해요",
|
||||
comment: "메모를 남겨요",
|
||||
};
|
||||
|
||||
interface CommandPaletteProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelectNode: (nodeType: string) => void;
|
||||
}
|
||||
|
||||
export function CommandPalette({ isOpen, onClose, onSelectNode }: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!query.trim()) return NODE_PALETTE;
|
||||
const q = query.toLowerCase();
|
||||
return NODE_PALETTE.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
item.description.toLowerCase().includes(q) ||
|
||||
item.type.toLowerCase().includes(q) ||
|
||||
(TOSS_NODE_DESCRIPTIONS[item.type] || "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
const groupedItems = useMemo(() => {
|
||||
const groups: { category: string; label: string; items: NodePaletteItem[] }[] = [];
|
||||
for (const cat of NODE_CATEGORIES) {
|
||||
const items = filteredItems.filter((i) => i.category === cat.id);
|
||||
if (items.length > 0) {
|
||||
groups.push({
|
||||
category: cat.id,
|
||||
label: TOSS_CATEGORY_LABELS[cat.id] || cat.label,
|
||||
items,
|
||||
});
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}, [filteredItems]);
|
||||
|
||||
const flatItems = useMemo(() => groupedItems.flatMap((g) => g.items), [groupedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setQuery("");
|
||||
setFocusIndex(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
setFocusIndex(0);
|
||||
}, [query]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setFocusIndex((i) => Math.min(i + 1, flatItems.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setFocusIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter" && flatItems[focusIndex]) {
|
||||
onSelectNode(flatItems[focusIndex].type);
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[flatItems, focusIndex, onClose, onSelectNode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const focused = listRef.current?.querySelector('[data-focused="true"]');
|
||||
focused?.scrollIntoView({ block: "nearest" });
|
||||
}, [focusIndex]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]">
|
||||
{/* backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* palette */}
|
||||
<div className="relative w-full max-w-[520px] overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-2xl shadow-black/50">
|
||||
{/* 검색 */}
|
||||
<div className="flex items-center gap-3 border-b border-zinc-800 px-4 py-3">
|
||||
<Search className="h-4 w-4 flex-shrink-0 text-zinc-500" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="어떤 노드를 추가할까요?"
|
||||
className="flex-1 bg-transparent text-sm text-zinc-200 outline-none placeholder:text-zinc-600"
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-zinc-500 transition-colors hover:text-zinc-300"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
<div ref={listRef} className="max-h-[360px] overflow-y-auto p-2">
|
||||
{groupedItems.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-zinc-500">
|
||||
“{query}”에 해당하는 노드를 찾지 못했어요
|
||||
</div>
|
||||
) : (
|
||||
groupedItems.map((group) => {
|
||||
let groupStartIdx = 0;
|
||||
for (const g of groupedItems) {
|
||||
if (g.category === group.category) break;
|
||||
groupStartIdx += g.items.length;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={group.category} className="mb-1">
|
||||
<div className="px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.items.map((item, idx) => {
|
||||
const globalIdx = groupStartIdx + idx;
|
||||
const isFocused = globalIdx === focusIndex;
|
||||
return (
|
||||
<button
|
||||
key={item.type}
|
||||
data-focused={isFocused}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors ${
|
||||
isFocused ? "bg-violet-500/15 text-zinc-100" : "text-zinc-300 hover:bg-zinc-800"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectNode(item.type);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setFocusIndex(globalIdx)}
|
||||
>
|
||||
<div
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{item.label}</div>
|
||||
<div className="truncate text-[11px] text-zinc-500">
|
||||
{TOSS_NODE_DESCRIPTIONS[item.type] || item.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 힌트 */}
|
||||
<div className="flex items-center gap-4 border-t border-zinc-800 px-4 py-2 text-[11px] text-zinc-600">
|
||||
<span>
|
||||
<kbd className="rounded border border-zinc-700 bg-zinc-800 px-1 py-0.5 font-mono text-[10px]">
|
||||
Enter
|
||||
</kbd>{" "}
|
||||
선택
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded border border-zinc-700 bg-zinc-800 px-1 py-0.5 font-mono text-[10px]">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
닫기
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded border border-zinc-700 bg-zinc-800 px-1 py-0.5 font-mono text-[10px]">
|
||||
↑↓
|
||||
</kbd>{" "}
|
||||
이동
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { getNodePaletteItem } from "./sidebar/nodePaletteConfig";
|
||||
|
||||
export function FlowBreadcrumb() {
|
||||
const { flowName, nodes, selectedNodes } = useFlowEditorStore();
|
||||
|
||||
const selectedNode =
|
||||
selectedNodes.length === 1
|
||||
? nodes.find((n) => n.id === selectedNodes[0])
|
||||
: null;
|
||||
|
||||
const nodeInfo = selectedNode
|
||||
? getNodePaletteItem(selectedNode.type as string)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-zinc-500">데이터 경로</span>
|
||||
<ChevronRight className="h-3 w-3 text-zinc-600" />
|
||||
<span className="font-medium text-zinc-300">{flowName || "새 플로우"}</span>
|
||||
{selectedNode && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 text-zinc-600" />
|
||||
<span className="flex items-center gap-1.5">
|
||||
{nodeInfo && (
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: nodeInfo.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-violet-400">
|
||||
{(selectedNode.data as any)?.displayName || nodeInfo?.label || selectedNode.type}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,20 +2,32 @@
|
|||
|
||||
/**
|
||||
* 노드 기반 플로우 에디터 메인 컴포넌트
|
||||
* - 100% 캔버스 + Command Palette (/ 키) + Slide-over 속성 패널
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useEffect, useState, useMemo } from "react";
|
||||
import ReactFlow, { Background, Controls, MiniMap, Panel, ReactFlowProvider, useReactFlow } from "reactflow";
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Panel,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
} from "reactflow";
|
||||
import "reactflow/dist/style.css";
|
||||
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { NodePalette } from "./sidebar/NodePalette";
|
||||
import { LeftV2Toolbar, ToolbarButton } from "@/components/screen/toolbar/LeftV2Toolbar";
|
||||
import { Boxes, Settings } from "lucide-react";
|
||||
import { PropertiesPanel } from "./panels/PropertiesPanel";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { SlideOverSheet } from "./SlideOverSheet";
|
||||
import { FlowBreadcrumb } from "./FlowBreadcrumb";
|
||||
import { NodeContextMenu } from "./NodeContextMenu";
|
||||
import { ValidationNotification } from "./ValidationNotification";
|
||||
import { FlowToolbar } from "./FlowToolbar";
|
||||
import { getNodePaletteItem } from "./sidebar/nodePaletteConfig";
|
||||
import { Pencil, Copy, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { TableSourceNode } from "./nodes/TableSourceNode";
|
||||
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
|
||||
import { ConditionNode } from "./nodes/ConditionNode";
|
||||
|
|
@ -36,70 +48,116 @@ import { ProcedureCallActionNode } from "./nodes/ProcedureCallActionNode";
|
|||
import { validateFlow } from "@/lib/utils/flowValidation";
|
||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||
|
||||
// 노드 타입들
|
||||
const nodeTypes = {
|
||||
// 데이터 소스
|
||||
tableSource: TableSourceNode,
|
||||
externalDBSource: ExternalDBSourceNode,
|
||||
restAPISource: RestAPISourceNode,
|
||||
// 변환/조건
|
||||
condition: ConditionNode,
|
||||
dataTransform: DataTransformNode,
|
||||
aggregate: AggregateNode,
|
||||
formulaTransform: FormulaTransformNode,
|
||||
// 데이터 액션
|
||||
insertAction: InsertActionNode,
|
||||
updateAction: UpdateActionNode,
|
||||
deleteAction: DeleteActionNode,
|
||||
upsertAction: UpsertActionNode,
|
||||
// 외부 연동 액션
|
||||
emailAction: EmailActionNode,
|
||||
scriptAction: ScriptActionNode,
|
||||
httpRequestAction: HttpRequestActionNode,
|
||||
procedureCallAction: ProcedureCallActionNode,
|
||||
// 유틸리티
|
||||
comment: CommentNode,
|
||||
log: LogNode,
|
||||
};
|
||||
|
||||
/**
|
||||
* FlowEditor 내부 컴포넌트
|
||||
*/
|
||||
interface FlowEditorInnerProps {
|
||||
initialFlowId?: number | null;
|
||||
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
|
||||
onSaveComplete?: (flowId: number, flowName: string) => void;
|
||||
/** 임베디드 모드 여부 */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
// 플로우 에디터 툴바 버튼 설정
|
||||
const flowToolbarButtons: ToolbarButton[] = [
|
||||
{
|
||||
id: "nodes",
|
||||
label: "노드",
|
||||
icon: <Boxes className="h-5 w-5" />,
|
||||
shortcut: "N",
|
||||
group: "source",
|
||||
panelWidth: 300,
|
||||
},
|
||||
{
|
||||
id: "properties",
|
||||
label: "속성",
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
shortcut: "P",
|
||||
group: "editor",
|
||||
panelWidth: 350,
|
||||
},
|
||||
];
|
||||
function getDefaultNodeData(type: string): Record<string, any> {
|
||||
const paletteItem = getNodePaletteItem(type);
|
||||
const base: Record<string, any> = {
|
||||
displayName: paletteItem?.label || `새 ${type} 노드`,
|
||||
};
|
||||
|
||||
function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorInnerProps) {
|
||||
if (type === "restAPISource") {
|
||||
Object.assign(base, {
|
||||
method: "GET",
|
||||
url: "",
|
||||
headers: {},
|
||||
timeout: 30000,
|
||||
responseFields: [],
|
||||
responseMapping: "",
|
||||
});
|
||||
}
|
||||
|
||||
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
|
||||
Object.assign(base, {
|
||||
targetType: "internal",
|
||||
fieldMappings: [],
|
||||
options: {},
|
||||
});
|
||||
if (type === "updateAction" || type === "deleteAction") {
|
||||
base.whereConditions = [];
|
||||
}
|
||||
if (type === "upsertAction") {
|
||||
base.conflictKeys = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "emailAction") {
|
||||
Object.assign(base, {
|
||||
displayName: "메일 발송",
|
||||
smtpConfig: { host: "", port: 587, secure: false },
|
||||
from: "",
|
||||
to: "",
|
||||
subject: "",
|
||||
body: "",
|
||||
bodyType: "text",
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "scriptAction") {
|
||||
Object.assign(base, {
|
||||
displayName: "스크립트 실행",
|
||||
scriptType: "python",
|
||||
executionMode: "inline",
|
||||
inlineScript: "",
|
||||
inputMethod: "stdin",
|
||||
inputFormat: "json",
|
||||
outputHandling: { captureStdout: true, captureStderr: true, parseOutput: "text" },
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "httpRequestAction") {
|
||||
Object.assign(base, {
|
||||
displayName: "HTTP 요청",
|
||||
url: "",
|
||||
method: "GET",
|
||||
bodyType: "none",
|
||||
authentication: { type: "none" },
|
||||
options: { timeout: 30000, followRedirects: true },
|
||||
});
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
function FlowEditorInner({
|
||||
initialFlowId,
|
||||
onSaveComplete,
|
||||
embedded = false,
|
||||
}: FlowEditorInnerProps) {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition, setCenter } = useReactFlow();
|
||||
const { screenToFlowPosition, setCenter, getViewport } = useReactFlow();
|
||||
|
||||
// 패널 표시 상태
|
||||
const [showNodesPanel, setShowNodesPanel] = useState(true);
|
||||
const [showPropertiesPanelLocal, setShowPropertiesPanelLocal] = useState(false);
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
const [slideOverOpen, setSlideOverOpen] = useState(false);
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
nodeId: string;
|
||||
} | null>(null);
|
||||
|
||||
const {
|
||||
nodes,
|
||||
|
|
@ -117,12 +175,11 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
|||
loadFlow,
|
||||
} = useFlowEditorStore();
|
||||
|
||||
// 🆕 실시간 플로우 검증
|
||||
const validations = useMemo<FlowValidation[]>(() => {
|
||||
return validateFlow(nodes, edges);
|
||||
}, [nodes, edges]);
|
||||
const validations = useMemo<FlowValidation[]>(
|
||||
() => validateFlow(nodes, edges),
|
||||
[nodes, edges],
|
||||
);
|
||||
|
||||
// 🆕 노드 클릭 핸들러 (검증 패널에서 사용)
|
||||
const handleValidationNodeClick = useCallback(
|
||||
(nodeId: string) => {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
|
|
@ -137,23 +194,27 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
|||
[nodes, selectNodes, setCenter],
|
||||
);
|
||||
|
||||
// 속성 패널 상태 동기화
|
||||
// 노드 선택 시 속성 패널 열기
|
||||
useEffect(() => {
|
||||
if (selectedNodes.length > 0 && !showPropertiesPanelLocal) {
|
||||
setShowPropertiesPanelLocal(true);
|
||||
if (selectedNodes.length > 0) {
|
||||
setSlideOverOpen(true);
|
||||
}
|
||||
}, [selectedNodes, showPropertiesPanelLocal]);
|
||||
}, [selectedNodes]);
|
||||
|
||||
// 초기 플로우 로드
|
||||
// 플로우 로드
|
||||
useEffect(() => {
|
||||
const fetchAndLoadFlow = async () => {
|
||||
if (initialFlowId) {
|
||||
try {
|
||||
const response = await apiClient.get(`/dataflow/node-flows/${initialFlowId}`);
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/dataflow/node-flows/${initialFlowId}`,
|
||||
);
|
||||
if (response.data.success && response.data.data) {
|
||||
const flow = response.data.data;
|
||||
const flowData = typeof flow.flowData === "string" ? JSON.parse(flow.flowData) : flow.flowData;
|
||||
const flowData =
|
||||
typeof flow.flowData === "string"
|
||||
? JSON.parse(flow.flowData)
|
||||
: flow.flowData;
|
||||
|
||||
loadFlow(
|
||||
flow.flowId,
|
||||
|
|
@ -162,73 +223,174 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
|||
flowData.nodes || [],
|
||||
flowData.edges || [],
|
||||
);
|
||||
|
||||
// 🆕 플로우 로드 후 첫 번째 노드 자동 선택
|
||||
if (flowData.nodes && flowData.nodes.length > 0) {
|
||||
const firstNode = flowData.nodes[0];
|
||||
selectNodes([firstNode.id]);
|
||||
setShowPropertiesPanelLocal(true);
|
||||
console.log("✅ 첫 번째 노드 자동 선택:", firstNode.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("플로우 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchAndLoadFlow();
|
||||
}, [initialFlowId, loadFlow, selectNodes]);
|
||||
}, [initialFlowId, loadFlow]);
|
||||
|
||||
/**
|
||||
* 노드 선택 변경 핸들러
|
||||
*/
|
||||
const onSelectionChange = useCallback(
|
||||
({ nodes: selectedNodes }: { nodes: any[] }) => {
|
||||
const selectedIds = selectedNodes.map((node) => node.id);
|
||||
({ nodes: selected }: { nodes: any[] }) => {
|
||||
const selectedIds = selected.map((n) => n.id);
|
||||
selectNodes(selectedIds);
|
||||
console.log("🔍 선택된 노드:", selectedIds);
|
||||
},
|
||||
[selectNodes],
|
||||
);
|
||||
|
||||
/**
|
||||
* 키보드 이벤트 핸들러 (Delete/Backspace 키로 노드 삭제, Ctrl+Z/Y로 Undo/Redo)
|
||||
*/
|
||||
// 더블클릭으로 속성 패널 열기
|
||||
const onNodeDoubleClick = useCallback(
|
||||
(_event: React.MouseEvent, node: any) => {
|
||||
selectNodes([node.id]);
|
||||
setSlideOverOpen(true);
|
||||
},
|
||||
[selectNodes],
|
||||
);
|
||||
|
||||
// 우클릭 컨텍스트 메뉴
|
||||
const onNodeContextMenu = useCallback(
|
||||
(event: React.MouseEvent, node: any) => {
|
||||
event.preventDefault();
|
||||
selectNodes([node.id]);
|
||||
setContextMenu({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
nodeId: node.id,
|
||||
});
|
||||
},
|
||||
[selectNodes],
|
||||
);
|
||||
|
||||
// 캔버스 우클릭 → 커맨드 팔레트
|
||||
const onPaneContextMenu = useCallback(
|
||||
(event: React.MouseEvent | MouseEvent) => {
|
||||
event.preventDefault();
|
||||
setCommandPaletteOpen(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 컨텍스트 메뉴 아이템 생성
|
||||
const getContextMenuItems = useCallback(
|
||||
(nodeId: string) => {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
const nodeName = (node?.data as any)?.displayName || "노드";
|
||||
return [
|
||||
{
|
||||
label: "속성 편집",
|
||||
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||
onClick: () => {
|
||||
selectNodes([nodeId]);
|
||||
setSlideOverOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "복제",
|
||||
icon: <Copy className="h-3.5 w-3.5" />,
|
||||
onClick: () => {
|
||||
if (!node) return;
|
||||
const newNode: any = {
|
||||
id: `node_${Date.now()}`,
|
||||
type: node.type,
|
||||
position: {
|
||||
x: node.position.x + 40,
|
||||
y: node.position.y + 40,
|
||||
},
|
||||
data: { ...(node.data as any) },
|
||||
};
|
||||
addNode(newNode);
|
||||
selectNodes([newNode.id]);
|
||||
toast.success(`"${nodeName}" 노드를 복제했어요`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "삭제",
|
||||
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||
onClick: () => {
|
||||
removeNodes([nodeId]);
|
||||
toast.success(`"${nodeName}" 노드를 삭제했어요`);
|
||||
},
|
||||
danger: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
[nodes, selectNodes, addNode, removeNodes],
|
||||
);
|
||||
|
||||
// "/" 키로 커맨드 팔레트 열기, Esc로 속성 패널 닫기 등
|
||||
const onKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
// Undo: Ctrl+Z (Windows/Linux) or Cmd+Z (Mac)
|
||||
const target = event.target as HTMLElement;
|
||||
const isInput =
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable;
|
||||
|
||||
if (!isInput && event.key === "/" && !event.ctrlKey && !event.metaKey) {
|
||||
event.preventDefault();
|
||||
setCommandPaletteOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "z" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
console.log("⏪ Undo");
|
||||
undo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Redo: Ctrl+Y (Windows/Linux) or Cmd+Shift+Z (Mac) or Ctrl+Shift+Z
|
||||
if (
|
||||
((event.ctrlKey || event.metaKey) && event.key === "y") ||
|
||||
((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "z")
|
||||
) {
|
||||
event.preventDefault();
|
||||
console.log("⏩ Redo");
|
||||
redo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete: Delete/Backspace 키로 노드 삭제
|
||||
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodes.length > 0) {
|
||||
if (
|
||||
(event.key === "Delete" || event.key === "Backspace") &&
|
||||
selectedNodes.length > 0 &&
|
||||
!isInput
|
||||
) {
|
||||
event.preventDefault();
|
||||
console.log("🗑️ 선택된 노드 삭제:", selectedNodes);
|
||||
removeNodes(selectedNodes);
|
||||
}
|
||||
},
|
||||
[selectedNodes, removeNodes, undo, redo],
|
||||
);
|
||||
|
||||
/**
|
||||
* 드래그 앤 드롭 핸들러
|
||||
*/
|
||||
// 커맨드 팔레트에서 노드 선택 시 뷰포트 중앙에 배치
|
||||
const handleCommandSelect = useCallback(
|
||||
(nodeType: string) => {
|
||||
const viewport = getViewport();
|
||||
const wrapper = reactFlowWrapper.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const centerX = rect.width / 2;
|
||||
const centerY = rect.height / 2;
|
||||
|
||||
const position = screenToFlowPosition({
|
||||
x: rect.left + centerX,
|
||||
y: rect.top + centerY,
|
||||
});
|
||||
|
||||
const newNode: any = {
|
||||
id: `node_${Date.now()}`,
|
||||
type: nodeType,
|
||||
position,
|
||||
data: getDefaultNodeData(nodeType),
|
||||
};
|
||||
|
||||
addNode(newNode);
|
||||
selectNodes([newNode.id]);
|
||||
},
|
||||
[screenToFlowPosition, addNode, selectNodes, getViewport],
|
||||
);
|
||||
|
||||
// 기존 드래그 앤 드롭 (하위 호환)
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
|
|
@ -237,7 +399,6 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
|||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const type = event.dataTransfer.getData("application/reactflow");
|
||||
if (!type) return;
|
||||
|
||||
|
|
@ -246,84 +407,11 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
|||
y: event.clientY,
|
||||
});
|
||||
|
||||
// 🔥 노드 타입별 기본 데이터 설정
|
||||
const defaultData: any = {
|
||||
displayName: `새 ${type} 노드`,
|
||||
};
|
||||
|
||||
// REST API 소스 노드의 경우
|
||||
if (type === "restAPISource") {
|
||||
defaultData.method = "GET";
|
||||
defaultData.url = "";
|
||||
defaultData.headers = {};
|
||||
defaultData.timeout = 30000;
|
||||
defaultData.responseFields = []; // 빈 배열로 초기화
|
||||
defaultData.responseMapping = "";
|
||||
}
|
||||
|
||||
// 데이터 액션 노드의 경우 targetType 기본값 설정
|
||||
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
|
||||
defaultData.targetType = "internal"; // 기본값: 내부 DB
|
||||
defaultData.fieldMappings = [];
|
||||
defaultData.options = {};
|
||||
|
||||
if (type === "updateAction" || type === "deleteAction") {
|
||||
defaultData.whereConditions = [];
|
||||
}
|
||||
|
||||
if (type === "upsertAction") {
|
||||
defaultData.conflictKeys = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 메일 발송 노드
|
||||
if (type === "emailAction") {
|
||||
defaultData.displayName = "메일 발송";
|
||||
defaultData.smtpConfig = {
|
||||
host: "",
|
||||
port: 587,
|
||||
secure: false,
|
||||
};
|
||||
defaultData.from = "";
|
||||
defaultData.to = "";
|
||||
defaultData.subject = "";
|
||||
defaultData.body = "";
|
||||
defaultData.bodyType = "text";
|
||||
}
|
||||
|
||||
// 스크립트 실행 노드
|
||||
if (type === "scriptAction") {
|
||||
defaultData.displayName = "스크립트 실행";
|
||||
defaultData.scriptType = "python";
|
||||
defaultData.executionMode = "inline";
|
||||
defaultData.inlineScript = "";
|
||||
defaultData.inputMethod = "stdin";
|
||||
defaultData.inputFormat = "json";
|
||||
defaultData.outputHandling = {
|
||||
captureStdout: true,
|
||||
captureStderr: true,
|
||||
parseOutput: "text",
|
||||
};
|
||||
}
|
||||
|
||||
// HTTP 요청 노드
|
||||
if (type === "httpRequestAction") {
|
||||
defaultData.displayName = "HTTP 요청";
|
||||
defaultData.url = "";
|
||||
defaultData.method = "GET";
|
||||
defaultData.bodyType = "none";
|
||||
defaultData.authentication = { type: "none" };
|
||||
defaultData.options = {
|
||||
timeout: 30000,
|
||||
followRedirects: true,
|
||||
};
|
||||
}
|
||||
|
||||
const newNode: any = {
|
||||
id: `node_${Date.now()}`,
|
||||
type,
|
||||
position,
|
||||
data: defaultData,
|
||||
data: getDefaultNodeData(type),
|
||||
};
|
||||
|
||||
addNode(newNode);
|
||||
|
|
@ -332,32 +420,17 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full" style={{ height: "100%", overflow: "hidden" }}>
|
||||
{/* 좌측 통합 툴바 */}
|
||||
<LeftV2Toolbar
|
||||
buttons={flowToolbarButtons}
|
||||
panelStates={{
|
||||
nodes: { isOpen: showNodesPanel },
|
||||
properties: { isOpen: showPropertiesPanelLocal },
|
||||
}}
|
||||
onTogglePanel={(panelId) => {
|
||||
if (panelId === "nodes") {
|
||||
setShowNodesPanel(!showNodesPanel);
|
||||
} else if (panelId === "properties") {
|
||||
setShowPropertiesPanelLocal(!showPropertiesPanelLocal);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 노드 라이브러리 패널 */}
|
||||
{showNodesPanel && (
|
||||
<div className="h-full w-[300px] border-r bg-white">
|
||||
<NodePalette />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 중앙 캔버스 */}
|
||||
<div className="relative flex-1" ref={reactFlowWrapper} onKeyDown={onKeyDown} tabIndex={0}>
|
||||
<div
|
||||
className="relative flex h-full w-full"
|
||||
style={{ height: "100%", overflow: "hidden" }}
|
||||
>
|
||||
{/* 100% 캔버스 */}
|
||||
<div
|
||||
className="relative flex-1"
|
||||
ref={reactFlowWrapper}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes as any}
|
||||
edges={edges as any}
|
||||
|
|
@ -366,74 +439,116 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
|||
onConnect={onConnect}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onNodeDoubleClick={onNodeDoubleClick}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
onPaneContextMenu={onPaneContextMenu}
|
||||
onPaneClick={() => setContextMenu(null)}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
className="bg-muted"
|
||||
className="bg-zinc-950"
|
||||
deleteKeyCode={["Delete", "Backspace"]}
|
||||
>
|
||||
{/* 배경 그리드 */}
|
||||
<Background gap={16} size={1} color="#E5E7EB" />
|
||||
<Background gap={20} size={1} color="#27272a" />
|
||||
|
||||
{/* 컨트롤 버튼 */}
|
||||
<Controls className="bg-white shadow-md" />
|
||||
|
||||
{/* 미니맵 */}
|
||||
<MiniMap
|
||||
className="bg-white shadow-md"
|
||||
nodeColor={(node) => {
|
||||
// 노드 타입별 색상 (추후 구현)
|
||||
return "#3B82F6";
|
||||
}}
|
||||
maskColor="rgba(0, 0, 0, 0.1)"
|
||||
<Controls
|
||||
className="!rounded-lg !border-zinc-700 !bg-zinc-900 !shadow-lg [&>button]:!border-zinc-700 [&>button]:!bg-zinc-900 [&>button]:!text-zinc-400 [&>button:hover]:!bg-zinc-800 [&>button:hover]:!text-zinc-200"
|
||||
showInteractive={false}
|
||||
/>
|
||||
|
||||
{/* 상단 툴바 */}
|
||||
<MiniMap
|
||||
className="!rounded-lg !border-zinc-700 !bg-zinc-900 !shadow-lg"
|
||||
nodeColor={(node) => {
|
||||
const item = getNodePaletteItem(node.type || "");
|
||||
return item?.color || "#6B7280";
|
||||
}}
|
||||
maskColor="rgba(0, 0, 0, 0.6)"
|
||||
/>
|
||||
|
||||
{/* Breadcrumb (좌상단) */}
|
||||
<Panel position="top-left" className="pointer-events-auto">
|
||||
<div className="rounded-lg border border-zinc-700/60 bg-zinc-900/90 px-3 py-2 backdrop-blur-sm">
|
||||
<FlowBreadcrumb />
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* 플로팅 툴바 (상단 중앙) */}
|
||||
<Panel position="top-center" className="pointer-events-auto">
|
||||
<FlowToolbar validations={validations} onSaveComplete={onSaveComplete} />
|
||||
<FlowToolbar
|
||||
validations={validations}
|
||||
onSaveComplete={onSaveComplete}
|
||||
onOpenCommandPalette={() => setCommandPaletteOpen(true)}
|
||||
/>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* 우측 속성 패널 */}
|
||||
{showPropertiesPanelLocal && selectedNodes.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "350px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
className="border-l bg-white"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
{/* Slide-over 속성 패널 */}
|
||||
<SlideOverSheet
|
||||
isOpen={slideOverOpen && selectedNodes.length > 0}
|
||||
onClose={() => setSlideOverOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Command Palette */}
|
||||
<CommandPalette
|
||||
isOpen={commandPaletteOpen}
|
||||
onClose={() => setCommandPaletteOpen(false)}
|
||||
onSelectNode={handleCommandSelect}
|
||||
/>
|
||||
|
||||
{/* 노드 우클릭 컨텍스트 메뉴 */}
|
||||
{contextMenu && (
|
||||
<NodeContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
items={getContextMenuItems(contextMenu.nodeId)}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 검증 알림 (우측 상단 플로팅) */}
|
||||
<ValidationNotification validations={validations} onNodeClick={handleValidationNodeClick} />
|
||||
{/* 검증 알림 */}
|
||||
<ValidationNotification
|
||||
validations={validations}
|
||||
onNodeClick={handleValidationNodeClick}
|
||||
/>
|
||||
|
||||
{/* 빈 캔버스 힌트 */}
|
||||
{nodes.length === 0 && !commandPaletteOpen && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="mb-2 text-sm text-zinc-500">
|
||||
캔버스가 비어 있어요
|
||||
</p>
|
||||
<p className="text-xs text-zinc-600">
|
||||
<kbd className="rounded border border-zinc-700 bg-zinc-800 px-1.5 py-0.5 font-mono text-[11px]">
|
||||
/
|
||||
</kbd>{" "}
|
||||
키를 눌러서 노드를 추가해 보세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FlowEditor 메인 컴포넌트 (Provider로 감싸기)
|
||||
*/
|
||||
interface FlowEditorProps {
|
||||
initialFlowId?: number | null;
|
||||
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
|
||||
onSaveComplete?: (flowId: number, flowName: string) => void;
|
||||
/** 임베디드 모드 여부 (헤더 표시 여부 등) */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function FlowEditor({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorProps = {}) {
|
||||
export function FlowEditor({
|
||||
initialFlowId,
|
||||
onSaveComplete,
|
||||
embedded = false,
|
||||
}: FlowEditorProps = {}) {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ReactFlowProvider>
|
||||
<FlowEditorInner
|
||||
initialFlowId={initialFlowId}
|
||||
<FlowEditorInner
|
||||
initialFlowId={initialFlowId}
|
||||
onSaveComplete={onSaveComplete}
|
||||
embedded={embedded}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 플로우 에디터 상단 툴바
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Save, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Save,
|
||||
Undo2,
|
||||
Redo2,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize2,
|
||||
Download,
|
||||
Trash2,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { useReactFlow } from "reactflow";
|
||||
|
|
@ -17,11 +22,15 @@ import { useToast } from "@/hooks/use-toast";
|
|||
|
||||
interface FlowToolbarProps {
|
||||
validations?: FlowValidation[];
|
||||
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
|
||||
onSaveComplete?: (flowId: number, flowName: string) => void;
|
||||
onOpenCommandPalette?: () => void;
|
||||
}
|
||||
|
||||
export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarProps) {
|
||||
export function FlowToolbar({
|
||||
validations = [],
|
||||
onSaveComplete,
|
||||
onOpenCommandPalette,
|
||||
}: FlowToolbarProps) {
|
||||
const { toast } = useToast();
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
const {
|
||||
|
|
@ -42,9 +51,7 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
|
|||
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
// Ctrl+S 단축키: 플로우 저장
|
||||
const handleSaveRef = useRef<() => void>();
|
||||
|
||||
useEffect(() => {
|
||||
handleSaveRef.current = handleSave;
|
||||
});
|
||||
|
|
@ -53,28 +60,20 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
|
|||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
if (!isSaving) {
|
||||
handleSaveRef.current?.();
|
||||
}
|
||||
if (!isSaving) handleSaveRef.current?.();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isSaving]);
|
||||
|
||||
const handleSave = async () => {
|
||||
// 검증 수행
|
||||
const currentValidations = validations.length > 0 ? validations : validateFlow(nodes, edges);
|
||||
const summary = summarizeValidations(currentValidations);
|
||||
|
||||
// 오류나 경고가 있으면 다이얼로그 표시
|
||||
const currentValidations =
|
||||
validations.length > 0 ? validations : validateFlow(nodes, edges);
|
||||
if (currentValidations.length > 0) {
|
||||
setShowSaveDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 문제 없으면 바로 저장
|
||||
await performSave();
|
||||
};
|
||||
|
||||
|
|
@ -82,27 +81,22 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
|
|||
const result = await saveFlow();
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "저장 완료",
|
||||
description: `${result.message}\nFlow ID: ${result.flowId}`,
|
||||
title: "저장했어요",
|
||||
description: `플로우가 안전하게 저장됐어요`,
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
// 임베디드 모드에서 저장 완료 콜백 호출
|
||||
if (onSaveComplete && result.flowId) {
|
||||
onSaveComplete(result.flowId, flowName);
|
||||
}
|
||||
|
||||
// 부모 창이 있으면 postMessage로 알림 (새 창에서 열린 경우)
|
||||
if (window.opener && result.flowId) {
|
||||
window.opener.postMessage({
|
||||
type: "FLOW_SAVED",
|
||||
flowId: result.flowId,
|
||||
flowName: flowName,
|
||||
}, "*");
|
||||
window.opener.postMessage(
|
||||
{ type: "FLOW_SAVED", flowId: result.flowId, flowName },
|
||||
"*",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: "저장 실패",
|
||||
title: "저장에 실패했어요",
|
||||
description: result.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
|
|
@ -120,102 +114,128 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
|
|||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast({
|
||||
title: "✅ 내보내기 완료",
|
||||
description: "JSON 파일로 저장되었습니다.",
|
||||
title: "내보내기 완료",
|
||||
description: "JSON 파일로 저장했어요",
|
||||
variant: "default",
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedNodes.length === 0) {
|
||||
toast({
|
||||
title: "⚠️ 선택된 노드 없음",
|
||||
description: "삭제할 노드를 선택해주세요.",
|
||||
variant: "default",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`선택된 ${selectedNodes.length}개 노드를 삭제하시겠습니까?`)) {
|
||||
removeNodes(selectedNodes);
|
||||
toast({
|
||||
title: "✅ 노드 삭제 완료",
|
||||
description: `${selectedNodes.length}개 노드가 삭제되었습니다.`,
|
||||
variant: "default",
|
||||
});
|
||||
}
|
||||
if (selectedNodes.length === 0) return;
|
||||
removeNodes(selectedNodes);
|
||||
toast({
|
||||
title: "노드를 삭제했어요",
|
||||
description: `${selectedNodes.length}개 노드가 삭제됐어요`,
|
||||
variant: "default",
|
||||
});
|
||||
};
|
||||
|
||||
const ToolBtn = ({
|
||||
onClick,
|
||||
disabled,
|
||||
title,
|
||||
danger,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
title: string;
|
||||
danger?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors disabled:opacity-30 ${
|
||||
danger
|
||||
? "text-pink-400 hover:bg-pink-500/15"
|
||||
: "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-background p-2 shadow-md">
|
||||
<div className="flex items-center gap-1 rounded-xl border border-zinc-700 bg-zinc-900/95 px-2 py-1.5 shadow-lg shadow-black/30 backdrop-blur-sm">
|
||||
{/* 노드 추가 */}
|
||||
{onOpenCommandPalette && (
|
||||
<>
|
||||
<button
|
||||
onClick={onOpenCommandPalette}
|
||||
title="노드 추가 (/)"
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg bg-violet-600/20 px-2.5 text-violet-400 transition-colors hover:bg-violet-600/30"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">추가</span>
|
||||
</button>
|
||||
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 플로우 이름 */}
|
||||
<Input
|
||||
value={flowName}
|
||||
onChange={(e) => setFlowName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// 입력 필드에서 키 이벤트가 FlowEditor로 전파되지 않도록 방지
|
||||
// FlowEditor의 Backspace/Delete 키로 노드가 삭제되는 것을 막음
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="h-8 w-[200px] text-sm"
|
||||
placeholder="플로우 이름"
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="h-7 w-[160px] border-none bg-transparent px-2 text-xs font-medium text-zinc-200 placeholder:text-zinc-600 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="플로우 이름을 입력해요"
|
||||
/>
|
||||
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
|
||||
|
||||
{/* 실행 취소/다시 실행 */}
|
||||
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* Undo / Redo */}
|
||||
<ToolBtn onClick={undo} disabled={!canUndo()} title="실행 취소 (Ctrl+Z)">
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={redo} disabled={!canRedo()} title="다시 실행 (Ctrl+Y)">
|
||||
<Redo2 className="h-3.5 w-3.5" />
|
||||
</ToolBtn>
|
||||
|
||||
<div className="h-6 w-px bg-border" />
|
||||
{/* 삭제 */}
|
||||
{selectedNodes.length > 0 && (
|
||||
<>
|
||||
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
|
||||
<ToolBtn onClick={handleDelete} title={`${selectedNodes.length}개 삭제`} danger>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</ToolBtn>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={selectedNodes.length === 0}
|
||||
title={selectedNodes.length > 0 ? `${selectedNodes.length}개 노드 삭제` : "삭제할 노드를 선택하세요"}
|
||||
className="gap-1 text-destructive hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{selectedNodes.length > 0 && <span className="text-xs">({selectedNodes.length})</span>}
|
||||
</Button>
|
||||
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
|
||||
|
||||
<div className="h-6 w-px bg-border" />
|
||||
{/* 줌 */}
|
||||
<ToolBtn onClick={() => zoomIn()} title="확대">
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => zoomOut()} title="축소">
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => fitView()} title="전체 보기">
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</ToolBtn>
|
||||
|
||||
{/* 줌 컨트롤 */}
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomIn()} title="확대">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomOut()} title="축소">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => fitView()} title="전체 보기">
|
||||
<span className="text-xs">전체</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
|
||||
|
||||
{/* 저장 */}
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-1">
|
||||
<Save className="h-4 w-4" />
|
||||
<span className="text-xs">{isSaving ? "저장 중..." : "저장"}</span>
|
||||
</Button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
title="저장 (Ctrl+S)"
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-zinc-300 transition-colors hover:bg-zinc-700 hover:text-zinc-100 disabled:opacity-40"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">{isSaving ? "저장 중..." : "저장"}</span>
|
||||
</button>
|
||||
|
||||
{/* 내보내기 */}
|
||||
<Button variant="outline" size="sm" onClick={handleExport} className="gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-xs">JSON</span>
|
||||
</Button>
|
||||
{/* JSON 내보내기 */}
|
||||
<ToolBtn onClick={handleExport} title="JSON 내보내기">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</ToolBtn>
|
||||
</div>
|
||||
|
||||
{/* 저장 확인 다이얼로그 */}
|
||||
<SaveConfirmDialog
|
||||
open={showSaveDialog}
|
||||
validations={validations.length > 0 ? validations : validateFlow(nodes, edges)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Pencil, Copy, Trash2, Scissors } from "lucide-react";
|
||||
|
||||
interface ContextMenuItem {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface NodeContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuItem[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NodeContextMenu({ x, y, items, onClose }: NodeContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
document.addEventListener("keydown", handleEsc);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
document.removeEventListener("keydown", handleEsc);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed z-[200] min-w-[160px] rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-xl shadow-black/40"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
item.onClick();
|
||||
onClose();
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors disabled:opacity-30 ${
|
||||
item.danger
|
||||
? "text-pink-400 hover:bg-pink-500/10"
|
||||
: "text-zinc-300 hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
import { X, Info } from "lucide-react";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { PropertiesPanel } from "./panels/PropertiesPanel";
|
||||
import { getNodePaletteItem } from "./sidebar/nodePaletteConfig";
|
||||
|
||||
const TOSS_NODE_HINTS: Record<string, string> = {
|
||||
tableSource: "어떤 테이블에서 데이터를 가져올지 선택해 주세요",
|
||||
externalDBSource: "외부 데이터베이스 연결 정보를 입력해 주세요",
|
||||
restAPISource: "호출할 API의 URL과 방식을 설정해 주세요",
|
||||
condition: "어떤 조건으로 데이터를 분기할지 설정해 주세요",
|
||||
dataTransform: "데이터를 어떻게 변환할지 규칙을 정해 주세요",
|
||||
aggregate: "어떤 기준으로 집계할지 설정해 주세요",
|
||||
formulaTransform: "계산에 사용할 수식을 입력해 주세요",
|
||||
insertAction: "데이터를 저장할 테이블과 필드를 매핑해 주세요",
|
||||
updateAction: "수정할 조건과 필드를 설정해 주세요",
|
||||
deleteAction: "삭제 조건을 설정해 주세요",
|
||||
upsertAction: "저장/수정 조건과 필드를 설정해 주세요",
|
||||
emailAction: "메일 서버와 발송 정보를 설정해 주세요",
|
||||
scriptAction: "실행할 스크립트 내용을 입력해 주세요",
|
||||
httpRequestAction: "요청 URL과 방식을 설정해 주세요",
|
||||
procedureCallAction: "호출할 프로시저 정보를 입력해 주세요",
|
||||
comment: "메모 내용을 자유롭게 작성해 주세요",
|
||||
};
|
||||
|
||||
interface SlideOverSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SlideOverSheet({ isOpen, onClose }: SlideOverSheetProps) {
|
||||
const { nodes, selectedNodes } = useFlowEditorStore();
|
||||
|
||||
const selectedNode =
|
||||
selectedNodes.length === 1
|
||||
? nodes.find((n) => n.id === selectedNodes[0])
|
||||
: null;
|
||||
|
||||
const nodeInfo = selectedNode
|
||||
? getNodePaletteItem(selectedNode.type as string)
|
||||
: null;
|
||||
|
||||
const hint = selectedNode
|
||||
? TOSS_NODE_HINTS[(selectedNode.type as string)] || "이 노드의 속성을 설정해 주세요"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute right-0 top-0 z-40 flex h-full w-[380px] flex-col border-l border-zinc-700 bg-zinc-900 shadow-2xl shadow-black/40 transition-transform duration-300 ease-out ${
|
||||
isOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-zinc-800 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{nodeInfo && (
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: nodeInfo.color }}
|
||||
/>
|
||||
)}
|
||||
<h3 className="truncate text-sm font-semibold text-zinc-200">
|
||||
{nodeInfo?.label || "속성"}
|
||||
</h3>
|
||||
</div>
|
||||
{selectedNode && (
|
||||
<p className="mt-0.5 truncate text-[11px] text-zinc-500">
|
||||
{(selectedNode.data as any)?.displayName || "이름 없음"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-2 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 힌트 배너 */}
|
||||
{hint && (
|
||||
<div className="flex items-start gap-2 border-b border-zinc-800 bg-violet-500/[0.06] px-4 py-2.5">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-violet-400" />
|
||||
<p className="text-[11px] leading-relaxed text-violet-300/80">{hint}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 속성 패널 내용 (라이트 배경으로 폼 가독성 유지) */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-white dark:bg-zinc-800">
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,107 +1,40 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 집계 노드 (Aggregate Node)
|
||||
* SUM, COUNT, AVG, MIN, MAX 등 집계 연산을 수행
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Calculator, Layers } from "lucide-react";
|
||||
import type { AggregateNodeData, AggregateFunction } from "@/types/node-editor";
|
||||
|
||||
// 집계 함수별 아이콘/라벨
|
||||
const AGGREGATE_FUNCTION_LABELS: Record<AggregateFunction, string> = {
|
||||
SUM: "합계",
|
||||
COUNT: "개수",
|
||||
AVG: "평균",
|
||||
MIN: "최소",
|
||||
MAX: "최대",
|
||||
FIRST: "첫번째",
|
||||
LAST: "마지막",
|
||||
};
|
||||
import { NodeProps } from "reactflow";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { AggregateNodeData } from "@/types/node-editor";
|
||||
|
||||
export const AggregateNode = memo(({ data, selected }: NodeProps<AggregateNodeData>) => {
|
||||
const groupByCount = data.groupByFields?.length || 0;
|
||||
const aggregationCount = data.aggregations?.length || 0;
|
||||
const opCount = data.operations?.length || 0;
|
||||
const groupCount = data.groupByFields?.length || 0;
|
||||
const summary = opCount > 0
|
||||
? `${opCount}개 연산${groupCount > 0 ? `, ${groupCount}개 그룹` : ""}`
|
||||
: "집계 연산을 설정해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[280px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-purple-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
<CompactNodeShell
|
||||
color="#A855F7"
|
||||
label={data.displayName || "집계"}
|
||||
summary={summary}
|
||||
icon={<BarChart3 className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-purple-600 px-3 py-2 text-white">
|
||||
<Calculator className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "집계"}</div>
|
||||
<div className="text-xs opacity-80">
|
||||
{groupByCount > 0 ? `${groupByCount}개 그룹` : "전체"} / {aggregationCount}개 집계
|
||||
</div>
|
||||
{opCount > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{data.operations!.slice(0, 3).map((op: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<span className="rounded bg-violet-500/20 px-1 py-0.5 font-mono text-[9px] font-semibold text-violet-400">
|
||||
{op.function || op.operation}
|
||||
</span>
|
||||
<span>{op.field || op.sourceField}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3 space-y-3">
|
||||
{/* 그룹 기준 */}
|
||||
{groupByCount > 0 && (
|
||||
<div className="rounded bg-purple-50 p-2">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Layers className="h-3 w-3 text-purple-600" />
|
||||
<span className="text-xs font-medium text-purple-700">그룹 기준</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{data.groupByFields.slice(0, 3).map((field, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-700"
|
||||
>
|
||||
{field.fieldLabel || field.field}
|
||||
</span>
|
||||
))}
|
||||
{data.groupByFields.length > 3 && (
|
||||
<span className="text-xs text-purple-500">+{data.groupByFields.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 집계 연산 */}
|
||||
{aggregationCount > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{data.aggregations.slice(0, 4).map((agg, idx) => (
|
||||
<div key={agg.id || idx} className="rounded bg-muted p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="rounded bg-purple-600 px-1.5 py-0.5 text-xs font-medium text-white">
|
||||
{AGGREGATE_FUNCTION_LABELS[agg.function] || agg.function}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{agg.outputFieldLabel || agg.outputField}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{agg.sourceFieldLabel || agg.sourceField}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.aggregations.length > 4 && (
|
||||
<div className="text-xs text-muted-foreground/70 text-center">
|
||||
... 외 {data.aggregations.length - 4}개
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground/70">집계 연산 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-purple-500" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-purple-500" />
|
||||
</div>
|
||||
)}
|
||||
</CompactNodeShell>
|
||||
);
|
||||
});
|
||||
|
||||
AggregateNode.displayName = "AggregateNode";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +1,21 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 주석 노드 - 플로우 설명용
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import type { CommentNodeData } from "@/types/node-editor";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
|
||||
export const CommentNode = memo(({ data, selected }: NodeProps<CommentNodeData>) => {
|
||||
export const CommentNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||
return (
|
||||
<div
|
||||
className={`max-w-[350px] min-w-[200px] rounded-lg border-2 border-dashed bg-amber-50 shadow-sm transition-all ${
|
||||
selected ? "border-yellow-500 shadow-md" : "border-amber-300"
|
||||
}`}
|
||||
>
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-amber-600" />
|
||||
<span className="text-xs font-semibold text-yellow-800">메모</span>
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-wrap text-foreground">{data.content || "메모를 입력하세요..."}</div>
|
||||
</div>
|
||||
</div>
|
||||
<CompactNodeShell
|
||||
color="#6B7280"
|
||||
label="메모"
|
||||
summary={data.comment || data.text || "메모를 작성해 주세요"}
|
||||
icon={<MessageSquare className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
hasInput={false}
|
||||
hasOutput={false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 콤팩트 노드 공통 쉘
|
||||
* 다크 캔버스에서 사용하는 공통 노드 래퍼
|
||||
*/
|
||||
|
||||
import { memo, ReactNode } from "react";
|
||||
import { Handle, Position } from "reactflow";
|
||||
|
||||
interface CompactNodeShellProps {
|
||||
color: string;
|
||||
label: string;
|
||||
summary?: string;
|
||||
icon: ReactNode;
|
||||
selected?: boolean;
|
||||
children?: ReactNode;
|
||||
hasInput?: boolean;
|
||||
hasOutput?: boolean;
|
||||
inputHandleId?: string;
|
||||
outputHandleId?: string;
|
||||
/** 커스텀 출력 핸들(ConditionNode 등)을 사용할 경우 true */
|
||||
customOutputHandles?: boolean;
|
||||
/** 커스텀 입력 핸들을 사용할 경우 true */
|
||||
customInputHandles?: boolean;
|
||||
minWidth?: string;
|
||||
}
|
||||
|
||||
export const CompactNodeShell = memo(
|
||||
({
|
||||
color,
|
||||
label,
|
||||
summary,
|
||||
icon,
|
||||
selected = false,
|
||||
children,
|
||||
hasInput = true,
|
||||
hasOutput = true,
|
||||
inputHandleId,
|
||||
outputHandleId,
|
||||
customOutputHandles = false,
|
||||
customInputHandles = false,
|
||||
minWidth = "260px",
|
||||
}: CompactNodeShellProps) => {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border bg-zinc-900 shadow-lg transition-all ${
|
||||
selected
|
||||
? "border-violet-500 shadow-violet-500/20"
|
||||
: "border-zinc-700 hover:border-zinc-600"
|
||||
}`}
|
||||
style={{ minWidth, maxWidth: "320px" }}
|
||||
>
|
||||
{/* 기본 입력 핸들 */}
|
||||
{hasInput && !customInputHandles && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={inputHandleId}
|
||||
className="!h-2.5 !w-2.5 !border-2 !bg-zinc-900"
|
||||
style={{ borderColor: color }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 컬러바 + 헤더 */}
|
||||
<div className="flex items-center gap-2.5 px-3 py-2.5">
|
||||
<div
|
||||
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md"
|
||||
style={{ backgroundColor: `${color}20` }}
|
||||
>
|
||||
<div className="text-zinc-200" style={{ color }}>{icon}</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-semibold text-zinc-200">{label}</div>
|
||||
{summary && (
|
||||
<div className="line-clamp-2 text-[10px] leading-relaxed text-zinc-500">{summary}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 바디 (옵셔널) */}
|
||||
{children && (
|
||||
<div className="border-t border-zinc-800 px-3 py-2 text-[10px] text-zinc-400">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기본 출력 핸들 */}
|
||||
{hasOutput && !customOutputHandles && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={outputHandleId}
|
||||
className="!h-2.5 !w-2.5 !border-2 !bg-zinc-900"
|
||||
style={{ borderColor: color }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CompactNodeShell.displayName = "CompactNodeShell";
|
||||
|
|
@ -1,132 +1,88 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 조건 분기 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Zap, Check, X } from "lucide-react";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { ConditionNodeData } from "@/types/node-editor";
|
||||
|
||||
const OPERATOR_LABELS: Record<string, string> = {
|
||||
EQUALS: "=",
|
||||
NOT_EQUALS: "≠",
|
||||
GREATER_THAN: ">",
|
||||
LESS_THAN: "<",
|
||||
GREATER_THAN_OR_EQUAL: "≥",
|
||||
LESS_THAN_OR_EQUAL: "≤",
|
||||
LIKE: "포함",
|
||||
NOT_LIKE: "미포함",
|
||||
IN: "IN",
|
||||
NOT_IN: "NOT IN",
|
||||
IS_NULL: "NULL",
|
||||
IS_NOT_NULL: "NOT NULL",
|
||||
EXISTS_IN: "EXISTS IN",
|
||||
NOT_EXISTS_IN: "NOT EXISTS IN",
|
||||
};
|
||||
|
||||
// EXISTS 계열 연산자인지 확인
|
||||
const isExistsOperator = (operator: string): boolean => {
|
||||
return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN";
|
||||
EQUALS: "=", NOT_EQUALS: "!=",
|
||||
GREATER_THAN: ">", LESS_THAN: "<",
|
||||
GREATER_THAN_OR_EQUAL: ">=", LESS_THAN_OR_EQUAL: "<=",
|
||||
LIKE: "포함", NOT_LIKE: "미포함",
|
||||
IN: "IN", NOT_IN: "NOT IN",
|
||||
IS_NULL: "NULL", IS_NOT_NULL: "NOT NULL",
|
||||
EXISTS_IN: "EXISTS", NOT_EXISTS_IN: "NOT EXISTS",
|
||||
};
|
||||
|
||||
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||
const condCount = data.conditions?.length || 0;
|
||||
const summary = condCount > 0
|
||||
? `${condCount}개 조건 (${data.logic || "AND"})`
|
||||
: "조건을 설정해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[280px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-yellow-500 shadow-lg" : "border-border"
|
||||
className={`rounded-lg border bg-zinc-900 shadow-lg transition-all ${
|
||||
selected ? "border-violet-500 shadow-violet-500/20" : "border-zinc-700"
|
||||
}`}
|
||||
style={{ minWidth: "260px", maxWidth: "320px" }}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-yellow-500 !bg-white" />
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-2.5 !w-2.5 !border-2 !border-amber-500 !bg-zinc-900"
|
||||
/>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-amber-500 px-3 py-2 text-white">
|
||||
<Zap className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">조건 검사</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || "조건 분기"}</div>
|
||||
<div className="flex items-center gap-2.5 px-3 py-2.5">
|
||||
<div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-amber-500/20">
|
||||
<GitBranch className="h-3.5 w-3.5 text-amber-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-semibold text-zinc-200">
|
||||
{data.displayName || "조건 분기"}
|
||||
</div>
|
||||
<div className="line-clamp-2 text-[10px] leading-relaxed text-zinc-500">{summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{data.conditions && data.conditions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-foreground">조건식: ({data.conditions.length}개)</div>
|
||||
<div className="max-h-[150px] space-y-1.5 overflow-y-auto">
|
||||
{data.conditions.slice(0, 4).map((condition, idx) => (
|
||||
<div key={idx} className="rounded bg-amber-50 px-2 py-1.5 text-xs">
|
||||
{idx > 0 && (
|
||||
<div className="mb-1 text-center text-xs font-semibold text-amber-600">{data.logic}</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="font-mono text-foreground">{condition.field}</span>
|
||||
<span
|
||||
className={`rounded px-1 py-0.5 ${
|
||||
isExistsOperator(condition.operator)
|
||||
? "bg-purple-200 text-purple-800"
|
||||
: "bg-yellow-200 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
{OPERATOR_LABELS[condition.operator] || condition.operator}
|
||||
</span>
|
||||
{/* EXISTS 연산자인 경우 테이블.필드 표시 */}
|
||||
{isExistsOperator(condition.operator) ? (
|
||||
<span className="text-purple-600">
|
||||
{(condition as any).lookupTableLabel || (condition as any).lookupTable || "..."}
|
||||
{(condition as any).lookupField && `.${(condition as any).lookupFieldLabel || (condition as any).lookupField}`}
|
||||
</span>
|
||||
) : (
|
||||
// 일반 연산자인 경우 값 표시
|
||||
condition.value !== null &&
|
||||
condition.value !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
{typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.conditions.length > 4 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {data.conditions.length - 4}개</div>
|
||||
{/* 조건 미리보기 */}
|
||||
{condCount > 0 && (
|
||||
<div className="space-y-0.5 border-t border-zinc-800 px-3 py-2 text-[10px] text-zinc-400">
|
||||
{data.conditions!.slice(0, 2).map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-1 flex-wrap">
|
||||
{i > 0 && <span className="text-amber-500">{data.logic}</span>}
|
||||
<span className="font-mono text-zinc-300">{c.field}</span>
|
||||
<span className="text-amber-400">{OPERATOR_LABELS[c.operator] || c.operator}</span>
|
||||
{c.value !== undefined && c.value !== null && (
|
||||
<span className="text-zinc-500">{String(c.value)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-xs text-muted-foreground/70">조건 없음</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{condCount > 2 && <span className="text-zinc-600">외 {condCount - 2}개</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 분기 출력 핸들 */}
|
||||
<div className="relative border-t">
|
||||
{/* TRUE 출력 - 오른쪽 위 */}
|
||||
<div className="relative border-b p-2">
|
||||
<div className="flex items-center justify-end gap-1 pr-6 text-xs">
|
||||
<Check className="h-3 w-3 text-emerald-600" />
|
||||
<span className="font-medium text-emerald-600">TRUE</span>
|
||||
</div>
|
||||
{/* 분기 출력 */}
|
||||
<div className="border-t border-zinc-800">
|
||||
<div className="relative flex items-center justify-end px-3 py-1.5">
|
||||
<span className="text-[10px] font-medium text-emerald-400">통과</span>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="true"
|
||||
className="!top-1/2 !-right-1.5 !h-3 !w-3 !-translate-y-1/2 !border-2 !border-emerald-500 !bg-white"
|
||||
className="!h-2.5 !w-2.5 !border-2 !border-emerald-500 !bg-zinc-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* FALSE 출력 - 오른쪽 아래 */}
|
||||
<div className="relative p-2">
|
||||
<div className="flex items-center justify-end gap-1 pr-6 text-xs">
|
||||
<X className="h-3 w-3 text-destructive" />
|
||||
<span className="font-medium text-destructive">FALSE</span>
|
||||
</div>
|
||||
<div className="relative flex items-center justify-end border-t border-zinc-800/50 px-3 py-1.5">
|
||||
<span className="text-[10px] font-medium text-pink-400">미통과</span>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="false"
|
||||
className="!top-1/2 !-right-1.5 !h-3 !w-3 !-translate-y-1/2 !border-2 !border-destructive !bg-white"
|
||||
className="!h-2.5 !w-2.5 !border-2 !border-pink-500 !bg-zinc-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,87 +1,38 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 데이터 변환 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Wand2, ArrowRight } from "lucide-react";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Repeat } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { DataTransformNodeData } from "@/types/node-editor";
|
||||
|
||||
export const DataTransformNode = memo(({ data, selected }: NodeProps<DataTransformNodeData>) => {
|
||||
const ruleCount = data.transformRules?.length || 0;
|
||||
const summary = ruleCount > 0
|
||||
? `${ruleCount}개 변환 규칙`
|
||||
: "변환 규칙을 설정해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
<CompactNodeShell
|
||||
color="#06B6D4"
|
||||
label={data.displayName || "데이터 변환"}
|
||||
summary={summary}
|
||||
icon={<Repeat className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-primary px-3 py-2 text-white">
|
||||
<Wand2 className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "데이터 변환"}</div>
|
||||
<div className="text-xs opacity-80">{data.transformations?.length || 0}개 변환</div>
|
||||
{ruleCount > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{data.transformRules!.slice(0, 3).map((r: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<div className="h-1 w-1 rounded-full bg-cyan-400" />
|
||||
<span>{r.sourceField || r.field || `규칙 ${i + 1}`}</span>
|
||||
{r.targetField && <span className="text-zinc-600">→ {r.targetField}</span>}
|
||||
</div>
|
||||
))}
|
||||
{ruleCount > 3 && <span className="text-zinc-600">외 {ruleCount - 3}개</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{data.transformations && data.transformations.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{data.transformations.slice(0, 3).map((transform, idx) => {
|
||||
const sourceLabel = transform.sourceFieldLabel || transform.sourceField || "소스";
|
||||
const targetField = transform.targetField || transform.sourceField;
|
||||
const targetLabel = transform.targetFieldLabel || targetField;
|
||||
const isInPlace = !transform.targetField || transform.targetField === transform.sourceField;
|
||||
|
||||
return (
|
||||
<div key={idx} className="rounded bg-indigo-50 p-2">
|
||||
<div className="mb-1 flex items-center gap-2 text-xs">
|
||||
<span className="font-medium text-indigo-700">{transform.type}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{sourceLabel}
|
||||
<span className="mx-1 text-muted-foreground/70">→</span>
|
||||
{isInPlace ? (
|
||||
<span className="font-medium text-primary">(자기자신)</span>
|
||||
) : (
|
||||
<span>{targetLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 타입별 추가 정보 */}
|
||||
{transform.type === "EXPLODE" && transform.delimiter && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">구분자: {transform.delimiter}</div>
|
||||
)}
|
||||
{transform.type === "CONCAT" && transform.separator && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">구분자: {transform.separator}</div>
|
||||
)}
|
||||
{transform.type === "REPLACE" && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
"{transform.searchValue}" → "{transform.replaceValue}"
|
||||
</div>
|
||||
)}
|
||||
{transform.expression && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
<code className="rounded bg-white px-1 py-0.5">{transform.expression}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{data.transformations.length > 3 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {data.transformations.length - 3}개</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground/70">변환 규칙 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-primary" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-primary" />
|
||||
</div>
|
||||
)}
|
||||
</CompactNodeShell>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,75 +1,25 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* DELETE 액션 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Trash2, AlertTriangle } from "lucide-react";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { DeleteActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const DeleteActionNode = memo(({ data, selected }: NodeProps<DeleteActionNodeData>) => {
|
||||
const whereCount = data.whereConditions?.length || 0;
|
||||
const summary = data.targetTable
|
||||
? `${data.targetTable} (${whereCount}개 조건)`
|
||||
: "대상 테이블을 선택해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-destructive shadow-lg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-destructive !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-destructive px-3 py-2 text-white">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">DELETE</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">타겟: {data.targetTable}</div>
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
{data.whereConditions && data.whereConditions.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-foreground">WHERE 조건:</div>
|
||||
<div className="max-h-[120px] space-y-1 overflow-y-auto">
|
||||
{data.whereConditions.map((condition, idx) => (
|
||||
<div key={idx} className="rounded bg-destructive/10 px-2 py-1 text-xs">
|
||||
<span className="font-mono text-foreground">{condition.field}</span>
|
||||
<span className="mx-1 text-destructive">{condition.operator}</span>
|
||||
<span className="text-muted-foreground">{condition.sourceField || condition.staticValue || "?"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded bg-amber-50 p-2 text-xs text-yellow-700">⚠️ 조건 없음 - 모든 데이터 삭제 주의!</div>
|
||||
)}
|
||||
|
||||
{/* 경고 메시지 */}
|
||||
<div className="mt-3 flex items-start gap-2 rounded border border-destructive/20 bg-destructive/10 p-2">
|
||||
<AlertTriangle className="h-3 w-3 flex-shrink-0 text-destructive" />
|
||||
<div className="text-xs text-destructive">
|
||||
<div className="font-medium">주의</div>
|
||||
<div className="mt-0.5">삭제된 데이터는 복구할 수 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 옵션 */}
|
||||
{data.options?.requireConfirmation && (
|
||||
<div className="mt-2">
|
||||
<span className="rounded bg-destructive/10 px-1.5 py-0.5 text-xs text-destructive">실행 전 확인 필요</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-destructive !bg-white" />
|
||||
</div>
|
||||
<CompactNodeShell
|
||||
color="#EF4444"
|
||||
label={data.displayName || "DELETE"}
|
||||
summary={summary}
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,104 +1,30 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 메일 발송 액션 노드
|
||||
* 등록된 메일 계정을 선택하여 이메일을 발송하는 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Mail, User, CheckCircle } from "lucide-react";
|
||||
import type { EmailActionNodeData } from "@/types/node-editor";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Mail } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
|
||||
export const EmailActionNode = memo(({ data, selected }: NodeProps<EmailActionNodeData>) => {
|
||||
const hasAccount = !!data.accountId;
|
||||
const hasRecipient = data.to && data.to.trim().length > 0;
|
||||
const hasSubject = data.subject && data.subject.trim().length > 0;
|
||||
export const EmailActionNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||
const summary = data.to
|
||||
? `To: ${data.to}`
|
||||
: "수신자를 설정해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-pink-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
<CompactNodeShell
|
||||
color="#EC4899"
|
||||
label={data.displayName || "메일 발송"}
|
||||
summary={summary}
|
||||
icon={<Mail className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-3 !w-3 !border-2 !border-white !bg-pink-500"
|
||||
/>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-pink-500 px-3 py-2 text-white">
|
||||
<Mail className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "메일 발송"}</div>
|
||||
{data.subject && (
|
||||
<div className="line-clamp-2">
|
||||
제목: {data.subject}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="space-y-2 p-3">
|
||||
{/* 발송 계정 상태 */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<User className="h-3 w-3 text-muted-foreground/70" />
|
||||
<span className="text-muted-foreground">
|
||||
{hasAccount ? (
|
||||
<span className="flex items-center gap-1 text-emerald-600">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
계정 선택됨
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-500">발송 계정 선택 필요</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 수신자 */}
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">수신자: </span>
|
||||
{hasRecipient ? (
|
||||
<span className="text-foreground">{data.to}</span>
|
||||
) : (
|
||||
<span className="text-amber-500">미설정</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">제목: </span>
|
||||
{hasSubject ? (
|
||||
<span className="truncate text-foreground">{data.subject}</span>
|
||||
) : (
|
||||
<span className="text-amber-500">미설정</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 본문 형식 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||
data.bodyType === "html" ? "bg-primary/10 text-primary" : "bg-muted text-foreground"
|
||||
}`}
|
||||
>
|
||||
{data.bodyType === "html" ? "HTML" : "TEXT"}
|
||||
</span>
|
||||
{data.attachments && data.attachments.length > 0 && (
|
||||
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-700">
|
||||
첨부 {data.attachments.length}개
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !border-white !bg-pink-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CompactNodeShell>
|
||||
);
|
||||
});
|
||||
|
||||
EmailActionNode.displayName = "EmailActionNode";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,87 +1,25 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 외부 DB 소스 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Plug } from "lucide-react";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { ExternalDBSourceNodeData } from "@/types/node-editor";
|
||||
|
||||
const DB_TYPE_COLORS: Record<string, string> = {
|
||||
PostgreSQL: "#336791",
|
||||
MySQL: "#4479A1",
|
||||
Oracle: "#F80000",
|
||||
MSSQL: "#CC2927",
|
||||
MariaDB: "#003545",
|
||||
};
|
||||
|
||||
const DB_TYPE_ICONS: Record<string, string> = {
|
||||
PostgreSQL: "🐘",
|
||||
MySQL: "🐬",
|
||||
Oracle: "🔴",
|
||||
MSSQL: "🟦",
|
||||
MariaDB: "🦭",
|
||||
};
|
||||
|
||||
export const ExternalDBSourceNode = memo(({ data, selected }: NodeProps<ExternalDBSourceNodeData>) => {
|
||||
const dbColor = (data.dbType && DB_TYPE_COLORS[data.dbType]) || "#F59E0B";
|
||||
const dbIcon = (data.dbType && DB_TYPE_ICONS[data.dbType]) || "🔌";
|
||||
const summary = data.connectionName
|
||||
? `${data.connectionName} → ${data.tableName || "..."}`
|
||||
: "외부 DB 연결을 설정해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg px-3 py-2 text-white" style={{ backgroundColor: dbColor }}>
|
||||
<Plug className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || data.connectionName}</div>
|
||||
<div className="text-xs opacity-80">{data.tableName}</div>
|
||||
</div>
|
||||
<span className="text-lg">{dbIcon}</span>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center gap-1 text-xs">
|
||||
<div className="rounded bg-amber-100 px-2 py-0.5 font-medium text-orange-700">{data.dbType || "DB"}</div>
|
||||
<div className="flex-1 text-muted-foreground">외부 DB</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-foreground">출력 필드:</div>
|
||||
<div className="max-h-[150px] overflow-y-auto">
|
||||
{data.fields && data.fields.length > 0 ? (
|
||||
data.fields.slice(0, 5).map((field) => (
|
||||
<div key={field.name} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: dbColor }} />
|
||||
<span className="font-mono">{field.name}</span>
|
||||
<span className="text-muted-foreground/70">({field.type})</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground/70">필드 없음</div>
|
||||
)}
|
||||
{data.fields && data.fields.length > 5 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {data.fields.length - 5}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !bg-white"
|
||||
style={{ borderColor: dbColor }}
|
||||
/>
|
||||
</div>
|
||||
<CompactNodeShell
|
||||
color="#F59E0B"
|
||||
label={data.displayName || "외부 DB"}
|
||||
summary={summary}
|
||||
icon={<HardDrive className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
hasInput={false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,164 +1,23 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 수식 변환 노드 (Formula Transform Node)
|
||||
* 산술 연산, 함수, 조건문 등을 사용해 새로운 필드를 계산합니다.
|
||||
* 타겟 테이블의 기존 값을 참조하여 UPSERT 시나리오를 지원합니다.
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Calculator, Database, ArrowRight } from "lucide-react";
|
||||
import type { FormulaTransformNodeData, FormulaType } from "@/types/node-editor";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Calculator } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
|
||||
// 수식 타입별 라벨
|
||||
const FORMULA_TYPE_LABELS: Record<FormulaType, { label: string; color: string }> = {
|
||||
arithmetic: { label: "산술", color: "bg-amber-500" },
|
||||
function: { label: "함수", color: "bg-primary" },
|
||||
condition: { label: "조건", color: "bg-amber-500" },
|
||||
static: { label: "정적", color: "bg-muted0" },
|
||||
};
|
||||
|
||||
// 연산자 표시
|
||||
const OPERATOR_LABELS: Record<string, string> = {
|
||||
"+": "+",
|
||||
"-": "-",
|
||||
"*": "x",
|
||||
"/": "/",
|
||||
"%": "%",
|
||||
};
|
||||
|
||||
// 피연산자를 문자열로 변환
|
||||
function getOperandStr(operand: any): string {
|
||||
if (!operand) return "?";
|
||||
if (operand.type === "static") return String(operand.value || "?");
|
||||
if (operand.fieldLabel) return operand.fieldLabel;
|
||||
return operand.field || operand.resultField || "?";
|
||||
}
|
||||
|
||||
// 수식 요약 생성
|
||||
function getFormulaSummary(transformation: FormulaTransformNodeData["transformations"][0]): string {
|
||||
const { formulaType, arithmetic, function: func, condition, staticValue } = transformation;
|
||||
|
||||
switch (formulaType) {
|
||||
case "arithmetic": {
|
||||
if (!arithmetic) return "미설정";
|
||||
const leftStr = getOperandStr(arithmetic.leftOperand);
|
||||
const rightStr = getOperandStr(arithmetic.rightOperand);
|
||||
let formula = `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`;
|
||||
|
||||
// 추가 연산 표시
|
||||
if (arithmetic.additionalOperations && arithmetic.additionalOperations.length > 0) {
|
||||
for (const addOp of arithmetic.additionalOperations) {
|
||||
const opStr = getOperandStr(addOp.operand);
|
||||
formula += ` ${OPERATOR_LABELS[addOp.operator] || addOp.operator} ${opStr}`;
|
||||
}
|
||||
}
|
||||
|
||||
return formula;
|
||||
}
|
||||
case "function": {
|
||||
if (!func) return "미설정";
|
||||
const args = func.arguments
|
||||
.map((arg) => (arg.type === "static" ? arg.value : `${arg.type}.${arg.field || arg.resultField}`))
|
||||
.join(", ");
|
||||
return `${func.name}(${args})`;
|
||||
}
|
||||
case "condition": {
|
||||
if (!condition) return "미설정";
|
||||
return "CASE WHEN ... THEN ... ELSE ...";
|
||||
}
|
||||
case "static": {
|
||||
return staticValue !== undefined ? String(staticValue) : "미설정";
|
||||
}
|
||||
default:
|
||||
return "미설정";
|
||||
}
|
||||
}
|
||||
|
||||
export const FormulaTransformNode = memo(({ data, selected }: NodeProps<FormulaTransformNodeData>) => {
|
||||
const transformationCount = data.transformations?.length || 0;
|
||||
const hasTargetLookup = !!data.targetLookup?.tableName;
|
||||
export const FormulaTransformNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||
const summary = data.formula
|
||||
? `${data.formula.substring(0, 30)}${data.formula.length > 30 ? "..." : ""}`
|
||||
: "수식을 입력해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[300px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-amber-500 px-3 py-2 text-white">
|
||||
<Calculator className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "수식 변환"}</div>
|
||||
<div className="text-xs opacity-80">
|
||||
{transformationCount}개 변환 {hasTargetLookup && "| 타겟 조회"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="space-y-3 p-3">
|
||||
{/* 타겟 테이블 조회 설정 */}
|
||||
{hasTargetLookup && (
|
||||
<div className="rounded bg-primary/10 p-2">
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<Database className="h-3 w-3 text-primary" />
|
||||
<span className="text-xs font-medium text-primary">타겟 조회</span>
|
||||
</div>
|
||||
<div className="text-xs text-primary">{data.targetLookup?.tableLabel || data.targetLookup?.tableName}</div>
|
||||
{data.targetLookup?.lookupKeys && data.targetLookup.lookupKeys.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{data.targetLookup.lookupKeys.slice(0, 2).map((key, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary"
|
||||
>
|
||||
{key.sourceFieldLabel || key.sourceField}
|
||||
<ArrowRight className="h-2 w-2" />
|
||||
{key.targetFieldLabel || key.targetField}
|
||||
</span>
|
||||
))}
|
||||
{data.targetLookup.lookupKeys.length > 2 && (
|
||||
<span className="text-xs text-primary">+{data.targetLookup.lookupKeys.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 변환 규칙들 */}
|
||||
{transformationCount > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{data.transformations.slice(0, 4).map((trans, idx) => {
|
||||
const typeInfo = FORMULA_TYPE_LABELS[trans.formulaType];
|
||||
return (
|
||||
<div key={trans.id || idx} className="rounded bg-muted p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs font-medium text-white ${typeInfo.color}`}>
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{trans.outputFieldLabel || trans.outputField}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate font-mono text-xs text-muted-foreground">{getFormulaSummary(trans)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{data.transformations.length > 4 && (
|
||||
<div className="text-center text-xs text-muted-foreground/70">... 외 {data.transformations.length - 4}개</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground/70">변환 규칙 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-amber-500" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-amber-500" />
|
||||
</div>
|
||||
<CompactNodeShell
|
||||
color="#F97316"
|
||||
label={data.displayName || "수식 변환"}
|
||||
summary={summary}
|
||||
icon={<Calculator className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,124 +1,34 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* HTTP 요청 액션 노드
|
||||
* REST API를 호출하는 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Globe, Lock, Unlock } from "lucide-react";
|
||||
import type { HttpRequestActionNodeData } from "@/types/node-editor";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Send } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
|
||||
// HTTP 메서드별 색상
|
||||
const METHOD_COLORS: Record<string, { bg: string; text: string }> = {
|
||||
GET: { bg: "bg-emerald-100", text: "text-emerald-700" },
|
||||
POST: { bg: "bg-primary/10", text: "text-primary" },
|
||||
PUT: { bg: "bg-amber-100", text: "text-orange-700" },
|
||||
PATCH: { bg: "bg-amber-100", text: "text-yellow-700" },
|
||||
DELETE: { bg: "bg-destructive/10", text: "text-destructive" },
|
||||
HEAD: { bg: "bg-muted", text: "text-foreground" },
|
||||
OPTIONS: { bg: "bg-purple-100", text: "text-purple-700" },
|
||||
};
|
||||
|
||||
export const HttpRequestActionNode = memo(({ data, selected }: NodeProps<HttpRequestActionNodeData>) => {
|
||||
const methodColor = METHOD_COLORS[data.method] || METHOD_COLORS.GET;
|
||||
const hasUrl = data.url && data.url.trim().length > 0;
|
||||
const hasAuth = data.authentication?.type && data.authentication.type !== "none";
|
||||
|
||||
// URL에서 도메인 추출
|
||||
const getDomain = (url: string) => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
export const HttpRequestActionNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||
const method = data.method || "GET";
|
||||
const summary = data.url
|
||||
? `${method} ${data.url}`
|
||||
: "요청 URL을 입력해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-cyan-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
<CompactNodeShell
|
||||
color="#06B6D4"
|
||||
label={data.displayName || "HTTP 요청"}
|
||||
summary={summary}
|
||||
icon={<Send className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-3 !w-3 !border-2 !border-white !bg-cyan-500"
|
||||
/>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-cyan-500 px-3 py-2 text-white">
|
||||
<Globe className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "HTTP 요청"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="space-y-2 p-3">
|
||||
{/* 메서드 & 인증 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-bold ${methodColor.bg} ${methodColor.text}`}>
|
||||
{data.method}
|
||||
{data.url && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="rounded bg-cyan-500/20 px-1 py-0.5 font-mono text-[9px] font-semibold text-cyan-400">
|
||||
{method}
|
||||
</span>
|
||||
{hasAuth ? (
|
||||
<span className="flex items-center gap-1 rounded bg-emerald-100 px-1.5 py-0.5 text-xs text-emerald-700">
|
||||
<Lock className="h-3 w-3" />
|
||||
{data.authentication?.type}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
<Unlock className="h-3 w-3" />
|
||||
인증없음
|
||||
</span>
|
||||
)}
|
||||
<span className="break-all font-mono">{data.url}</span>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">URL: </span>
|
||||
{hasUrl ? (
|
||||
<span className="truncate text-foreground" title={data.url}>
|
||||
{getDomain(data.url)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-500">URL 설정 필요</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 바디 타입 */}
|
||||
{data.bodyType && data.bodyType !== "none" && (
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">Body: </span>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground">
|
||||
{data.bodyType.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 타임아웃 & 재시도 */}
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
{data.options?.timeout && (
|
||||
<span>타임아웃: {Math.round(data.options.timeout / 1000)}초</span>
|
||||
)}
|
||||
{data.options?.retryCount && data.options.retryCount > 0 && (
|
||||
<span>재시도: {data.options.retryCount}회</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !border-white !bg-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CompactNodeShell>
|
||||
);
|
||||
});
|
||||
|
||||
HttpRequestActionNode.displayName = "HttpRequestActionNode";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,81 +1,38 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* INSERT 액션 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Plus } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { InsertActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const InsertActionNode = memo(({ data, selected }: NodeProps<InsertActionNodeData>) => {
|
||||
const mappingCount = data.fieldMappings?.length || 0;
|
||||
const summary = data.targetTable
|
||||
? `${data.targetTable} (${mappingCount}개 필드)`
|
||||
: "대상 테이블을 선택해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-emerald-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
<CompactNodeShell
|
||||
color="#22C55E"
|
||||
label={data.displayName || "INSERT"}
|
||||
summary={summary}
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-emerald-500 !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-emerald-500 px-3 py-2 text-white">
|
||||
<Plus className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">INSERT</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
타겟: {data.displayName || data.targetTable}
|
||||
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
|
||||
<span className="ml-1 font-mono text-muted-foreground/70">({data.targetTable})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
{data.fieldMappings && data.fieldMappings.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-foreground">삽입 필드:</div>
|
||||
<div className="max-h-[120px] space-y-1 overflow-y-auto">
|
||||
{data.fieldMappings.slice(0, 4).map((mapping, idx) => (
|
||||
<div key={idx} className="rounded bg-muted px-2 py-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
|
||||
</span>
|
||||
<span className="mx-1 text-muted-foreground/70">→</span>
|
||||
<span className="font-mono text-foreground">{mapping.targetFieldLabel || mapping.targetField}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.fieldMappings.length > 4 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {data.fieldMappings.length - 4}개</div>
|
||||
)}
|
||||
{mappingCount > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{data.fieldMappings!.slice(0, 3).map((m, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<span>{m.sourceFieldLabel || m.sourceField || "?"}</span>
|
||||
<span className="text-zinc-600">→</span>
|
||||
<span className="font-mono text-zinc-300">{m.targetFieldLabel || m.targetField}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
{data.options && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{data.options.ignoreDuplicates && (
|
||||
<span className="rounded bg-emerald-100 px-1.5 py-0.5 text-xs text-emerald-700">중복 무시</span>
|
||||
)}
|
||||
{data.options.batchSize && (
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
|
||||
배치 {data.options.batchSize}건
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-emerald-500 !bg-white" />
|
||||
</div>
|
||||
))}
|
||||
{mappingCount > 3 && <span className="text-zinc-600">외 {mappingCount - 3}개</span>}
|
||||
</div>
|
||||
)}
|
||||
</CompactNodeShell>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,58 +1,24 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 로그 노드 - 디버깅 및 모니터링용
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { FileText, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
||||
import type { LogNodeData } from "@/types/node-editor";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { FileText } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
|
||||
const LOG_LEVEL_CONFIG = {
|
||||
debug: { icon: Info, color: "text-primary", bg: "bg-primary/10", border: "border-primary/20" },
|
||||
info: { icon: Info, color: "text-emerald-600", bg: "bg-emerald-50", border: "border-emerald-200" },
|
||||
warn: { icon: AlertTriangle, color: "text-amber-600", bg: "bg-amber-50", border: "border-amber-200" },
|
||||
error: { icon: AlertCircle, color: "text-destructive", bg: "bg-destructive/10", border: "border-destructive/20" },
|
||||
};
|
||||
|
||||
export const LogNode = memo(({ data, selected }: NodeProps<LogNodeData>) => {
|
||||
const config = LOG_LEVEL_CONFIG[data.level] || LOG_LEVEL_CONFIG.info;
|
||||
const Icon = config.icon;
|
||||
export const LogNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||
const summary = data.logLevel
|
||||
? `${data.logLevel} 레벨 로깅`
|
||||
: "로그를 기록해요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[200px] rounded-lg border-2 bg-white shadow-sm transition-all ${
|
||||
selected ? `${config.border} shadow-md` : "border-border"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className={`flex items-center gap-2 rounded-t-lg ${config.bg} px-3 py-2`}>
|
||||
<FileText className={`h-4 w-4 ${config.color}`} />
|
||||
<div className="flex-1">
|
||||
<div className={`text-sm font-semibold ${config.color}`}>로그</div>
|
||||
<div className="text-xs text-muted-foreground">{data.level.toUpperCase()}</div>
|
||||
</div>
|
||||
<Icon className={`h-4 w-4 ${config.color}`} />
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{data.message ? (
|
||||
<div className="text-sm text-foreground">{data.message}</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground/70">로그 메시지 없음</div>
|
||||
)}
|
||||
|
||||
{data.includeData && (
|
||||
<div className="mt-2 rounded bg-muted px-2 py-1 text-xs text-muted-foreground">✓ 데이터 포함</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-muted-foreground" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-muted-foreground" />
|
||||
</div>
|
||||
<CompactNodeShell
|
||||
color="#6B7280"
|
||||
label={data.displayName || "로그"}
|
||||
summary={summary}
|
||||
icon={<FileText className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
hasOutput={false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,121 +1,24 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 프로시저/함수 호출 액션 노드
|
||||
* 내부 또는 외부 DB의 프로시저/함수를 호출하는 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Database, Workflow } from "lucide-react";
|
||||
import type { ProcedureCallActionNodeData } from "@/types/node-editor";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Database } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
|
||||
export const ProcedureCallActionNode = memo(
|
||||
({ data, selected }: NodeProps<ProcedureCallActionNodeData>) => {
|
||||
const hasProcedure = !!data.procedureName;
|
||||
const inParams = data.parameters?.filter((p) => p.mode === "IN" || p.mode === "INOUT") ?? [];
|
||||
const outParams = data.parameters?.filter((p) => p.mode === "OUT" || p.mode === "INOUT") ?? [];
|
||||
export const ProcedureCallActionNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||
const summary = data.procedureName
|
||||
? `${data.procedureName}()`
|
||||
: "프로시저를 선택해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-violet-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-3 !w-3 !border-2 !border-white !bg-violet-500"
|
||||
/>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-violet-500 px-3 py-2 text-white">
|
||||
<Workflow className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">
|
||||
{data.displayName || "프로시저 호출"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="space-y-2 p-3">
|
||||
{/* DB 소스 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-3 w-3 text-muted-foreground/70" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{data.dbSource === "external" ? (
|
||||
<span className="rounded bg-amber-100 px-2 py-0.5 text-amber-700">
|
||||
{data.connectionName || "외부 DB"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded bg-primary/10 px-2 py-0.5 text-primary">
|
||||
내부 DB
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`ml-auto rounded px-2 py-0.5 text-xs font-medium ${
|
||||
data.callType === "function"
|
||||
? "bg-cyan-100 text-cyan-700"
|
||||
: "bg-violet-100 text-violet-700"
|
||||
}`}
|
||||
>
|
||||
{data.callType === "function" ? "FUNCTION" : "PROCEDURE"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 프로시저명 */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Workflow className="h-3 w-3 text-muted-foreground/70" />
|
||||
{hasProcedure ? (
|
||||
<span className="font-mono text-emerald-600 truncate">
|
||||
{data.procedureSchema && data.procedureSchema !== "public"
|
||||
? `${data.procedureSchema}.`
|
||||
: ""}
|
||||
{data.procedureName}()
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-500">프로시저 선택 필요</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 파라미터 수 */}
|
||||
{hasProcedure && inParams.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
입력 파라미터: {inParams.length}개
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 반환 필드 */}
|
||||
{hasProcedure && outParams.length > 0 && (
|
||||
<div className="mt-1 space-y-1 border-t border-border pt-1">
|
||||
<div className="text-[10px] font-medium text-emerald-600">
|
||||
반환 필드:
|
||||
</div>
|
||||
{outParams.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className="flex items-center justify-between rounded bg-emerald-50 px-2 py-0.5 text-[10px]"
|
||||
>
|
||||
<span className="font-mono text-emerald-700">{p.name}</span>
|
||||
<span className="text-muted-foreground/70">{p.dataType}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !border-white !bg-violet-500"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<CompactNodeShell
|
||||
color="#8B5CF6"
|
||||
label={data.displayName || "프로시저 호출"}
|
||||
summary={summary}
|
||||
icon={<Database className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
ProcedureCallActionNode.displayName = "ProcedureCallActionNode";
|
||||
|
|
|
|||
|
|
@ -1,80 +1,35 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* REST API 소스 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Globe, Lock } from "lucide-react";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Globe } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { RestAPISourceNodeData } from "@/types/node-editor";
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
GET: "bg-emerald-100 text-emerald-700",
|
||||
POST: "bg-primary/10 text-primary",
|
||||
PUT: "bg-amber-100 text-yellow-700",
|
||||
DELETE: "bg-destructive/10 text-destructive",
|
||||
PATCH: "bg-purple-100 text-purple-700",
|
||||
};
|
||||
|
||||
export const RestAPISourceNode = memo(({ data, selected }: NodeProps<RestAPISourceNodeData>) => {
|
||||
const methodColor = METHOD_COLORS[data.method] || "bg-muted text-foreground";
|
||||
const method = data.method || "GET";
|
||||
const summary = data.url
|
||||
? `${method} ${data.url}`
|
||||
: "API URL을 입력해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
<CompactNodeShell
|
||||
color="#10B981"
|
||||
label={data.displayName || "REST API"}
|
||||
summary={summary}
|
||||
icon={<Globe className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
hasInput={false}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-teal-600 px-3 py-2 text-white">
|
||||
<Globe className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "REST API"}</div>
|
||||
<div className="text-xs opacity-80">{data.url || "URL 미설정"}</div>
|
||||
{data.url && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="rounded bg-emerald-500/20 px-1 py-0.5 font-mono text-[9px] font-semibold text-emerald-400">
|
||||
{method}
|
||||
</span>
|
||||
<span className="break-all font-mono">{data.url}</span>
|
||||
</div>
|
||||
{data.authentication && <Lock className="h-4 w-4 opacity-70" />}
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{/* HTTP 메서드 */}
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className={`rounded px-2 py-1 text-xs font-semibold ${methodColor}`}>{data.method}</span>
|
||||
{data.timeout && <span className="text-xs text-muted-foreground">{data.timeout}ms</span>}
|
||||
</div>
|
||||
|
||||
{/* 헤더 */}
|
||||
{data.headers && Object.keys(data.headers).length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-medium text-foreground">헤더:</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{Object.entries(data.headers)
|
||||
.slice(0, 2)
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{key}:</span>
|
||||
<span className="truncate text-muted-foreground">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(data.headers).length > 2 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {Object.keys(data.headers).length - 2}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 응답 매핑 */}
|
||||
{data.responseMapping && (
|
||||
<div className="rounded bg-teal-50 px-2 py-1 text-xs text-teal-700">
|
||||
응답 경로: <code className="font-mono">{data.responseMapping}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-teal-500" />
|
||||
</div>
|
||||
)}
|
||||
</CompactNodeShell>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,118 +1,31 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 스크립트 실행 액션 노드
|
||||
* Python, Shell, PowerShell 등 외부 스크립트를 실행하는 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Terminal, FileCode, Play } from "lucide-react";
|
||||
import type { ScriptActionNodeData } from "@/types/node-editor";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Terminal } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
|
||||
// 스크립트 타입별 아이콘 색상
|
||||
const SCRIPT_TYPE_COLORS: Record<string, { bg: string; text: string; label: string }> = {
|
||||
python: { bg: "bg-amber-100", text: "text-yellow-700", label: "Python" },
|
||||
shell: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Shell" },
|
||||
powershell: { bg: "bg-primary/10", text: "text-primary", label: "PowerShell" },
|
||||
node: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Node.js" },
|
||||
executable: { bg: "bg-muted", text: "text-foreground", label: "실행파일" },
|
||||
};
|
||||
|
||||
export const ScriptActionNode = memo(({ data, selected }: NodeProps<ScriptActionNodeData>) => {
|
||||
const scriptTypeInfo = SCRIPT_TYPE_COLORS[data.scriptType] || SCRIPT_TYPE_COLORS.executable;
|
||||
const hasScript = data.executionMode === "inline" ? !!data.inlineScript : !!data.scriptPath;
|
||||
export const ScriptActionNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||
const scriptType = data.scriptType || "python";
|
||||
const summary = data.inlineScript
|
||||
? `${scriptType} 스크립트 (${data.inlineScript.split("\n").length}줄)`
|
||||
: "스크립트를 작성해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-emerald-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
<CompactNodeShell
|
||||
color="#10B981"
|
||||
label={data.displayName || "스크립트 실행"}
|
||||
summary={summary}
|
||||
icon={<Terminal className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-3 !w-3 !border-2 !border-white !bg-emerald-500"
|
||||
/>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-emerald-500 px-3 py-2 text-white">
|
||||
<Terminal className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "스크립트 실행"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="space-y-2 p-3">
|
||||
{/* 스크립트 타입 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${scriptTypeInfo.bg} ${scriptTypeInfo.text}`}>
|
||||
{scriptTypeInfo.label}
|
||||
</span>
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{data.executionMode === "inline" ? "인라인" : "파일"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 스크립트 정보 */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{data.executionMode === "inline" ? (
|
||||
<>
|
||||
<FileCode className="h-3 w-3 text-muted-foreground/70" />
|
||||
<span className="text-muted-foreground">
|
||||
{hasScript ? (
|
||||
<span className="text-emerald-600">
|
||||
{data.inlineScript!.split("\n").length}줄 스크립트
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-500">스크립트 입력 필요</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-3 w-3 text-muted-foreground/70" />
|
||||
<span className="text-muted-foreground">
|
||||
{hasScript ? (
|
||||
<span className="truncate text-emerald-600">{data.scriptPath}</span>
|
||||
) : (
|
||||
<span className="text-amber-500">파일 경로 필요</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 입력 방식 */}
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">입력: </span>
|
||||
<span className="text-foreground">
|
||||
{data.inputMethod === "stdin" && "표준입력 (stdin)"}
|
||||
{data.inputMethod === "args" && "명령줄 인자"}
|
||||
{data.inputMethod === "env" && "환경변수"}
|
||||
{data.inputMethod === "file" && "파일"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 타임아웃 */}
|
||||
{data.options?.timeout && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
타임아웃: {Math.round(data.options.timeout / 1000)}초
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !border-white !bg-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
{data.scriptType && (
|
||||
<span className="rounded bg-emerald-500/20 px-1 py-0.5 font-mono text-[9px] font-semibold text-emerald-400">
|
||||
{scriptType}
|
||||
</span>
|
||||
)}
|
||||
</CompactNodeShell>
|
||||
);
|
||||
});
|
||||
|
||||
ScriptActionNode.displayName = "ScriptActionNode";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,69 +1,40 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 테이블 소스 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Database } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { TableSourceNodeData } from "@/types/node-editor";
|
||||
|
||||
export const TableSourceNode = memo(({ data, selected }: NodeProps<TableSourceNodeData>) => {
|
||||
// 디버깅: 필드 데이터 확인
|
||||
if (data.fields && data.fields.length > 0) {
|
||||
console.log("🔍 TableSource 필드 데이터:", data.fields);
|
||||
}
|
||||
const fieldCount = data.fields?.length || 0;
|
||||
const summary = data.tableName
|
||||
? `${data.tableName} (${fieldCount}개 필드)`
|
||||
: "테이블을 선택해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-primary shadow-lg" : "border-border"
|
||||
}`}
|
||||
<CompactNodeShell
|
||||
color="#3B82F6"
|
||||
label={data.displayName || data.tableName || "테이블 소스"}
|
||||
summary={summary}
|
||||
icon={<Database className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
hasInput={false}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-primary px-3 py-2 text-white">
|
||||
<Database className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || data.tableName || "테이블 소스"}</div>
|
||||
{data.tableName && data.displayName !== data.tableName && (
|
||||
<div className="text-xs opacity-80">{data.tableName}</div>
|
||||
{fieldCount > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{data.fields!.slice(0, 4).map((f) => (
|
||||
<div key={f.name} className="flex items-center gap-1.5">
|
||||
<div className="h-1 w-1 flex-shrink-0 rounded-full bg-blue-400" />
|
||||
<span>{f.label || f.displayName || f.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{fieldCount > 4 && (
|
||||
<span className="text-zinc-600">외 {fieldCount - 4}개</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">📍 내부 데이터베이스</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-foreground">출력 필드:</div>
|
||||
<div className="max-h-[150px] overflow-y-auto">
|
||||
{data.fields && data.fields.length > 0 ? (
|
||||
data.fields.slice(0, 5).map((field) => (
|
||||
<div key={field.name} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary/70" />
|
||||
<span className="font-medium">{field.label || field.displayName || field.name}</span>
|
||||
{(field.label || field.displayName) && field.label !== field.name && (
|
||||
<span className="font-mono text-muted-foreground/70">({field.name})</span>
|
||||
)}
|
||||
<span className="text-muted-foreground/70">{field.type}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground/70">필드 없음</div>
|
||||
)}
|
||||
{data.fields && data.fields.length > 5 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {data.fields.length - 5}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-primary !bg-white" />
|
||||
</div>
|
||||
)}
|
||||
</CompactNodeShell>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,97 +1,26 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UPDATE 액션 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Edit } from "lucide-react";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { UpdateActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const UpdateActionNode = memo(({ data, selected }: NodeProps<UpdateActionNodeData>) => {
|
||||
const mappingCount = data.fieldMappings?.length || 0;
|
||||
const whereCount = data.whereConditions?.length || 0;
|
||||
const summary = data.targetTable
|
||||
? `${data.targetTable} (${mappingCount}개 필드, ${whereCount}개 조건)`
|
||||
: "대상 테이블을 선택해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-primary shadow-lg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-primary !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-primary px-3 py-2 text-white">
|
||||
<Edit className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">UPDATE</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
타겟: {data.displayName || data.targetTable}
|
||||
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
|
||||
<span className="ml-1 font-mono text-muted-foreground/70">({data.targetTable})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
{data.whereConditions && data.whereConditions.length > 0 && (
|
||||
<div className="mb-3 space-y-1">
|
||||
<div className="text-xs font-medium text-foreground">WHERE 조건:</div>
|
||||
<div className="max-h-[80px] space-y-1 overflow-y-auto">
|
||||
{data.whereConditions.slice(0, 2).map((condition, idx) => (
|
||||
<div key={idx} className="rounded bg-primary/10 px-2 py-1 text-xs">
|
||||
<span className="font-mono text-foreground">{condition.fieldLabel || condition.field}</span>
|
||||
<span className="mx-1 text-primary">{condition.operator}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{condition.sourceFieldLabel || condition.sourceField || condition.staticValue || "?"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{data.whereConditions.length > 2 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {data.whereConditions.length - 2}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
{data.fieldMappings && data.fieldMappings.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-foreground">업데이트 필드:</div>
|
||||
<div className="max-h-[100px] space-y-1 overflow-y-auto">
|
||||
{data.fieldMappings.slice(0, 3).map((mapping, idx) => (
|
||||
<div key={idx} className="rounded bg-muted px-2 py-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
|
||||
</span>
|
||||
<span className="mx-1 text-muted-foreground/70">→</span>
|
||||
<span className="font-mono text-foreground">{mapping.targetFieldLabel || mapping.targetField}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.fieldMappings.length > 3 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {data.fieldMappings.length - 3}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
{data.options && data.options.batchSize && (
|
||||
<div className="mt-2">
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
|
||||
배치 {data.options.batchSize}건
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-primary !bg-white" />
|
||||
</div>
|
||||
<CompactNodeShell
|
||||
color="#3B82F6"
|
||||
label={data.displayName || "UPDATE"}
|
||||
summary={summary}
|
||||
icon={<Pencil className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,93 +1,26 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UPSERT 액션 노드
|
||||
* INSERT와 UPDATE를 결합한 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Database, RefreshCw } from "lucide-react";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { CompactNodeShell } from "./CompactNodeShell";
|
||||
import type { UpsertActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const UpsertActionNode = memo(({ data, selected }: NodeProps<UpsertActionNodeData>) => {
|
||||
const mappingCount = data.fieldMappings?.length || 0;
|
||||
const conflictCount = data.conflictKeys?.length || 0;
|
||||
const summary = data.targetTable
|
||||
? `${data.targetTable} (${mappingCount}개 필드, ${conflictCount}개 키)`
|
||||
: "대상 테이블을 선택해 주세요";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-purple-600 px-3 py-2 text-white">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "UPSERT 액션"}</div>
|
||||
<div className="text-xs opacity-80">{data.targetTable}</div>
|
||||
</div>
|
||||
<Database className="h-4 w-4 opacity-70" />
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
타겟: {data.displayName || data.targetTable}
|
||||
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
|
||||
<span className="ml-1 font-mono text-muted-foreground/70">({data.targetTable})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 충돌 키 */}
|
||||
{data.conflictKeys && data.conflictKeys.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-medium text-foreground">충돌 키:</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{data.conflictKeys.map((key, idx) => (
|
||||
<span key={idx} className="rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-700">
|
||||
{data.conflictKeyLabels?.[idx] || key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
{data.fieldMappings && data.fieldMappings.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-medium text-foreground">필드 매핑:</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{data.fieldMappings.slice(0, 3).map((mapping, idx) => (
|
||||
<div key={idx} className="rounded bg-muted px-2 py-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
|
||||
</span>
|
||||
<span className="mx-1 text-muted-foreground/70">→</span>
|
||||
<span className="font-mono text-foreground">{mapping.targetFieldLabel || mapping.targetField}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.fieldMappings.length > 3 && (
|
||||
<div className="text-xs text-muted-foreground/70">... 외 {data.fieldMappings.length - 3}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{data.options?.updateOnConflict && (
|
||||
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs text-primary">충돌 시 업데이트</span>
|
||||
)}
|
||||
{data.options?.batchSize && (
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
배치: {data.options.batchSize}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-purple-500" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-purple-500" />
|
||||
</div>
|
||||
<CompactNodeShell
|
||||
color="#8B5CF6"
|
||||
label={data.displayName || "UPSERT"}
|
||||
summary={summary}
|
||||
icon={<RefreshCw className="h-3.5 w-3.5" />}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@
|
|||
* 노드 속성 편집 패널
|
||||
*/
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { TableSourceProperties } from "./properties/TableSourceProperties";
|
||||
import { InsertActionProperties } from "./properties/InsertActionProperties";
|
||||
|
|
@ -29,70 +27,32 @@ import type { NodeType } from "@/types/node-editor";
|
|||
export function PropertiesPanel() {
|
||||
const { nodes, selectedNodes, setShowPropertiesPanel } = useFlowEditorStore();
|
||||
|
||||
// 선택된 노드가 하나일 경우 해당 노드 데이터 가져오기
|
||||
const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: "64px",
|
||||
}}
|
||||
className="flex items-center justify-between border-b bg-white p-4"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">속성</h3>
|
||||
{selectedNode && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{getNodeTypeLabel(selectedNode.type as NodeType)}</p>
|
||||
)}
|
||||
if (selectedNodes.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<p>노드를 선택해 주세요</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowPropertiesPanel(false)} className="h-6 w-6 p-0">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* 내용 - 스크롤 가능 영역 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
}}
|
||||
>
|
||||
{selectedNodes.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<div className="mb-2 text-2xl">📝</div>
|
||||
<p>노드를 선택하여</p>
|
||||
<p>속성을 편집하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedNodes.length === 1 && selectedNode ? (
|
||||
<NodePropertiesRenderer node={selectedNode} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<div className="mb-2 text-2xl">📋</div>
|
||||
<p>{selectedNodes.length}개의 노드가</p>
|
||||
<p>선택되었습니다</p>
|
||||
<p className="mt-2 text-xs">한 번에 하나의 노드만 편집할 수 있습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
if (selectedNodes.length > 1) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<p>{selectedNodes.length}개의 노드가 선택됐어요</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">하나의 노드만 선택하면 속성을 편집할 수 있어요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedNode) return null;
|
||||
|
||||
return <NodePropertiesRenderer node={selectedNode} />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -155,14 +115,10 @@ function NodePropertiesRenderer({ node }: { node: any }) {
|
|||
return (
|
||||
<div className="p-4">
|
||||
<div className="rounded border border-amber-200 bg-amber-50 p-4 text-sm">
|
||||
<p className="font-medium text-yellow-800">🚧 속성 편집 준비 중</p>
|
||||
<p className="mt-2 text-xs text-yellow-700">
|
||||
{getNodeTypeLabel(node.type as NodeType)} 노드의 속성 편집 UI는 곧 구현될 예정입니다.
|
||||
<p className="font-medium text-amber-800">속성 편집 준비 중이에요</p>
|
||||
<p className="mt-2 text-xs text-amber-700">
|
||||
{getNodeTypeLabel(node.type as NodeType)} 노드의 속성 편집은 곧 지원될 예정이에요.
|
||||
</p>
|
||||
<div className="mt-3 rounded bg-white p-2 text-xs">
|
||||
<p className="font-medium text-foreground">노드 ID:</p>
|
||||
<p className="font-mono text-muted-foreground">{node.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,16 +5,29 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Save, ListOrdered } from "lucide-react";
|
||||
import { Plus, Save, Search, Hash, Table2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
|
||||
import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule";
|
||||
import { NumberingRuleCard } from "./NumberingRuleCard";
|
||||
import { NumberingRulePreview, computePartDisplayItems, getPartTypeColorClass } from "./NumberingRulePreview";
|
||||
import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
||||
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NumberingColumn {
|
||||
tableName: string;
|
||||
tableLabel: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
}
|
||||
|
||||
interface GroupedColumns {
|
||||
tableLabel: string;
|
||||
columns: NumberingColumn[];
|
||||
}
|
||||
|
||||
interface NumberingRuleDesignerProps {
|
||||
initialConfig?: NumberingRuleConfig;
|
||||
onSave?: (config: NumberingRuleConfig) => void;
|
||||
|
|
@ -36,64 +49,95 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
currentTableName,
|
||||
menuObjid,
|
||||
}) => {
|
||||
const [rulesList, setRulesList] = useState<NumberingRuleConfig[]>([]);
|
||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
|
||||
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
|
||||
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
||||
const [selectedPartOrder, setSelectedPartOrder] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [columnSearch, setColumnSearch] = useState("");
|
||||
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
|
||||
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
||||
|
||||
const selectedRule = rulesList.find((r) => r.ruleId === selectedRuleId) ?? currentRule;
|
||||
|
||||
// 좌측: 규칙 목록 로드
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
loadNumberingColumns();
|
||||
}, []);
|
||||
|
||||
const loadRules = async () => {
|
||||
const loadNumberingColumns = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getNumberingRules();
|
||||
if (response.success && response.data) {
|
||||
setRulesList(response.data);
|
||||
if (response.data.length > 0 && !selectedRuleId) {
|
||||
const first = response.data[0];
|
||||
setSelectedRuleId(first.ruleId);
|
||||
setCurrentRule(JSON.parse(JSON.stringify(first)));
|
||||
}
|
||||
const response = await apiClient.get("/table-management/numbering-columns");
|
||||
if (response.data.success && response.data.data) {
|
||||
setNumberingColumns(response.data.data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("채번 규칙 목록 로드 실패:", e);
|
||||
} catch (error: any) {
|
||||
console.error("채번 컬럼 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRule = (rule: NumberingRuleConfig) => {
|
||||
setSelectedRuleId(rule.ruleId);
|
||||
setCurrentRule(JSON.parse(JSON.stringify(rule)));
|
||||
const handleSelectColumn = async (tableName: string, columnName: string) => {
|
||||
setSelectedColumn({ tableName, columnName });
|
||||
setSelectedPartOrder(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
|
||||
if (response.data.success && response.data.data) {
|
||||
const rule = response.data.data as NumberingRuleConfig;
|
||||
setCurrentRule(JSON.parse(JSON.stringify(rule)));
|
||||
} else {
|
||||
const newRule: NumberingRuleConfig = {
|
||||
ruleId: `rule-${Date.now()}`,
|
||||
ruleName: `${columnName} 채번`,
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
scopeType: "table",
|
||||
tableName,
|
||||
columnName,
|
||||
};
|
||||
setCurrentRule(newRule);
|
||||
}
|
||||
} catch {
|
||||
const newRule: NumberingRuleConfig = {
|
||||
ruleId: `rule-${Date.now()}`,
|
||||
ruleName: `${columnName} 채번`,
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
scopeType: "table",
|
||||
tableName,
|
||||
columnName,
|
||||
};
|
||||
setCurrentRule(newRule);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNewRule = () => {
|
||||
const newRule: NumberingRuleConfig = {
|
||||
ruleId: `rule-${Date.now()}`,
|
||||
ruleName: "새 규칙",
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
scopeType: "global",
|
||||
tableName: currentTableName ?? "",
|
||||
columnName: "",
|
||||
};
|
||||
setRulesList((prev) => [...prev, newRule]);
|
||||
setSelectedRuleId(newRule.ruleId);
|
||||
setCurrentRule(JSON.parse(JSON.stringify(newRule)));
|
||||
setSelectedPartOrder(null);
|
||||
toast.success("새 규칙이 추가되었습니다");
|
||||
};
|
||||
// 테이블별 그룹화
|
||||
const groupedColumns = numberingColumns.reduce<Record<string, GroupedColumns>>((acc, col) => {
|
||||
if (!acc[col.tableName]) {
|
||||
acc[col.tableName] = { tableLabel: col.tableLabel, columns: [] };
|
||||
}
|
||||
acc[col.tableName].columns.push(col);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 검색 필터
|
||||
const filteredGroups = Object.entries(groupedColumns).filter(([tableName, group]) => {
|
||||
if (!columnSearch) return true;
|
||||
const search = columnSearch.toLowerCase();
|
||||
return (
|
||||
tableName.toLowerCase().includes(search) ||
|
||||
group.tableLabel.toLowerCase().includes(search) ||
|
||||
group.columns.some(
|
||||
(c) => c.columnName.toLowerCase().includes(search) || c.columnLabel.toLowerCase().includes(search)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (currentRule) onChange?.(currentRule);
|
||||
|
|
@ -225,24 +269,14 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
const ruleToSave = {
|
||||
...currentRule,
|
||||
parts: partsWithDefaults,
|
||||
scopeType: "global" as const,
|
||||
tableName: currentRule.tableName || currentTableName || "",
|
||||
columnName: currentRule.columnName || "",
|
||||
scopeType: "table" as const,
|
||||
tableName: selectedColumn?.tableName || currentRule.tableName || "",
|
||||
columnName: selectedColumn?.columnName || currentRule.columnName || "",
|
||||
};
|
||||
const response = await saveNumberingRuleToTest(ruleToSave);
|
||||
if (response.success && response.data) {
|
||||
const saved: NumberingRuleConfig = JSON.parse(JSON.stringify(response.data));
|
||||
setCurrentRule(saved);
|
||||
setRulesList((prev) => {
|
||||
const idx = prev.findIndex((r) => r.ruleId === currentRule.ruleId);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = saved;
|
||||
return next;
|
||||
}
|
||||
return [...prev, saved];
|
||||
});
|
||||
setSelectedRuleId(saved.ruleId);
|
||||
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
|
||||
setCurrentRule(currentData);
|
||||
await onSave?.(response.data);
|
||||
toast.success("채번 규칙이 저장되었습니다");
|
||||
} else {
|
||||
|
|
@ -257,7 +291,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentRule, onSave, currentTableName]);
|
||||
}, [currentRule, onSave, selectedColumn]);
|
||||
|
||||
const selectedPart = currentRule?.parts.find((p) => p.order === selectedPartOrder) ?? null;
|
||||
const globalSep = currentRule?.separator ?? "-";
|
||||
|
|
@ -265,92 +299,118 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
|
||||
return (
|
||||
<div className={cn("flex h-full", className)}>
|
||||
{/* 좌측: 규칙 리스트 (code-nav, 220px) */}
|
||||
<div className="code-nav flex w-[220px] flex-shrink-0 flex-col border-r border-border">
|
||||
<div className="code-nav-head flex items-center justify-between gap-2 border-b border-border px-3 py-2.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<ListOrdered className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs font-bold">채번 규칙 ({rulesList.length})</span>
|
||||
{/* 좌측: 채번 컬럼 목록 (테이블별 그룹화) */}
|
||||
<div className="code-nav flex w-[240px] flex-shrink-0 flex-col border-r border-border">
|
||||
<div className="code-nav-head flex flex-col gap-2 border-b border-border px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-xs font-bold">채번 컬럼 ({numberingColumns.length})</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={columnSearch}
|
||||
onChange={(e) => setColumnSearch(e.target.value)}
|
||||
placeholder="검색..."
|
||||
className="h-7 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-8 shrink-0 gap-1 text-xs font-medium"
|
||||
onClick={handleAddNewRule}
|
||||
disabled={isPreview || loading}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="code-nav-list flex-1 overflow-y-auto">
|
||||
{loading && rulesList.length === 0 ? (
|
||||
{loading && numberingColumns.length === 0 ? (
|
||||
<div className="flex h-24 items-center justify-center text-xs text-muted-foreground">
|
||||
로딩 중...
|
||||
</div>
|
||||
) : rulesList.length === 0 ? (
|
||||
<div className="flex h-24 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50 text-xs text-muted-foreground">
|
||||
규칙이 없습니다
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="flex h-24 flex-col items-center justify-center gap-1 px-3 text-center text-xs text-muted-foreground">
|
||||
<Hash className="h-6 w-6" />
|
||||
{numberingColumns.length === 0
|
||||
? "채번 타입 컬럼이 없습니다"
|
||||
: "검색 결과 없음"}
|
||||
</div>
|
||||
) : (
|
||||
rulesList.map((rule) => {
|
||||
const isSelected = selectedRuleId === rule.ruleId;
|
||||
return (
|
||||
<button
|
||||
key={rule.ruleId}
|
||||
type="button"
|
||||
className={cn(
|
||||
"code-nav-item flex w-full items-center gap-2 border-b border-border/50 px-3 py-2 text-left transition-colors",
|
||||
isSelected
|
||||
? "border-l-[3px] border-primary bg-primary/5 pl-2.5 font-bold"
|
||||
: "hover:bg-accent"
|
||||
)}
|
||||
onClick={() => handleSelectRule(rule)}
|
||||
>
|
||||
<span className="rule-name min-w-0 flex-1 truncate text-xs font-semibold">
|
||||
{rule.ruleName}
|
||||
filteredGroups.map(([tableName, group]) => (
|
||||
<div key={tableName}>
|
||||
<div className="flex items-center gap-1.5 bg-muted/50 px-3 py-1.5">
|
||||
<Table2 className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="truncate text-[10px] font-bold text-muted-foreground">
|
||||
{group.tableLabel || tableName}
|
||||
</span>
|
||||
<span className="rule-table max-w-[70px] shrink-0 truncate text-[9px] text-muted-foreground">
|
||||
{rule.tableName || "-"}
|
||||
</span>
|
||||
<span className="rule-parts shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[8px] font-bold text-muted-foreground">
|
||||
{rule.parts?.length ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
</div>
|
||||
{group.columns.map((col) => {
|
||||
const isSelected =
|
||||
selectedColumn?.tableName === col.tableName &&
|
||||
selectedColumn?.columnName === col.columnName;
|
||||
return (
|
||||
<button
|
||||
key={`${col.tableName}.${col.columnName}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 border-b border-border/30 px-3 py-2 text-left transition-colors",
|
||||
isSelected
|
||||
? "border-l-[3px] border-l-primary bg-primary/5 pl-2.5 font-bold"
|
||||
: "pl-5 hover:bg-accent"
|
||||
)}
|
||||
onClick={() => handleSelectColumn(col.tableName, col.columnName)}
|
||||
>
|
||||
<Hash className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-semibold">
|
||||
{col.columnLabel || col.columnName}
|
||||
</div>
|
||||
<div className="truncate text-[9px] text-muted-foreground">
|
||||
{col.columnName}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 (code-main) */}
|
||||
{/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 */}
|
||||
<div className="code-main flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{!currentRule ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center text-center">
|
||||
<ListOrdered className="mb-3 h-10 w-10 text-muted-foreground" />
|
||||
<p className="mb-2 text-lg font-medium text-muted-foreground">규칙을 선택하세요</p>
|
||||
<Hash className="mb-3 h-10 w-10 text-muted-foreground" />
|
||||
<p className="mb-2 text-lg font-medium text-muted-foreground">컬럼을 선택하세요</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
좌측에서 채번 규칙을 선택하거나 "추가"로 새 규칙을 만드세요
|
||||
좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 헤더: 규칙명 + 적용 대상 표시 */}
|
||||
<div className="flex flex-col gap-2 px-6 pt-4">
|
||||
<Label className="text-xs font-medium">규칙명</Label>
|
||||
<Input
|
||||
value={currentRule.ruleName}
|
||||
onChange={(e) => setCurrentRule((prev) => (prev ? { ...prev, ruleName: e.target.value } : null))}
|
||||
placeholder="예: 프로젝트 코드"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs font-medium">규칙명</Label>
|
||||
<Input
|
||||
value={currentRule.ruleName}
|
||||
onChange={(e) => setCurrentRule((prev) => (prev ? { ...prev, ruleName: e.target.value } : null))}
|
||||
placeholder="예: 프로젝트 코드"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{selectedColumn && (
|
||||
<div className="flex-shrink-0 pt-4">
|
||||
<span className="rounded bg-muted px-2 py-1 text-[10px] font-medium text-muted-foreground">
|
||||
{selectedColumn.tableName}.{selectedColumn.columnName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 큰 미리보기 스트립 (code-preview-strip) */}
|
||||
{/* 미리보기 스트립 */}
|
||||
<div className="code-preview-strip flex-shrink-0 border-b border-border px-6 py-5">
|
||||
<NumberingRulePreview config={currentRule} variant="strip" />
|
||||
</div>
|
||||
|
||||
{/* 파이프라인 영역 (code-pipeline-area) */}
|
||||
{/* 파이프라인 영역 */}
|
||||
<div className="code-pipeline-area flex flex-col gap-3 border-b border-border px-6 py-5">
|
||||
<div className="area-label flex items-center gap-1.5">
|
||||
<span className="text-xs font-bold">코드 구성</span>
|
||||
|
|
@ -360,15 +420,21 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
</div>
|
||||
<div className="code-pipeline flex flex-1 flex-wrap items-center gap-0 overflow-x-auto overflow-y-hidden pb-2">
|
||||
{currentRule.parts.length === 0 ? (
|
||||
<div className="flex h-24 min-w-[200px] items-center justify-center rounded-xl border-2 border-dashed border-border bg-muted/30 text-xs text-muted-foreground">
|
||||
규칙을 추가하여 코드를 구성하세요
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-24 min-w-[200px] flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border bg-muted/30 text-xs text-muted-foreground transition-colors hover:border-primary hover:bg-primary/5 hover:text-primary"
|
||||
onClick={handleAddPart}
|
||||
disabled={isPreview || loading}
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
클릭하여 첫 번째 규칙을 추가하세요
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{currentRule.parts.map((part, index) => {
|
||||
const item = partItems.find((i) => i.order === part.order);
|
||||
const sep = part.separatorAfter ?? globalSep;
|
||||
const isSelected = selectedPartOrder === part.order;
|
||||
const isPartSelected = selectedPartOrder === part.order;
|
||||
const typeLabel = CODE_PART_TYPE_OPTIONS.find((o) => o.value === part.partType)?.label ?? part.partType;
|
||||
return (
|
||||
<React.Fragment key={`part-${part.order}-${index}`}>
|
||||
|
|
@ -380,7 +446,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
part.partType === "text" && "border-primary",
|
||||
part.partType === "sequence" && "border-primary",
|
||||
(part.partType === "number" || part.partType === "category" || part.partType === "reference") && "border-border",
|
||||
isSelected && "border-primary bg-primary/5 shadow-md ring-2 ring-primary/30"
|
||||
isPartSelected && "border-primary bg-primary/5 shadow-md ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={() => setSelectedPartOrder(part.order)}
|
||||
>
|
||||
|
|
@ -416,7 +482,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설정 패널 (선택된 세그먼트 상세, code-config-panel) */}
|
||||
{/* 설정 패널 */}
|
||||
{selectedPart && (
|
||||
<div className="code-config-panel min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
<div className="code-config-grid grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
|
||||
|
|
@ -460,7 +526,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 저장 바 (code-save-bar) */}
|
||||
{/* 저장 바 */}
|
||||
<div className="code-save-bar flex flex-shrink-0 items-center justify-between gap-4 border-t border-border bg-muted/30 px-6 py-4">
|
||||
<div className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||
{currentRule.tableName && (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export type BatchExecutionType = "mapping" | "node_flow";
|
||||
|
||||
export interface BatchConfig {
|
||||
id?: number;
|
||||
batch_name: string;
|
||||
|
|
@ -10,14 +12,55 @@ export interface BatchConfig {
|
|||
cron_schedule: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
save_mode?: 'INSERT' | 'UPSERT'; // 저장 모드 (기본: INSERT)
|
||||
conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명
|
||||
auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명
|
||||
save_mode?: 'INSERT' | 'UPSERT';
|
||||
conflict_key?: string;
|
||||
auth_service_name?: string;
|
||||
execution_type?: BatchExecutionType;
|
||||
node_flow_id?: number;
|
||||
node_flow_context?: Record<string, any>;
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
updated_date?: Date;
|
||||
updated_by?: string;
|
||||
batch_mappings?: BatchMapping[];
|
||||
last_status?: string;
|
||||
last_executed_at?: string;
|
||||
last_total_records?: number;
|
||||
}
|
||||
|
||||
export interface NodeFlowInfo {
|
||||
flow_id: number;
|
||||
flow_name: string;
|
||||
description?: string;
|
||||
company_code?: string;
|
||||
node_count: number;
|
||||
}
|
||||
|
||||
export interface BatchStats {
|
||||
totalBatches: number;
|
||||
activeBatches: number;
|
||||
todayExecutions: number;
|
||||
todayFailures: number;
|
||||
prevDayExecutions: number;
|
||||
prevDayFailures: number;
|
||||
}
|
||||
|
||||
export interface SparklineData {
|
||||
hour: string;
|
||||
success: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface RecentLog {
|
||||
id: number;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
status: string;
|
||||
total_records: number;
|
||||
success_records: number;
|
||||
failed_records: number;
|
||||
error_message: string | null;
|
||||
duration_ms: number | null;
|
||||
}
|
||||
|
||||
export interface BatchMapping {
|
||||
|
|
@ -97,6 +140,9 @@ export interface BatchMappingRequest {
|
|||
cronSchedule: string;
|
||||
mappings: BatchMapping[];
|
||||
isActive?: boolean;
|
||||
executionType?: BatchExecutionType;
|
||||
nodeFlowId?: number;
|
||||
nodeFlowContext?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
|
|
@ -192,7 +238,7 @@ export class BatchAPI {
|
|||
static async createBatchConfig(data: BatchMappingRequest): Promise<BatchConfig> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<BatchConfig>>(
|
||||
`/batch-configs`,
|
||||
`/batch-management/batch-configs`,
|
||||
data,
|
||||
);
|
||||
|
||||
|
|
@ -462,4 +508,76 @@ export class BatchAPI {
|
|||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 플로우 목록 조회 (배치 설정 시 플로우 선택용)
|
||||
*/
|
||||
static async getNodeFlows(): Promise<NodeFlowInfo[]> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<NodeFlowInfo[]>>(
|
||||
`/batch-management/node-flows`
|
||||
);
|
||||
if (response.data.success) {
|
||||
return response.data.data || [];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("노드 플로우 목록 조회 오류:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 대시보드 통계 조회
|
||||
*/
|
||||
static async getBatchStats(): Promise<BatchStats | null> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<BatchStats>>(
|
||||
`/batch-management/stats`
|
||||
);
|
||||
if (response.data.success) {
|
||||
return response.data.data || null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("배치 통계 조회 오류:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치별 최근 24시간 스파크라인 데이터
|
||||
*/
|
||||
static async getBatchSparkline(batchId: number): Promise<SparklineData[]> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<SparklineData[]>>(
|
||||
`/batch-management/batch-configs/${batchId}/sparkline`
|
||||
);
|
||||
if (response.data.success) {
|
||||
return response.data.data || [];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("스파크라인 조회 오류:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치별 최근 실행 로그
|
||||
*/
|
||||
static async getBatchRecentLogs(batchId: number, limit: number = 5): Promise<RecentLog[]> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<RecentLog[]>>(
|
||||
`/batch-management/batch-configs/${batchId}/recent-logs?limit=${limit}`
|
||||
);
|
||||
if (response.data.success) {
|
||||
return response.data.data || [];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("최근 실행 로그 조회 오류:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue