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) {
|
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { batchName, description, cronSchedule, mappings, isActive } =
|
const {
|
||||||
req.body;
|
batchName, description, cronSchedule, mappings, isActive,
|
||||||
|
executionType, nodeFlowId, nodeFlowContext,
|
||||||
|
} = req.body;
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
if (
|
if (!batchName || !cronSchedule) {
|
||||||
!batchName ||
|
|
||||||
!cronSchedule ||
|
|
||||||
!mappings ||
|
|
||||||
!Array.isArray(mappings)
|
|
||||||
) {
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message:
|
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)",
|
||||||
"필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const batchConfig = await BatchService.createBatchConfig({
|
// 노드 플로우 타입은 매핑 없이 생성 가능
|
||||||
|
if (executionType !== "node_flow" && (!mappings || !Array.isArray(mappings))) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "매핑 타입은 mappings 배열이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchConfig = await BatchService.createBatchConfig(
|
||||||
|
{
|
||||||
batchName,
|
batchName,
|
||||||
description,
|
description,
|
||||||
cronSchedule,
|
cronSchedule,
|
||||||
mappings,
|
mappings: mappings || [],
|
||||||
isActive: isActive !== undefined ? isActive : true,
|
isActive: isActive === false || isActive === "N" ? "N" : "Y",
|
||||||
} as CreateBatchConfigRequest);
|
companyCode: companyCode || "",
|
||||||
|
executionType: executionType || "mapping",
|
||||||
|
nodeFlowId: nodeFlowId || null,
|
||||||
|
nodeFlowContext: nodeFlowContext || null,
|
||||||
|
} as CreateBatchConfigRequest,
|
||||||
|
req.user?.userId
|
||||||
|
);
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
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
|
* GET /api/batch-management/stats
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,12 @@ const router = Router();
|
||||||
*/
|
*/
|
||||||
router.get("/stats", authenticateToken, BatchManagementController.getBatchStats);
|
router.get("/stats", authenticateToken, BatchManagementController.getBatchStats);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/batch-management/node-flows
|
||||||
|
* 배치 설정에서 노드 플로우 선택용 목록 조회
|
||||||
|
*/
|
||||||
|
router.get("/node-flows", authenticateToken, BatchManagementController.getNodeFlows);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/batch-management/connections
|
* GET /api/batch-management/connections
|
||||||
* 사용 가능한 커넥션 목록 조회
|
* 사용 가능한 커넥션 목록 조회
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,54 @@ import { auditLogService, getClientIp } from "../../services/auditLogService";
|
||||||
const router = Router();
|
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) => {
|
router.get("/", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -24,6 +71,7 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
flow_id as "flowId",
|
flow_id as "flowId",
|
||||||
flow_name as "flowName",
|
flow_name as "flowName",
|
||||||
flow_description as "flowDescription",
|
flow_description as "flowDescription",
|
||||||
|
flow_data as "flowData",
|
||||||
company_code as "companyCode",
|
company_code as "companyCode",
|
||||||
created_at as "createdAt",
|
created_at as "createdAt",
|
||||||
updated_at as "updatedAt"
|
updated_at as "updatedAt"
|
||||||
|
|
@ -32,7 +80,6 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
|
|
||||||
// 슈퍼 관리자가 아니면 회사별 필터링
|
|
||||||
if (userCompanyCode && userCompanyCode !== "*") {
|
if (userCompanyCode && userCompanyCode !== "*") {
|
||||||
sqlQuery += ` WHERE company_code = $1`;
|
sqlQuery += ` WHERE company_code = $1`;
|
||||||
params.push(userCompanyCode);
|
params.push(userCompanyCode);
|
||||||
|
|
@ -42,9 +89,15 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
|
||||||
const flows = await query(sqlQuery, params);
|
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({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: flows,
|
data: flowsWithSummary,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("플로우 목록 조회 실패:", error);
|
logger.error("플로우 목록 조회 실패:", error);
|
||||||
|
|
|
||||||
|
|
@ -122,22 +122,24 @@ export class BatchSchedulerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 배치 설정 실행
|
* 배치 설정 실행 - execution_type에 따라 매핑 또는 노드 플로우 실행
|
||||||
*/
|
*/
|
||||||
static async executeBatchConfig(config: any) {
|
static async executeBatchConfig(config: any) {
|
||||||
const startTime = new Date();
|
const startTime = new Date();
|
||||||
let executionLog: any = null;
|
let executionLog: any = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
|
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id}, type: ${config.execution_type || "mapping"})`);
|
||||||
|
|
||||||
// 매핑 정보가 없으면 상세 조회로 다시 가져오기
|
// 상세 조회 (매핑 또는 노드플로우 정보가 없을 수 있음)
|
||||||
|
if (!config.execution_type || config.execution_type === "mapping") {
|
||||||
if (!config.batch_mappings || config.batch_mappings.length === 0) {
|
if (!config.batch_mappings || config.batch_mappings.length === 0) {
|
||||||
const fullConfig = await BatchService.getBatchConfigById(config.id);
|
const fullConfig = await BatchService.getBatchConfigById(config.id);
|
||||||
if (fullConfig.success && fullConfig.data) {
|
if (fullConfig.success && fullConfig.data) {
|
||||||
config = fullConfig.data;
|
config = fullConfig.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 실행 로그 생성
|
// 실행 로그 생성
|
||||||
const executionLogResponse =
|
const executionLogResponse =
|
||||||
|
|
@ -165,12 +167,17 @@ export class BatchSchedulerService {
|
||||||
|
|
||||||
executionLog = executionLogResponse.data;
|
executionLog = executionLogResponse.data;
|
||||||
|
|
||||||
// 실제 배치 실행 로직 (수동 실행과 동일한 로직 사용)
|
let result: { totalRecords: number; successRecords: number; failedRecords: number };
|
||||||
const result = await this.executeBatchMappings(config);
|
|
||||||
|
if (config.execution_type === "node_flow") {
|
||||||
|
result = await this.executeNodeFlow(config);
|
||||||
|
} else {
|
||||||
|
result = await this.executeBatchMappings(config);
|
||||||
|
}
|
||||||
|
|
||||||
// 실행 로그 업데이트 (성공)
|
// 실행 로그 업데이트 (성공)
|
||||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||||
execution_status: "SUCCESS",
|
execution_status: result.failedRecords > 0 ? "PARTIAL" : "SUCCESS",
|
||||||
end_time: new Date(),
|
end_time: new Date(),
|
||||||
duration_ms: Date.now() - startTime.getTime(),
|
duration_ms: Date.now() - startTime.getTime(),
|
||||||
total_records: result.totalRecords,
|
total_records: result.totalRecords,
|
||||||
|
|
@ -182,12 +189,10 @@ export class BatchSchedulerService {
|
||||||
`배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})`
|
`배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 성공 결과 반환
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error);
|
logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error);
|
||||||
|
|
||||||
// 실행 로그 업데이트 (실패)
|
|
||||||
if (executionLog) {
|
if (executionLog) {
|
||||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||||
execution_status: "FAILED",
|
execution_status: "FAILED",
|
||||||
|
|
@ -198,7 +203,6 @@ export class BatchSchedulerService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 실패 결과 반환
|
|
||||||
return {
|
return {
|
||||||
totalRecords: 0,
|
totalRecords: 0,
|
||||||
successRecords: 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 total = parseInt(countResult[0].count);
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
// 목록 조회
|
// 목록 조회 (최근 실행 정보 포함)
|
||||||
const configs = await query<any>(
|
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
|
FROM batch_configs bc
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY bc.created_date DESC
|
ORDER BY bc.created_date DESC
|
||||||
|
|
@ -82,9 +85,6 @@ export class BatchService {
|
||||||
[...values, limit, offset]
|
[...values, limit, offset]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 매핑 정보 조회 (N+1 문제 해결을 위해 별도 쿼리 대신 여기서는 생략하고 상세 조회에서 처리)
|
|
||||||
// 하지만 목록에서도 간단한 정보는 필요할 수 있음
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: configs as BatchConfig[],
|
data: configs as BatchConfig[],
|
||||||
|
|
@ -176,8 +176,8 @@ export class BatchService {
|
||||||
// 배치 설정 생성
|
// 배치 설정 생성
|
||||||
const batchConfigResult = await client.query(
|
const batchConfigResult = await client.query(
|
||||||
`INSERT INTO batch_configs
|
`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)
|
(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, NOW(), NOW())
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW())
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
data.batchName,
|
data.batchName,
|
||||||
|
|
@ -189,6 +189,9 @@ export class BatchService {
|
||||||
data.conflictKey || null,
|
data.conflictKey || null,
|
||||||
data.authServiceName || null,
|
data.authServiceName || null,
|
||||||
data.dataArrayPath || null,
|
data.dataArrayPath || null,
|
||||||
|
data.executionType || "mapping",
|
||||||
|
data.nodeFlowId || null,
|
||||||
|
data.nodeFlowContext ? JSON.stringify(data.nodeFlowContext) : null,
|
||||||
userId,
|
userId,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
@ -332,6 +335,22 @@ export class BatchService {
|
||||||
updateFields.push(`data_array_path = $${paramIndex++}`);
|
updateFields.push(`data_array_path = $${paramIndex++}`);
|
||||||
updateValues.push(data.dataArrayPath || null);
|
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(
|
const batchConfigResult = await client.query(
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,9 @@ export interface BatchMapping {
|
||||||
created_date?: Date;
|
created_date?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 배치 실행 타입: 기존 매핑 방식 또는 노드 플로우 실행
|
||||||
|
export type BatchExecutionType = "mapping" | "node_flow";
|
||||||
|
|
||||||
// 배치 설정 타입
|
// 배치 설정 타입
|
||||||
export interface BatchConfig {
|
export interface BatchConfig {
|
||||||
id?: number;
|
id?: number;
|
||||||
|
|
@ -87,15 +90,21 @@ export interface BatchConfig {
|
||||||
cron_schedule: string;
|
cron_schedule: string;
|
||||||
is_active: "Y" | "N";
|
is_active: "Y" | "N";
|
||||||
company_code?: string;
|
company_code?: string;
|
||||||
save_mode?: "INSERT" | "UPSERT"; // 저장 모드 (기본: INSERT)
|
save_mode?: "INSERT" | "UPSERT";
|
||||||
conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명
|
conflict_key?: string;
|
||||||
auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명
|
auth_service_name?: string;
|
||||||
data_array_path?: string; // REST API 응답에서 데이터 배열 경로 (예: response, data.items)
|
data_array_path?: string;
|
||||||
|
execution_type?: BatchExecutionType;
|
||||||
|
node_flow_id?: number;
|
||||||
|
node_flow_context?: Record<string, any>;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
created_date?: Date;
|
created_date?: Date;
|
||||||
updated_by?: string;
|
updated_by?: string;
|
||||||
updated_date?: Date;
|
updated_date?: Date;
|
||||||
batch_mappings?: BatchMapping[];
|
batch_mappings?: BatchMapping[];
|
||||||
|
last_status?: string;
|
||||||
|
last_executed_at?: string;
|
||||||
|
last_total_records?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchConnectionInfo {
|
export interface BatchConnectionInfo {
|
||||||
|
|
@ -149,7 +158,10 @@ export interface CreateBatchConfigRequest {
|
||||||
saveMode?: "INSERT" | "UPSERT";
|
saveMode?: "INSERT" | "UPSERT";
|
||||||
conflictKey?: string;
|
conflictKey?: string;
|
||||||
authServiceName?: string;
|
authServiceName?: string;
|
||||||
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
|
dataArrayPath?: string;
|
||||||
|
executionType?: BatchExecutionType;
|
||||||
|
nodeFlowId?: number;
|
||||||
|
nodeFlowContext?: Record<string, any>;
|
||||||
mappings: BatchMappingRequest[];
|
mappings: BatchMappingRequest[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,7 +173,10 @@ export interface UpdateBatchConfigRequest {
|
||||||
saveMode?: "INSERT" | "UPSERT";
|
saveMode?: "INSERT" | "UPSERT";
|
||||||
conflictKey?: string;
|
conflictKey?: string;
|
||||||
authServiceName?: string;
|
authServiceName?: string;
|
||||||
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
|
dataArrayPath?: string;
|
||||||
|
executionType?: BatchExecutionType;
|
||||||
|
nodeFlowId?: number;
|
||||||
|
nodeFlowContext?: Record<string, any>;
|
||||||
mappings?: BatchMappingRequest[];
|
mappings?: BatchMappingRequest[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,12 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -15,17 +16,58 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { RefreshCw, Save, ArrowLeft, Plus, Trash2, Database, Workflow, Clock, Info, Layers, Link, Search } from "lucide-react";
|
||||||
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
BatchAPI,
|
BatchAPI,
|
||||||
BatchConfig,
|
BatchConfig,
|
||||||
BatchMapping,
|
BatchMapping,
|
||||||
ConnectionInfo,
|
ConnectionInfo,
|
||||||
|
type NodeFlowInfo,
|
||||||
|
type BatchExecutionType,
|
||||||
} from "@/lib/api/batch";
|
} from "@/lib/api/batch";
|
||||||
import { BatchManagementAPI } from "@/lib/api/batchManagement";
|
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 {
|
interface BatchColumnInfo {
|
||||||
column_name: string;
|
column_name: string;
|
||||||
data_type: string;
|
data_type: string;
|
||||||
|
|
@ -49,15 +91,33 @@ const detectBatchType = (mapping: BatchMapping): 'db-to-db' | 'restapi-to-db' |
|
||||||
export default function BatchEditPage() {
|
export default function BatchEditPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { openTab } = useTabStore();
|
||||||
const batchId = parseInt(params.id as string);
|
const batchId = parseInt(params.id as string);
|
||||||
|
|
||||||
// 기본 상태
|
// 기본 상태
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [batchConfig, setBatchConfig] = useState<BatchConfig | null>(null);
|
const [batchConfig, setBatchConfig] = useState<BatchConfig | null>(null);
|
||||||
const [batchName, setBatchName] = useState("");
|
const [batchName, setBatchName] = useState("");
|
||||||
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
|
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [isActive, setIsActive] = useState("Y");
|
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 [saveMode, setSaveMode] = useState<"INSERT" | "UPSERT">("INSERT");
|
||||||
const [conflictKey, setConflictKey] = useState("");
|
const [conflictKey, setConflictKey] = useState("");
|
||||||
const [authServiceName, setAuthServiceName] = 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);
|
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 미리보기 상태
|
// REST API 미리보기 상태
|
||||||
const [apiPreviewData, setApiPreviewData] = useState<any[]>([]);
|
const [apiPreviewData, setApiPreviewData] = useState<any[]>([]);
|
||||||
const [fromApiFields, setFromApiFields] = useState<string[]>([]);
|
const [fromApiFields, setFromApiFields] = useState<string[]>([]);
|
||||||
|
|
@ -217,14 +284,31 @@ export default function BatchEditPage() {
|
||||||
|
|
||||||
setBatchConfig(config);
|
setBatchConfig(config);
|
||||||
setBatchName(config.batch_name);
|
setBatchName(config.batch_name);
|
||||||
setCronSchedule(config.cron_schedule);
|
|
||||||
setDescription(config.description || "");
|
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");
|
setIsActive(config.is_active || "Y");
|
||||||
setSaveMode((config as any).save_mode || "INSERT");
|
setSaveMode((config as any).save_mode || "INSERT");
|
||||||
setConflictKey((config as any).conflict_key || "");
|
setConflictKey((config as any).conflict_key || "");
|
||||||
setAuthServiceName((config as any).auth_service_name || "");
|
setAuthServiceName((config as any).auth_service_name || "");
|
||||||
setDataArrayPath((config as any).data_array_path || "");
|
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) {
|
if ((config as any).auth_service_name) {
|
||||||
setAuthTokenMode("db");
|
setAuthTokenMode("db");
|
||||||
|
|
@ -539,11 +623,49 @@ export default function BatchEditPage() {
|
||||||
|
|
||||||
// 배치 설정 저장
|
// 배치 설정 저장
|
||||||
const saveBatchConfig = async () => {
|
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;
|
const effectiveMappings = batchType === "restapi-to-db" ? mappingList : mappings;
|
||||||
|
|
||||||
if (!batchName || !cronSchedule || effectiveMappings.length === 0) {
|
if (effectiveMappings.length === 0) {
|
||||||
toast.error("필수 항목을 모두 입력해주세요.");
|
toast.error("매핑을 최소 하나 이상 설정해주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -592,7 +714,7 @@ export default function BatchEditPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("배치 설정이 성공적으로 수정되었습니다.");
|
toast.success("배치 설정이 성공적으로 수정되었습니다.");
|
||||||
router.push("/admin/batchmng");
|
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치 설정 수정 실패:", 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) {
|
if (loading && !batchConfig) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<div className="mx-auto max-w-5xl p-4 sm:p-6">
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex h-64 items-center justify-center gap-2">
|
||||||
<RefreshCw className="w-8 h-8 animate-spin" />
|
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
<span className="ml-2">배치 설정을 불러오는 중...</span>
|
<span className="text-sm text-muted-foreground">배치 설정을 불러오는 중...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto space-y-6 p-6">
|
<div className="mx-auto h-full max-w-[640px] space-y-7 overflow-y-auto p-4 sm:p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center gap-4 border-b pb-4">
|
<div>
|
||||||
<Button
|
<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">
|
||||||
variant="outline"
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
onClick={() => router.push("/admin/batchmng")}
|
배치 관리로 돌아가기
|
||||||
className="gap-2"
|
</button>
|
||||||
>
|
<div className="flex items-center justify-between">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<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>
|
</Button>
|
||||||
<h1 className="text-3xl font-bold">배치 설정 수정</h1>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
<Card>
|
<div>
|
||||||
<CardHeader>
|
<h2 className="mb-3 text-sm font-bold">기본 정보</h2>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="space-y-4">
|
||||||
기본 정보
|
<div className="space-y-1.5">
|
||||||
{batchType && (
|
<Label htmlFor="batchName" className="text-xs font-medium">배치 이름 <span className="text-destructive">*</span></Label>
|
||||||
<Badge variant="outline">
|
<Input id="batchName" value={batchName} onChange={e => setBatchName(e.target.value)} placeholder="예: 매출 데이터 동기화" className="h-10 text-sm" />
|
||||||
{batchType === "db-to-db" && "DB -> DB"}
|
</div>
|
||||||
{batchType === "restapi-to-db" && "REST API -> DB"}
|
<div className="space-y-1.5">
|
||||||
{batchType === "db-to-restapi" && "DB -> REST API"}
|
<Label htmlFor="description" className="text-xs font-medium">설명</Label>
|
||||||
</Badge>
|
<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>
|
||||||
|
<p className="text-sm font-medium">배치 켜기</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{isActive === "Y" ? "스케줄에 따라 자동으로 실행돼요" : "배치가 꺼져 있어요"}</p>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
<div className="space-y-1">
|
||||||
</CardHeader>
|
<span className="text-[11px] font-medium text-muted-foreground">시</span>
|
||||||
<CardContent className="space-y-4">
|
<Select value={customHour} onValueChange={setCustomHour}>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<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 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>
|
<div>
|
||||||
<Label htmlFor="batchName">배치명 *</Label>
|
<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>
|
||||||
|
|
||||||
|
{/* 노드 플로우 설정 */}
|
||||||
|
{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
|
<Input
|
||||||
id="batchName"
|
value={flowSearch}
|
||||||
value={batchName}
|
onChange={e => setFlowSearch(e.target.value)}
|
||||||
onChange={(e) => setBatchName(e.target.value)}
|
placeholder="플로우 이름으로 검색하세요"
|
||||||
placeholder="배치명을 입력하세요"
|
className="h-8 pl-9 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="max-h-[240px] space-y-2 overflow-y-auto">
|
||||||
<Label htmlFor="cronSchedule">실행 스케줄 (Cron) *</Label>
|
{nodeFlows
|
||||||
<Input
|
.filter(flow => !flowSearch || flow.flow_name.toLowerCase().includes(flowSearch.toLowerCase()) || (flow.description || "").toLowerCase().includes(flowSearch.toLowerCase()))
|
||||||
id="cronSchedule"
|
.map(flow => (
|
||||||
value={cronSchedule}
|
<button
|
||||||
onChange={(e) => setCronSchedule(e.target.value)}
|
key={flow.flow_id}
|
||||||
placeholder="0 12 * * *"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
{selectedFlow && (
|
||||||
<Label htmlFor="description">설명</Label>
|
<div className="mt-4 space-y-1.5">
|
||||||
<Textarea
|
<Label className="text-xs font-medium">실행할 때 전달할 데이터 <span className="text-muted-foreground">(선택)</span></Label>
|
||||||
id="description"
|
<Textarea value={nodeFlowContext} onChange={e => setNodeFlowContext(e.target.value)} placeholder='예: {"target_status": "퇴사"}' rows={3} className="resize-none font-mono text-xs" />
|
||||||
value={description}
|
<p className="text-[11px] text-muted-foreground">플로우가 실행될 때 참고할 데이터를 JSON 형식으로 적어주세요. 비워두면 기본값을 사용해요.</p>
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="배치에 대한 설명을 입력하세요"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</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>
|
</div>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* FROM/TO 섹션 가로 배치 */}
|
{/* FROM/TO 섹션 가로 배치 (매핑 타입일 때만) */}
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
{executionType === "mapping" && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
{/* FROM 설정 */}
|
{/* FROM 설정 */}
|
||||||
<Card>
|
<div className="space-y-3 rounded-lg border border-emerald-500/20 p-4 sm:p-5">
|
||||||
<CardHeader>
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle>FROM (소스)</CardTitle>
|
<div className="flex h-6 w-6 items-center justify-center rounded bg-emerald-500/10 text-emerald-500">
|
||||||
</CardHeader>
|
<Database className="h-3.5 w-3.5" />
|
||||||
<CardContent className="space-y-4">
|
</div>
|
||||||
|
<span className="text-sm font-medium">FROM (소스)</span>
|
||||||
|
</div>
|
||||||
{batchType === "db-to-db" && (
|
{batchType === "db-to-db" && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -1000,21 +1301,22 @@ export default function BatchEditPage() {
|
||||||
|
|
||||||
{batchType === "db-to-restapi" && mappings.length > 0 && (
|
{batchType === "db-to-restapi" && mappings.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div className="space-y-1.5">
|
||||||
<Label>소스 테이블</Label>
|
<Label className="text-xs">소스 테이블</Label>
|
||||||
<Input value={mappings[0]?.from_table_name || ""} readOnly />
|
<Input value={mappings[0]?.from_table_name || ""} readOnly className="h-9 text-sm" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* TO 설정 */}
|
{/* TO 설정 */}
|
||||||
<Card>
|
<div className="space-y-3 rounded-lg border border-sky-500/20 p-4 sm:p-5">
|
||||||
<CardHeader>
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle>TO (대상)</CardTitle>
|
<div className="flex h-6 w-6 items-center justify-center rounded bg-sky-500/10 text-sky-500">
|
||||||
</CardHeader>
|
<Database className="h-3.5 w-3.5" />
|
||||||
<CardContent className="space-y-4">
|
</div>
|
||||||
|
<span className="text-sm font-medium">TO (대상)</span>
|
||||||
|
</div>
|
||||||
{batchType === "db-to-db" && (
|
{batchType === "db-to-db" && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -1188,8 +1490,7 @@ export default function BatchEditPage() {
|
||||||
UPSERT 시 중복 여부를 판단할 컬럼을 선택하세요.
|
UPSERT 시 중복 여부를 판단할 컬럼을 선택하세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API 데이터 미리보기 버튼 */}
|
{/* API 데이터 미리보기 버튼 */}
|
||||||
|
|
@ -1206,19 +1507,19 @@ export default function BatchEditPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 컬럼 매핑 섹션 - 좌우 분리 */}
|
{/* 컬럼 매핑 섹션 */}
|
||||||
<Card>
|
<div className="space-y-3 rounded-lg border p-4 sm:p-5">
|
||||||
<CardHeader>
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle 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 === "db-to-db" && "컬럼 매핑"}
|
||||||
{batchType === "restapi-to-db" && "컬럼 매핑 설정"}
|
{batchType === "restapi-to-db" && "컬럼 매핑 설정"}
|
||||||
{batchType === "db-to-restapi" && "DB 컬럼 -> API 필드 매핑"}
|
{batchType === "db-to-restapi" && "DB → API 필드 매핑"}
|
||||||
</CardTitle>
|
</div>
|
||||||
{batchType === "restapi-to-db" && (
|
{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>
|
</div>
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
{/* 왼쪽: 샘플 데이터 */}
|
{/* 왼쪽: 샘플 데이터 */}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
|
@ -1526,24 +1827,21 @@ export default function BatchEditPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 하단 버튼 */}
|
{/* 하단 버튼 */}
|
||||||
<div className="flex justify-end space-x-2 border-t pt-6">
|
<div className="flex justify-end gap-2 border-t pt-5">
|
||||||
<Button variant="outline" onClick={() => router.push("/admin/batchmng")}>
|
<Button variant="outline" size="sm" onClick={goBack} className="h-9 text-xs">취소</Button>
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
|
size="sm"
|
||||||
onClick={saveBatchConfig}
|
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 ? (
|
{loading ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
{loading ? "저장 중..." : "저장하기"}
|
||||||
) : (
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{loading ? "저장 중..." : "배치 설정 저장"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,366 +1,707 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Database
|
CheckCircle,
|
||||||
|
Play,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Clock,
|
||||||
|
Link,
|
||||||
|
Settings,
|
||||||
|
Database,
|
||||||
|
Cloud,
|
||||||
|
Workflow,
|
||||||
|
ChevronDown,
|
||||||
|
AlertCircle,
|
||||||
|
BarChart3,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||||
import { useRouter } from "next/navigation";
|
import {
|
||||||
import { BatchAPI, type BatchConfig, type BatchMapping } from "@/lib/api/batch";
|
BatchAPI,
|
||||||
import { apiClient } from "@/lib/api/client";
|
type BatchConfig,
|
||||||
|
type BatchMapping,
|
||||||
|
type BatchStats,
|
||||||
|
type SparklineData,
|
||||||
|
type RecentLog,
|
||||||
|
} from "@/lib/api/batch";
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
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() {
|
export default function BatchManagementPage() {
|
||||||
const router = useRouter();
|
const { openTab } = useTabStore();
|
||||||
|
|
||||||
// 상태 관리
|
|
||||||
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
|
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
|
||||||
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
|
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 [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
||||||
|
const [togglingBatch, setTogglingBatch] = useState<number | null>(null);
|
||||||
|
|
||||||
// 페이지 로드 시 배치 목록 조회
|
const loadBatchConfigs = useCallback(async () => {
|
||||||
useEffect(() => {
|
|
||||||
loadBatchConfigs();
|
|
||||||
}, [currentPage, searchTerm]);
|
|
||||||
|
|
||||||
// 배치 설정 목록 조회
|
|
||||||
const loadBatchConfigs = async () => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await BatchAPI.getBatchConfigs({
|
const [configsResponse, statsData] = await Promise.all([
|
||||||
page: currentPage,
|
BatchAPI.getBatchConfigs({ page: 1, limit: 200 }),
|
||||||
limit: 10,
|
BatchAPI.getBatchStats(),
|
||||||
search: searchTerm || undefined,
|
]);
|
||||||
|
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 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setBatchConfigs(response.data);
|
|
||||||
if (response.pagination) {
|
|
||||||
setTotalPages(response.pagination.totalPages);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setBatchConfigs([]);
|
setBatchConfigs([]);
|
||||||
}
|
}
|
||||||
|
if (statsData) setStats(statsData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치 목록 조회 실패:", error);
|
console.error("배치 목록 조회 실패:", error);
|
||||||
toast.error("배치 목록을 불러오는데 실패했습니다.");
|
toast.error("배치 목록을 불러올 수 없어요");
|
||||||
setBatchConfigs([]);
|
setBatchConfigs([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 toggleBatchActive = async (batchId: number, currentActive: string) => {
|
||||||
const executeBatch = async (batchId: number) => {
|
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);
|
setExecutingBatch(batchId);
|
||||||
try {
|
try {
|
||||||
const response = await BatchAPI.executeBatchConfig(batchId);
|
const response = await BatchAPI.executeBatchConfig(batchId);
|
||||||
if (response.success) {
|
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 {
|
} else {
|
||||||
toast.error("배치 실행에 실패했습니다.");
|
toast.error("배치 실행에 실패했어요");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치 실행 실패:", error);
|
showErrorToast("배치 실행 실패", error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
|
||||||
showErrorToast("배치 실행에 실패했습니다", error, {
|
|
||||||
guidance: "배치 설정을 확인하고 다시 시도해 주세요.",
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setExecutingBatch(null);
|
setExecutingBatch(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 배치 활성화/비활성화 토글
|
const deleteBatch = async (e: React.MouseEvent, batchId: number, batchName: string) => {
|
||||||
const toggleBatchStatus = async (batchId: number, currentStatus: string) => {
|
e.stopPropagation();
|
||||||
console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus });
|
if (!confirm(`'${batchName}' 배치를 삭제할까요?`)) return;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await BatchAPI.deleteBatchConfig(batchId);
|
await BatchAPI.deleteBatchConfig(batchId);
|
||||||
toast.success("배치가 삭제되었습니다.");
|
toast.success("배치를 삭제했어요");
|
||||||
loadBatchConfigs(); // 목록 새로고침
|
loadBatchConfigs();
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("배치 삭제 실패:", error);
|
toast.error("배치 삭제에 실패했어요");
|
||||||
toast.error("배치 삭제에 실패했습니다.");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 검색 처리
|
const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db" | "node-flow") => {
|
||||||
const handleSearch = (value: string) => {
|
setIsBatchTypeModalOpen(false);
|
||||||
setSearchTerm(value);
|
if (type === "db-to-db") {
|
||||||
setCurrentPage(1); // 검색 시 첫 페이지로 이동
|
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) => {
|
||||||
const getMappingSummary = (mappings: BatchMapping[]) => {
|
if (searchTerm && !batch.batch_name.toLowerCase().includes(searchTerm.toLowerCase()) && !(batch.description || "").toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||||
if (!mappings || mappings.length === 0) {
|
if (statusFilter === "active" && batch.is_active !== "Y") return false;
|
||||||
return "매핑 없음";
|
if (statusFilter === "inactive" && batch.is_active !== "N") return false;
|
||||||
}
|
return true;
|
||||||
|
|
||||||
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]) =>
|
const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length;
|
||||||
`${key} (${count}개 컬럼)`
|
const inactiveBatches = batchConfigs.length - activeBatches;
|
||||||
);
|
const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0;
|
||||||
|
const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0;
|
||||||
return summaries.join(", ");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 배치 추가 버튼 클릭 핸들러
|
|
||||||
const handleCreateBatch = () => {
|
|
||||||
setIsBatchTypeModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 배치 타입 선택 핸들러
|
|
||||||
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => {
|
|
||||||
console.log("배치 타입 선택:", type);
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<div className="space-y-6 p-6">
|
<div className="mx-auto w-full max-w-[720px] space-y-4 px-4 py-6 sm:px-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="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{/* 검색 영역 */}
|
<div>
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
<h1 className="text-lg font-bold tracking-tight">배치 관리</h1>
|
||||||
<div className="w-full sm:w-[400px]">
|
<p className="text-xs text-muted-foreground">등록한 배치가 자동으로 실행돼요</p>
|
||||||
<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>
|
||||||
</div>
|
<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">
|
||||||
<Button
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
variant="outline"
|
</button>
|
||||||
onClick={loadBatchConfigs}
|
<Button size="sm" onClick={() => setIsBatchTypeModalOpen(true)} className="h-8 gap-1 text-xs">
|
||||||
disabled={loading}
|
<Plus className="h-3.5 w-3.5" />
|
||||||
className="h-10 gap-2 text-sm font-medium"
|
새 배치
|
||||||
>
|
|
||||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
|
||||||
새로고침
|
|
||||||
</Button>
|
|
||||||
</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" />
|
|
||||||
배치 추가
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 배치 목록 */}
|
{/* 통계 요약 스트립 */}
|
||||||
{batchConfigs.length === 0 ? (
|
{stats && (
|
||||||
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
<div className="flex items-center gap-0 rounded-lg border bg-card">
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
<div className="flex flex-1 flex-col px-4 py-3">
|
||||||
<Database className="h-12 w-12 text-muted-foreground" />
|
<span className="text-[11px] text-muted-foreground">전체</span>
|
||||||
<div className="space-y-2">
|
<span className="text-lg font-bold">{batchConfigs.length}</span>
|
||||||
<h3 className="text-lg font-semibold">배치가 없습니다</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{!searchTerm && (
|
<div className="h-8 w-px bg-border" />
|
||||||
<Button
|
<div className="flex flex-1 flex-col px-4 py-3">
|
||||||
onClick={handleCreateBatch}
|
<span className="text-[11px] text-muted-foreground">켜진 배치</span>
|
||||||
className="h-10 gap-2 text-sm font-medium"
|
<span className="text-lg font-bold text-primary">{activeBatches}</span>
|
||||||
>
|
</div>
|
||||||
<Plus className="h-4 w-4" />
|
<div className="h-8 w-px bg-border" />
|
||||||
첫 번째 배치 추가
|
<div className="flex flex-1 flex-col px-4 py-3">
|
||||||
</Button>
|
<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>
|
</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) => (
|
{/* 24시간 차트 */}
|
||||||
<BatchCard
|
<GlobalSparkline stats={stats} />
|
||||||
key={batch.id}
|
|
||||||
batch={batch}
|
{/* 검색 + 필터 */}
|
||||||
executingBatch={executingBatch}
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
onExecute={executeBatch}
|
<div className="relative min-w-[180px] flex-1">
|
||||||
onToggleStatus={(batchId, currentStatus) => {
|
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
toggleBatchStatus(batchId, currentStatus);
|
<Input placeholder="배치 이름으로 검색하세요" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="h-8 pl-9 text-xs" />
|
||||||
}}
|
</div>
|
||||||
onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)}
|
<div className="flex gap-0.5 rounded-lg border bg-muted/30 p-0.5">
|
||||||
onDelete={deleteBatch}
|
{([
|
||||||
getMappingSummary={getMappingSummary}
|
{ 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>
|
||||||
|
|
||||||
|
{/* 배치 리스트 */}
|
||||||
|
<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 && (
|
||||||
{totalPages > 1 && (
|
<div className="flex h-40 flex-col items-center justify-center gap-2">
|
||||||
<div className="flex items-center justify-center gap-2">
|
<Database className="h-6 w-6 text-muted-foreground/40" />
|
||||||
<Button
|
<p className="text-xs text-muted-foreground">{searchTerm ? "검색 결과가 없어요" : "등록된 배치가 없어요"}</p>
|
||||||
variant="outline"
|
</div>
|
||||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
)}
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="h-10 text-sm font-medium"
|
{filteredBatches.map((batch) => {
|
||||||
>
|
const batchId = batch.id!;
|
||||||
이전
|
const isExpanded = expandedBatch === batchId;
|
||||||
</Button>
|
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";
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
|
||||||
const pageNum = i + 1;
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<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" : ""}`}>
|
||||||
key={pageNum}
|
{/* 행 */}
|
||||||
variant={currentPage === pageNum ? "default" : "outline"}
|
<div className="flex cursor-pointer items-center gap-3 px-4 py-3.5 sm:gap-4" onClick={() => handleRowClick(batchId)}>
|
||||||
onClick={() => setCurrentPage(pageNum)}
|
{/* 토글 */}
|
||||||
className="h-10 min-w-[40px] text-sm"
|
<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="지금 실행하기"
|
||||||
>
|
>
|
||||||
{pageNum}
|
{isExecuting ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
className="h-10 text-sm font-medium"
|
|
||||||
>
|
|
||||||
다음
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 배치 타입 선택 모달 */}
|
{/* 배치 타입 선택 모달 */}
|
||||||
{isBatchTypeModalOpen && (
|
{isBatchTypeModalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
<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-2xl rounded-lg border bg-card p-6 shadow-lg">
|
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-lg" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="space-y-6">
|
<h2 className="mb-1 text-base font-bold">어떤 배치를 만들까요?</h2>
|
||||||
<h2 className="text-xl font-semibold text-center">배치 타입 선택</h2>
|
<p className="mb-5 text-xs text-muted-foreground">데이터를 가져올 방식을 선택해주세요</p>
|
||||||
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
{[
|
||||||
{/* DB → DB */}
|
{ 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
|
<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"
|
key={opt.type}
|
||||||
onClick={() => handleBatchTypeSelect('db-to-db')}
|
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">
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||||
<Database className="h-8 w-8 text-primary" />
|
<opt.icon className={`h-[18px] w-[18px] ${opt.iconColor}`} />
|
||||||
<span className="text-muted-foreground">→</span>
|
|
||||||
<Database className="h-8 w-8 text-primary" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-center">
|
<div>
|
||||||
<div className="text-lg font-medium">DB → DB</div>
|
<p className="text-sm font-semibold">{opt.title}</p>
|
||||||
<div className="text-sm text-muted-foreground">데이터베이스 간 데이터 동기화</div>
|
<p className="text-[11px] text-muted-foreground">{opt.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsBatchTypeModalOpen(false)}
|
|
||||||
className="h-10 text-sm font-medium"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 */}
|
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo, memo } from "react";
|
import React, { useState, useEffect, useMemo, memo } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -62,6 +63,7 @@ interface DbToRestApiMappingCardProps {
|
||||||
|
|
||||||
export default function BatchManagementNewPage() {
|
export default function BatchManagementNewPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { openTab } = useTabStore();
|
||||||
|
|
||||||
// 기본 상태
|
// 기본 상태
|
||||||
const [batchName, setBatchName] = useState("");
|
const [batchName, setBatchName] = useState("");
|
||||||
|
|
@ -463,7 +465,7 @@ export default function BatchManagementNewPage() {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message || "REST API 배치 설정이 저장되었습니다.");
|
toast.success(result.message || "REST API 배치 설정이 저장되었습니다.");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push("/admin/batchmng");
|
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "배치 저장에 실패했습니다.");
|
toast.error(result.message || "배치 저장에 실패했습니다.");
|
||||||
|
|
@ -554,7 +556,7 @@ export default function BatchManagementNewPage() {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message || "DB → REST API 배치 설정이 저장되었습니다.");
|
toast.success(result.message || "DB → REST API 배치 설정이 저장되었습니다.");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push("/admin/batchmng");
|
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "배치 저장에 실패했습니다.");
|
toast.error(result.message || "배치 저장에 실패했습니다.");
|
||||||
|
|
@ -571,80 +573,69 @@ export default function BatchManagementNewPage() {
|
||||||
toast.error("지원하지 않는 배치 타입입니다.");
|
toast.error("지원하지 않는 배치 타입입니다.");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto space-y-6 p-6">
|
<div className="mx-auto max-w-5xl space-y-6 p-4 sm:p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="border-b pb-4">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-3xl font-bold">고급 배치 생성</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
<Card>
|
<div className="space-y-4 rounded-lg border p-4 sm:p-5">
|
||||||
<CardHeader>
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
<CardTitle>기본 정보</CardTitle>
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
기본 정보
|
||||||
<CardContent className="space-y-4">
|
</div>
|
||||||
{/* 배치 타입 선택 */}
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div className="space-y-1.5">
|
||||||
<Label>배치 타입 *</Label>
|
<Label htmlFor="batchName" className="text-xs">배치명 <span className="text-destructive">*</span></Label>
|
||||||
<div className="mt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
|
<Input id="batchName" value={batchName} onChange={e => setBatchName(e.target.value)} placeholder="배치명을 입력하세요" className="h-9 text-sm" />
|
||||||
{batchTypeOptions.map((option) => (
|
</div>
|
||||||
<div
|
<div className="space-y-1.5">
|
||||||
key={option.value}
|
<Label htmlFor="cronSchedule" className="text-xs">실행 스케줄 <span className="text-destructive">*</span></Label>
|
||||||
className={`cursor-pointer rounded-lg border p-3 transition-all ${
|
<Input id="cronSchedule" value={cronSchedule} onChange={e => setCronSchedule(e.target.value)} placeholder="0 12 * * *" className="h-9 font-mono text-sm" />
|
||||||
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>
|
<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>
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="description">설명</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="배치에 대한 설명을 입력하세요"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* FROM/TO 설정 - 가로 배치 */}
|
{/* FROM/TO 설정 - 가로 배치 */}
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
{/* FROM 설정 */}
|
{/* FROM 설정 */}
|
||||||
|
|
@ -1426,13 +1417,14 @@ export default function BatchManagementNewPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 하단 액션 버튼 */}
|
{/* 하단 액션 버튼 */}
|
||||||
<div className="flex items-center justify-end gap-2 border-t pt-6">
|
<div className="flex items-center justify-end gap-2 border-t pt-4">
|
||||||
<Button onClick={loadConnections} variant="outline" className="gap-2">
|
<Button onClick={goBack} variant="outline" size="sm" className="h-8 gap-1 text-xs">취소</Button>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<Button onClick={loadConnections} variant="outline" size="sm" className="h-8 gap-1 text-xs">
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} className="gap-2">
|
<Button onClick={handleSave} size="sm" className="h-8 gap-1 text-xs">
|
||||||
<Save className="h-4 w-4" />
|
<Save className="h-3.5 w-3.5" />
|
||||||
저장
|
저장
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,66 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import DataFlowList from "@/components/dataflow/DataFlowList";
|
import DataFlowList from "@/components/dataflow/DataFlowList";
|
||||||
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
|
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
type Step = "list" | "editor";
|
type Step = "list" | "editor";
|
||||||
|
|
||||||
export default function DataFlowPage() {
|
export default function DataFlowPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const router = useRouter();
|
|
||||||
const [currentStep, setCurrentStep] = useState<Step>("list");
|
const [currentStep, setCurrentStep] = useState<Step>("list");
|
||||||
const [loadingFlowId, setLoadingFlowId] = useState<number | null>(null);
|
const [loadingFlowId, setLoadingFlowId] = useState<number | null>(null);
|
||||||
|
|
||||||
// 플로우 불러오기 핸들러
|
|
||||||
const handleLoadFlow = async (flowId: number | null) => {
|
const handleLoadFlow = async (flowId: number | null) => {
|
||||||
if (flowId === null) {
|
if (flowId === null) {
|
||||||
// 새 플로우 생성
|
|
||||||
setLoadingFlowId(null);
|
setLoadingFlowId(null);
|
||||||
setCurrentStep("editor");
|
setCurrentStep("editor");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 기존 플로우 불러오기
|
|
||||||
setLoadingFlowId(flowId);
|
setLoadingFlowId(flowId);
|
||||||
setCurrentStep("editor");
|
setCurrentStep("editor");
|
||||||
|
toast.success("플로우를 불러왔어요");
|
||||||
toast.success("플로우를 불러왔습니다.");
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("❌ 플로우 불러오기 실패:", error);
|
console.error("플로우 불러오기 실패:", error);
|
||||||
showErrorToast("플로우 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
|
showErrorToast("플로우를 불러오는 데 실패했어요", error, {
|
||||||
|
guidance: "네트워크 연결을 확인해 주세요.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 목록으로 돌아가기
|
|
||||||
const handleBackToList = () => {
|
const handleBackToList = () => {
|
||||||
setCurrentStep("list");
|
setCurrentStep("list");
|
||||||
setLoadingFlowId(null);
|
setLoadingFlowId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 에디터 모드일 때는 전체 화면 사용
|
if (currentStep === "editor") {
|
||||||
const isEditorMode = currentStep === "editor";
|
|
||||||
|
|
||||||
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
|
|
||||||
if (isEditorMode) {
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background fixed inset-0 z-50">
|
<div className="bg-background fixed inset-0 z-50">
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* 에디터 헤더 */}
|
<div className="bg-background flex items-center gap-4 border-b px-5 py-3">
|
||||||
<div className="bg-background flex items-center gap-4 border-b p-4">
|
<Button
|
||||||
<Button variant="outline" size="sm" onClick={handleBackToList} className="flex items-center gap-2">
|
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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
목록으로
|
목록으로
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight">노드 플로우 에디터</h1>
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
|
||||||
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 플로우 에디터 */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<FlowEditor key={loadingFlowId || "new"} initialFlowId={loadingFlowId} />
|
<FlowEditor
|
||||||
|
key={loadingFlowId || "new"}
|
||||||
|
initialFlowId={loadingFlowId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -78,20 +68,10 @@ export default function DataFlowPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex min-h-screen flex-col">
|
<div className="h-full overflow-y-auto">
|
||||||
<div className="space-y-6 p-4 sm:p-6">
|
<div className="mx-auto w-full max-w-[1400px] space-y-6 p-4 sm:p-6 pb-20">
|
||||||
{/* 페이지 헤더 */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 플로우 목록 */}
|
|
||||||
<DataFlowList onLoadFlow={handleLoadFlow} />
|
<DataFlowList onLoadFlow={handleLoadFlow} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 */}
|
|
||||||
<ScrollToTop />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,7 @@ import { commonCodeApi } from "@/lib/api/commonCode";
|
||||||
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||||
import { ddlApi } from "@/lib/api/ddl";
|
import { ddlApi } from "@/lib/api/ddl";
|
||||||
import { getSecondLevelMenus, createColumnMapping, deleteColumnMappingsByColumn } from "@/lib/api/tableCategoryValue";
|
import { getSecondLevelMenus, createColumnMapping, deleteColumnMappingsByColumn } from "@/lib/api/tableCategoryValue";
|
||||||
import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
||||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
|
||||||
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
||||||
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
||||||
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
|
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
|
||||||
|
|
@ -102,10 +101,7 @@ export default function TableManagementPage() {
|
||||||
// 🆕 Category 타입용: 2레벨 메뉴 목록
|
// 🆕 Category 타입용: 2레벨 메뉴 목록
|
||||||
const [secondLevelMenus, setSecondLevelMenus] = useState<SecondLevelMenu[]>([]);
|
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);
|
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 () => {
|
const loadTables = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -344,9 +322,7 @@ export default function TableManagementPage() {
|
||||||
|
|
||||||
// 컬럼 데이터에 기본값 설정
|
// 컬럼 데이터에 기본값 설정
|
||||||
const processedColumns = (data.columns || data).map((col: any) => {
|
const processedColumns = (data.columns || data).map((col: any) => {
|
||||||
// detailSettings에서 hierarchyRole, numberingRuleId 추출
|
|
||||||
let hierarchyRole: "large" | "medium" | "small" | undefined = undefined;
|
let hierarchyRole: "large" | "medium" | "small" | undefined = undefined;
|
||||||
let numberingRuleId: string | undefined = undefined;
|
|
||||||
if (col.detailSettings && typeof col.detailSettings === "string") {
|
if (col.detailSettings && typeof col.detailSettings === "string") {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(col.detailSettings);
|
const parsed = JSON.parse(col.detailSettings);
|
||||||
|
|
@ -357,9 +333,6 @@ export default function TableManagementPage() {
|
||||||
) {
|
) {
|
||||||
hierarchyRole = parsed.hierarchyRole;
|
hierarchyRole = parsed.hierarchyRole;
|
||||||
}
|
}
|
||||||
if (parsed.numberingRuleId) {
|
|
||||||
numberingRuleId = parsed.numberingRuleId;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// JSON 파싱 실패 시 무시
|
// JSON 파싱 실패 시 무시
|
||||||
}
|
}
|
||||||
|
|
@ -369,7 +342,6 @@ export default function TableManagementPage() {
|
||||||
...col,
|
...col,
|
||||||
inputType: col.inputType || "text",
|
inputType: col.inputType || "text",
|
||||||
isUnique: col.isUnique || "NO",
|
isUnique: col.isUnique || "NO",
|
||||||
numberingRuleId,
|
|
||||||
categoryMenus: col.categoryMenus || [],
|
categoryMenus: col.categoryMenus || [],
|
||||||
hierarchyRole,
|
hierarchyRole,
|
||||||
categoryRef: col.categoryRef || null,
|
categoryRef: col.categoryRef || null,
|
||||||
|
|
@ -1000,7 +972,6 @@ export default function TableManagementPage() {
|
||||||
loadTables();
|
loadTables();
|
||||||
loadCommonCodeCategories();
|
loadCommonCodeCategories();
|
||||||
loadSecondLevelMenus();
|
loadSecondLevelMenus();
|
||||||
loadNumberingRules();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드
|
// 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드
|
||||||
|
|
@ -1619,7 +1590,7 @@ export default function TableManagementPage() {
|
||||||
tables={tables}
|
tables={tables}
|
||||||
referenceTableColumns={referenceTableColumns}
|
referenceTableColumns={referenceTableColumns}
|
||||||
secondLevelMenus={secondLevelMenus}
|
secondLevelMenus={secondLevelMenus}
|
||||||
numberingRules={numberingRules}
|
numberingRules={[]}
|
||||||
onColumnChange={(field, value) => {
|
onColumnChange={(field, value) => {
|
||||||
if (!selectedColumn) return;
|
if (!selectedColumn) return;
|
||||||
if (field === "inputType") {
|
if (field === "inputType") {
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,13 @@ import { cn } from "@/lib/utils";
|
||||||
import type { ColumnTypeInfo, TableInfo, SecondLevelMenu } from "./types";
|
import type { ColumnTypeInfo, TableInfo, SecondLevelMenu } from "./types";
|
||||||
import { INPUT_TYPE_COLORS } from "./types";
|
import { INPUT_TYPE_COLORS } from "./types";
|
||||||
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
|
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||||
import type { NumberingRuleConfig } from "@/types/numbering-rule";
|
|
||||||
|
|
||||||
export interface ColumnDetailPanelProps {
|
export interface ColumnDetailPanelProps {
|
||||||
column: ColumnTypeInfo | null;
|
column: ColumnTypeInfo | null;
|
||||||
tables: TableInfo[];
|
tables: TableInfo[];
|
||||||
referenceTableColumns: Record<string, ReferenceTableColumn[]>;
|
referenceTableColumns: Record<string, ReferenceTableColumn[]>;
|
||||||
secondLevelMenus: SecondLevelMenu[];
|
secondLevelMenus: SecondLevelMenu[];
|
||||||
numberingRules: NumberingRuleConfig[];
|
numberingRules: any[];
|
||||||
onColumnChange: (field: keyof ColumnTypeInfo, value: unknown) => void;
|
onColumnChange: (field: keyof ColumnTypeInfo, value: unknown) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onLoadReferenceColumns?: (tableName: string) => void;
|
onLoadReferenceColumns?: (tableName: string) => void;
|
||||||
|
|
@ -53,7 +52,6 @@ export function ColumnDetailPanel({
|
||||||
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
||||||
const [entityTableOpen, setEntityTableOpen] = React.useState(false);
|
const [entityTableOpen, setEntityTableOpen] = React.useState(false);
|
||||||
const [entityColumnOpen, setEntityColumnOpen] = 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 typeConf = column ? INPUT_TYPE_COLORS[column.inputType || "text"] : null;
|
||||||
const refColumns = column?.referenceTable
|
const refColumns = column?.referenceTable
|
||||||
|
|
@ -404,53 +402,10 @@ export function ColumnDetailPanel({
|
||||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||||
<Label className="text-sm font-medium">채번 규칙</Label>
|
<Label className="text-sm font-medium">채번 규칙</Label>
|
||||||
</div>
|
</div>
|
||||||
<Popover open={numberingOpen} onOpenChange={setNumberingOpen}>
|
<p className="rounded-md border border-border bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||||
<PopoverTrigger asChild>
|
채번 규칙은 옵션설정 > 채번설정에서 관리합니다.
|
||||||
<Button variant="outline" className="h-9 w-full justify-between text-xs">
|
타입을 저장하면 자동으로 채번 목록에 표시됩니다.
|
||||||
{column.numberingRuleId
|
</p>
|
||||||
? 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>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -17,12 +11,39 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} 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 { toast } from "sonner";
|
||||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
|
||||||
import { apiClient } from "@/lib/api/client";
|
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 {
|
interface NodeFlow {
|
||||||
flowId: number;
|
flowId: number;
|
||||||
|
|
@ -30,18 +51,205 @@ interface NodeFlow {
|
||||||
flowDescription: string;
|
flowDescription: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
summary: FlowSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DataFlowListProps {
|
interface DataFlowListProps {
|
||||||
onLoadFlow: (flowId: number | null) => void;
|
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) {
|
export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
||||||
const { user } = useAuth();
|
|
||||||
const [flows, setFlows] = useState<NodeFlow[]>([]);
|
const [flows, setFlows] = useState<NodeFlow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [selectedFlow, setSelectedFlow] = useState<NodeFlow | null>(null);
|
const [selectedFlow, setSelectedFlow] = useState<NodeFlow | null>(null);
|
||||||
|
|
||||||
|
|
@ -49,7 +257,6 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await apiClient.get("/dataflow/node-flows");
|
const response = await apiClient.get("/dataflow/node-flows");
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
setFlows(response.data.data);
|
setFlows(response.data.data);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -57,7 +264,9 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("플로우 목록 조회 실패", error);
|
console.error("플로우 목록 조회 실패", error);
|
||||||
showErrorToast("플로우 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
|
showErrorToast("플로우 목록을 불러오는 데 실패했어요", error, {
|
||||||
|
guidance: "네트워크 연결을 확인해 주세요.",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -75,30 +284,26 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
||||||
const handleCopy = async (flow: NodeFlow) => {
|
const handleCopy = async (flow: NodeFlow) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const response = await apiClient.get(`/dataflow/node-flows/${flow.flowId}`);
|
const response = await apiClient.get(`/dataflow/node-flows/${flow.flowId}`);
|
||||||
|
if (!response.data.success) throw new Error(response.data.message || "플로우 조회 실패");
|
||||||
if (!response.data.success) {
|
|
||||||
throw new Error(response.data.message || "플로우 조회 실패");
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalFlow = response.data.data;
|
|
||||||
|
|
||||||
const copyResponse = await apiClient.post("/dataflow/node-flows", {
|
const copyResponse = await apiClient.post("/dataflow/node-flows", {
|
||||||
flowName: `${flow.flowName} (복사본)`,
|
flowName: `${flow.flowName} (복사본)`,
|
||||||
flowDescription: flow.flowDescription,
|
flowDescription: flow.flowDescription,
|
||||||
flowData: originalFlow.flowData,
|
flowData: response.data.data.flowData,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (copyResponse.data.success) {
|
if (copyResponse.data.success) {
|
||||||
toast.success(`플로우가 성공적으로 복사되었습니다`);
|
toast.success("플로우를 복사했어요");
|
||||||
await loadFlows();
|
await loadFlows();
|
||||||
} else {
|
} else {
|
||||||
throw new Error(copyResponse.data.message || "플로우 복사 실패");
|
throw new Error(copyResponse.data.message || "플로우 복사 실패");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("플로우 복사 실패:", error);
|
console.error("플로우 복사 실패:", error);
|
||||||
showErrorToast("플로우 복사에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
showErrorToast("플로우 복사에 실패했어요", error, {
|
||||||
|
guidance: "잠시 후 다시 시도해 주세요.",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -106,20 +311,20 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
||||||
|
|
||||||
const handleConfirmDelete = async () => {
|
const handleConfirmDelete = async () => {
|
||||||
if (!selectedFlow) return;
|
if (!selectedFlow) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await apiClient.delete(`/dataflow/node-flows/${selectedFlow.flowId}`);
|
const response = await apiClient.delete(`/dataflow/node-flows/${selectedFlow.flowId}`);
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
toast.success(`플로우가 삭제되었습니다: ${selectedFlow.flowName}`);
|
toast.success(`"${selectedFlow.flowName}" 플로우를 삭제했어요`);
|
||||||
await loadFlows();
|
await loadFlows();
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.data.message || "플로우 삭제 실패");
|
throw new Error(response.data.message || "플로우 삭제 실패");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("플로우 삭제 실패:", error);
|
console.error("플로우 삭제 실패:", error);
|
||||||
showErrorToast("플로우 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
showErrorToast("플로우 삭제에 실패했어요", error, {
|
||||||
|
guidance: "잠시 후 다시 시도해 주세요.",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setShowDeleteModal(false);
|
setShowDeleteModal(false);
|
||||||
|
|
@ -127,170 +332,241 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredFlows = flows.filter(
|
const filteredFlows = useMemo(
|
||||||
(flow) =>
|
() =>
|
||||||
flow.flowName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
flows.filter(
|
||||||
flow.flowDescription.toLowerCase().includes(searchTerm.toLowerCase()),
|
(f) =>
|
||||||
|
f.flowName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(f.flowDescription || "").toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
),
|
||||||
|
[flows, searchTerm],
|
||||||
);
|
);
|
||||||
|
|
||||||
// DropdownMenu 렌더러 (테이블 + 카드 공통)
|
const stats = useMemo(() => {
|
||||||
const renderDropdownMenu = (flow: NodeFlow) => (
|
let totalNodes = 0;
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
let totalEdges = 0;
|
||||||
<DropdownMenu>
|
flows.forEach((f) => {
|
||||||
<DropdownMenuTrigger asChild>
|
totalNodes += f.summary?.nodeCount || 0;
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
totalEdges += f.summary?.edgeCount || 0;
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
});
|
||||||
</Button>
|
return { total: flows.length, totalNodes, totalEdges };
|
||||||
</DropdownMenuTrigger>
|
}, [flows]);
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => onLoadFlow(flow.flowId)}>
|
if (loading && flows.length === 0) {
|
||||||
<Network className="mr-2 h-4 w-4" />
|
return (
|
||||||
불러오기
|
<div className="flex h-64 items-center justify-center">
|
||||||
</DropdownMenuItem>
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
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(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-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-start sm:justify-between">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
<div>
|
||||||
<div className="w-full sm:w-[400px]">
|
<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">
|
||||||
<div className="relative">
|
제어 관리
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
</h1>
|
||||||
|
<p className="mt-1 text-xs text-zinc-500 sm:text-sm">
|
||||||
|
노드 기반 데이터 플로우를 시각적으로 설계하고 관리해요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 통계 스트립 */}
|
||||||
|
<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
|
<Input
|
||||||
placeholder="플로우명, 설명으로 검색..."
|
placeholder="플로우 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="h-10 pl-10 text-sm"
|
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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
{/* 컨텐츠 */}
|
||||||
<div className="text-sm text-muted-foreground">
|
{filteredFlows.length === 0 ? (
|
||||||
총 <span className="font-semibold text-foreground">{filteredFlows.length}</span> 건
|
<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>
|
</div>
|
||||||
<Button onClick={() => onLoadFlow(null)} className="h-10 gap-2 text-sm font-medium">
|
<h2 className="mb-2 text-lg font-bold text-zinc-200">
|
||||||
<Plus className="h-4 w-4" />
|
{searchTerm ? "검색 결과가 없어요" : "아직 플로우가 없어요"}
|
||||||
새 플로우 생성
|
</h2>
|
||||||
</Button>
|
<p className="mb-6 max-w-sm text-sm leading-relaxed text-zinc-500">
|
||||||
</div>
|
{searchTerm
|
||||||
</div>
|
? `"${searchTerm}"에 해당하는 플로우를 찾지 못했어요. 다른 키워드로 검색해 보세요.`
|
||||||
|
: "노드를 연결해서 데이터 처리 파이프라인을 만들어 보세요. 코드 없이 드래그 앤 드롭만으로 설계할 수 있어요."}
|
||||||
{/* 빈 상태: 커스텀 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>
|
</p>
|
||||||
<Button onClick={() => onLoadFlow(null)} className="mt-4 h-10 gap-2 text-sm font-medium">
|
{!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" />
|
<Plus className="h-4 w-4" />
|
||||||
새 플로우 생성
|
첫 번째 플로우 만들기
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveDataView<NodeFlow>
|
<div className="space-y-2">
|
||||||
data={filteredFlows}
|
{filteredFlows.map((flow) => (
|
||||||
columns={columns}
|
<div
|
||||||
keyExtractor={(flow) => String(flow.flowId)}
|
key={flow.flowId}
|
||||||
isLoading={loading}
|
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"
|
||||||
skeletonCount={5}
|
onClick={() => onLoadFlow(flow.flowId)}
|
||||||
cardTitle={(flow) => (
|
>
|
||||||
<span className="flex items-center">
|
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-violet-500/10">
|
||||||
<Network className="mr-2 h-4 w-4 text-primary" />
|
<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}
|
{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>
|
</span>
|
||||||
)}
|
);
|
||||||
cardSubtitle={(flow) => flow.flowDescription || "설명 없음"}
|
})}
|
||||||
cardHeaderRight={renderDropdownMenu}
|
</div>
|
||||||
cardFields={cardFields}
|
<span className="hidden font-mono text-[11px] text-zinc-600 sm:block">
|
||||||
actionsLabel="작업"
|
{relativeTime(flow.updatedAt)}
|
||||||
actionsWidth="80px"
|
</span>
|
||||||
renderActions={renderDropdownMenu}
|
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||||
onRowClick={(flow) => onLoadFlow(flow.flowId)}
|
<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}>
|
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-base sm:text-lg">플로우 삭제</DialogTitle>
|
<DialogTitle className="text-base sm:text-lg">플로우를 삭제할까요?</DialogTitle>
|
||||||
<DialogDescription className="text-xs sm:text-sm">
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
“{selectedFlow?.flowName}” 플로우를 완전히 삭제하시겠습니까?
|
“{selectedFlow?.flowName}” 플로우가 완전히 삭제돼요.
|
||||||
<br />
|
<br />
|
||||||
<span className="font-medium text-destructive">
|
<span className="text-destructive font-medium">
|
||||||
이 작업은 되돌릴 수 없으며, 모든 플로우 정보가 영구적으로 삭제됩니다.
|
이 작업은 되돌릴 수 없으며, 모든 노드와 연결 정보가 함께 삭제돼요.
|
||||||
</span>
|
</span>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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 { 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 "reactflow/dist/style.css";
|
||||||
|
|
||||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { NodePalette } from "./sidebar/NodePalette";
|
import { CommandPalette } from "./CommandPalette";
|
||||||
import { LeftV2Toolbar, ToolbarButton } from "@/components/screen/toolbar/LeftV2Toolbar";
|
import { SlideOverSheet } from "./SlideOverSheet";
|
||||||
import { Boxes, Settings } from "lucide-react";
|
import { FlowBreadcrumb } from "./FlowBreadcrumb";
|
||||||
import { PropertiesPanel } from "./panels/PropertiesPanel";
|
import { NodeContextMenu } from "./NodeContextMenu";
|
||||||
import { ValidationNotification } from "./ValidationNotification";
|
import { ValidationNotification } from "./ValidationNotification";
|
||||||
import { FlowToolbar } from "./FlowToolbar";
|
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 { TableSourceNode } from "./nodes/TableSourceNode";
|
||||||
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
|
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
|
||||||
import { ConditionNode } from "./nodes/ConditionNode";
|
import { ConditionNode } from "./nodes/ConditionNode";
|
||||||
|
|
@ -36,70 +48,116 @@ import { ProcedureCallActionNode } from "./nodes/ProcedureCallActionNode";
|
||||||
import { validateFlow } from "@/lib/utils/flowValidation";
|
import { validateFlow } from "@/lib/utils/flowValidation";
|
||||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||||
|
|
||||||
// 노드 타입들
|
|
||||||
const nodeTypes = {
|
const nodeTypes = {
|
||||||
// 데이터 소스
|
|
||||||
tableSource: TableSourceNode,
|
tableSource: TableSourceNode,
|
||||||
externalDBSource: ExternalDBSourceNode,
|
externalDBSource: ExternalDBSourceNode,
|
||||||
restAPISource: RestAPISourceNode,
|
restAPISource: RestAPISourceNode,
|
||||||
// 변환/조건
|
|
||||||
condition: ConditionNode,
|
condition: ConditionNode,
|
||||||
dataTransform: DataTransformNode,
|
dataTransform: DataTransformNode,
|
||||||
aggregate: AggregateNode,
|
aggregate: AggregateNode,
|
||||||
formulaTransform: FormulaTransformNode,
|
formulaTransform: FormulaTransformNode,
|
||||||
// 데이터 액션
|
|
||||||
insertAction: InsertActionNode,
|
insertAction: InsertActionNode,
|
||||||
updateAction: UpdateActionNode,
|
updateAction: UpdateActionNode,
|
||||||
deleteAction: DeleteActionNode,
|
deleteAction: DeleteActionNode,
|
||||||
upsertAction: UpsertActionNode,
|
upsertAction: UpsertActionNode,
|
||||||
// 외부 연동 액션
|
|
||||||
emailAction: EmailActionNode,
|
emailAction: EmailActionNode,
|
||||||
scriptAction: ScriptActionNode,
|
scriptAction: ScriptActionNode,
|
||||||
httpRequestAction: HttpRequestActionNode,
|
httpRequestAction: HttpRequestActionNode,
|
||||||
procedureCallAction: ProcedureCallActionNode,
|
procedureCallAction: ProcedureCallActionNode,
|
||||||
// 유틸리티
|
|
||||||
comment: CommentNode,
|
comment: CommentNode,
|
||||||
log: LogNode,
|
log: LogNode,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* FlowEditor 내부 컴포넌트
|
|
||||||
*/
|
|
||||||
interface FlowEditorInnerProps {
|
interface FlowEditorInnerProps {
|
||||||
initialFlowId?: number | null;
|
initialFlowId?: number | null;
|
||||||
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
|
|
||||||
onSaveComplete?: (flowId: number, flowName: string) => void;
|
onSaveComplete?: (flowId: number, flowName: string) => void;
|
||||||
/** 임베디드 모드 여부 */
|
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 플로우 에디터 툴바 버튼 설정
|
function getDefaultNodeData(type: string): Record<string, any> {
|
||||||
const flowToolbarButtons: ToolbarButton[] = [
|
const paletteItem = getNodePaletteItem(type);
|
||||||
{
|
const base: Record<string, any> = {
|
||||||
id: "nodes",
|
displayName: paletteItem?.label || `새 ${type} 노드`,
|
||||||
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 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 reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||||
const { screenToFlowPosition, setCenter } = useReactFlow();
|
const { screenToFlowPosition, setCenter, getViewport } = useReactFlow();
|
||||||
|
|
||||||
// 패널 표시 상태
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||||
const [showNodesPanel, setShowNodesPanel] = useState(true);
|
const [slideOverOpen, setSlideOverOpen] = useState(false);
|
||||||
const [showPropertiesPanelLocal, setShowPropertiesPanelLocal] = useState(false);
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
nodeId: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
nodes,
|
nodes,
|
||||||
|
|
@ -117,12 +175,11 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
||||||
loadFlow,
|
loadFlow,
|
||||||
} = useFlowEditorStore();
|
} = useFlowEditorStore();
|
||||||
|
|
||||||
// 🆕 실시간 플로우 검증
|
const validations = useMemo<FlowValidation[]>(
|
||||||
const validations = useMemo<FlowValidation[]>(() => {
|
() => validateFlow(nodes, edges),
|
||||||
return validateFlow(nodes, edges);
|
[nodes, edges],
|
||||||
}, [nodes, edges]);
|
);
|
||||||
|
|
||||||
// 🆕 노드 클릭 핸들러 (검증 패널에서 사용)
|
|
||||||
const handleValidationNodeClick = useCallback(
|
const handleValidationNodeClick = useCallback(
|
||||||
(nodeId: string) => {
|
(nodeId: string) => {
|
||||||
const node = nodes.find((n) => n.id === nodeId);
|
const node = nodes.find((n) => n.id === nodeId);
|
||||||
|
|
@ -137,23 +194,27 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
||||||
[nodes, selectNodes, setCenter],
|
[nodes, selectNodes, setCenter],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 속성 패널 상태 동기화
|
// 노드 선택 시 속성 패널 열기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedNodes.length > 0 && !showPropertiesPanelLocal) {
|
if (selectedNodes.length > 0) {
|
||||||
setShowPropertiesPanelLocal(true);
|
setSlideOverOpen(true);
|
||||||
}
|
}
|
||||||
}, [selectedNodes, showPropertiesPanelLocal]);
|
}, [selectedNodes]);
|
||||||
|
|
||||||
// 초기 플로우 로드
|
// 플로우 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchAndLoadFlow = async () => {
|
const fetchAndLoadFlow = async () => {
|
||||||
if (initialFlowId) {
|
if (initialFlowId) {
|
||||||
try {
|
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) {
|
if (response.data.success && response.data.data) {
|
||||||
const flow = 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(
|
loadFlow(
|
||||||
flow.flowId,
|
flow.flowId,
|
||||||
|
|
@ -162,73 +223,174 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
||||||
flowData.nodes || [],
|
flowData.nodes || [],
|
||||||
flowData.edges || [],
|
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) {
|
} catch (error) {
|
||||||
console.error("플로우 로드 실패:", error);
|
console.error("플로우 로드 실패:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAndLoadFlow();
|
fetchAndLoadFlow();
|
||||||
}, [initialFlowId, loadFlow, selectNodes]);
|
}, [initialFlowId, loadFlow]);
|
||||||
|
|
||||||
/**
|
|
||||||
* 노드 선택 변경 핸들러
|
|
||||||
*/
|
|
||||||
const onSelectionChange = useCallback(
|
const onSelectionChange = useCallback(
|
||||||
({ nodes: selectedNodes }: { nodes: any[] }) => {
|
({ nodes: selected }: { nodes: any[] }) => {
|
||||||
const selectedIds = selectedNodes.map((node) => node.id);
|
const selectedIds = selected.map((n) => n.id);
|
||||||
selectNodes(selectedIds);
|
selectNodes(selectedIds);
|
||||||
console.log("🔍 선택된 노드:", selectedIds);
|
|
||||||
},
|
},
|
||||||
[selectNodes],
|
[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(
|
const onKeyDown = useCallback(
|
||||||
(event: React.KeyboardEvent) => {
|
(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) {
|
if ((event.ctrlKey || event.metaKey) && event.key === "z" && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
console.log("⏪ Undo");
|
|
||||||
undo();
|
undo();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redo: Ctrl+Y (Windows/Linux) or Cmd+Shift+Z (Mac) or Ctrl+Shift+Z
|
|
||||||
if (
|
if (
|
||||||
((event.ctrlKey || event.metaKey) && event.key === "y") ||
|
((event.ctrlKey || event.metaKey) && event.key === "y") ||
|
||||||
((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "z")
|
((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "z")
|
||||||
) {
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
console.log("⏩ Redo");
|
|
||||||
redo();
|
redo();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete: Delete/Backspace 키로 노드 삭제
|
if (
|
||||||
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodes.length > 0) {
|
(event.key === "Delete" || event.key === "Backspace") &&
|
||||||
|
selectedNodes.length > 0 &&
|
||||||
|
!isInput
|
||||||
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
console.log("🗑️ 선택된 노드 삭제:", selectedNodes);
|
|
||||||
removeNodes(selectedNodes);
|
removeNodes(selectedNodes);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedNodes, removeNodes, undo, redo],
|
[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) => {
|
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = "move";
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
|
@ -237,7 +399,6 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(event: React.DragEvent) => {
|
(event: React.DragEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const type = event.dataTransfer.getData("application/reactflow");
|
const type = event.dataTransfer.getData("application/reactflow");
|
||||||
if (!type) return;
|
if (!type) return;
|
||||||
|
|
||||||
|
|
@ -246,84 +407,11 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
||||||
y: event.clientY,
|
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 = {
|
const newNode: any = {
|
||||||
id: `node_${Date.now()}`,
|
id: `node_${Date.now()}`,
|
||||||
type,
|
type,
|
||||||
position,
|
position,
|
||||||
data: defaultData,
|
data: getDefaultNodeData(type),
|
||||||
};
|
};
|
||||||
|
|
||||||
addNode(newNode);
|
addNode(newNode);
|
||||||
|
|
@ -332,32 +420,17 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full" style={{ height: "100%", overflow: "hidden" }}>
|
<div
|
||||||
{/* 좌측 통합 툴바 */}
|
className="relative flex h-full w-full"
|
||||||
<LeftV2Toolbar
|
style={{ height: "100%", overflow: "hidden" }}
|
||||||
buttons={flowToolbarButtons}
|
>
|
||||||
panelStates={{
|
{/* 100% 캔버스 */}
|
||||||
nodes: { isOpen: showNodesPanel },
|
<div
|
||||||
properties: { isOpen: showPropertiesPanelLocal },
|
className="relative flex-1"
|
||||||
}}
|
ref={reactFlowWrapper}
|
||||||
onTogglePanel={(panelId) => {
|
onKeyDown={onKeyDown}
|
||||||
if (panelId === "nodes") {
|
tabIndex={0}
|
||||||
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}>
|
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes as any}
|
nodes={nodes as any}
|
||||||
edges={edges as any}
|
edges={edges as any}
|
||||||
|
|
@ -366,69 +439,111 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
onNodeDragStart={onNodeDragStart}
|
onNodeDragStart={onNodeDragStart}
|
||||||
onSelectionChange={onSelectionChange}
|
onSelectionChange={onSelectionChange}
|
||||||
|
onNodeDoubleClick={onNodeDoubleClick}
|
||||||
|
onNodeContextMenu={onNodeContextMenu}
|
||||||
|
onPaneContextMenu={onPaneContextMenu}
|
||||||
|
onPaneClick={() => setContextMenu(null)}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
fitView
|
fitView
|
||||||
className="bg-muted"
|
className="bg-zinc-950"
|
||||||
deleteKeyCode={["Delete", "Backspace"]}
|
deleteKeyCode={["Delete", "Backspace"]}
|
||||||
>
|
>
|
||||||
{/* 배경 그리드 */}
|
<Background gap={20} size={1} color="#27272a" />
|
||||||
<Background gap={16} size={1} color="#E5E7EB" />
|
|
||||||
|
|
||||||
{/* 컨트롤 버튼 */}
|
<Controls
|
||||||
<Controls className="bg-white shadow-md" />
|
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="bg-white shadow-md"
|
|
||||||
nodeColor={(node) => {
|
|
||||||
// 노드 타입별 색상 (추후 구현)
|
|
||||||
return "#3B82F6";
|
|
||||||
}}
|
|
||||||
maskColor="rgba(0, 0, 0, 0.1)"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 상단 툴바 */}
|
<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">
|
<Panel position="top-center" className="pointer-events-auto">
|
||||||
<FlowToolbar validations={validations} onSaveComplete={onSaveComplete} />
|
<FlowToolbar
|
||||||
|
validations={validations}
|
||||||
|
onSaveComplete={onSaveComplete}
|
||||||
|
onOpenCommandPalette={() => setCommandPaletteOpen(true)}
|
||||||
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측 속성 패널 */}
|
{/* Slide-over 속성 패널 */}
|
||||||
{showPropertiesPanelLocal && selectedNodes.length > 0 && (
|
<SlideOverSheet
|
||||||
<div
|
isOpen={slideOverOpen && selectedNodes.length > 0}
|
||||||
style={{
|
onClose={() => setSlideOverOpen(false)}
|
||||||
height: "100%",
|
/>
|
||||||
width: "350px",
|
|
||||||
display: "flex",
|
{/* Command Palette */}
|
||||||
flexDirection: "column",
|
<CommandPalette
|
||||||
}}
|
isOpen={commandPaletteOpen}
|
||||||
className="border-l bg-white"
|
onClose={() => setCommandPaletteOpen(false)}
|
||||||
>
|
onSelectNode={handleCommandSelect}
|
||||||
<PropertiesPanel />
|
/>
|
||||||
</div>
|
|
||||||
|
{/* 노드 우클릭 컨텍스트 메뉴 */}
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* FlowEditor 메인 컴포넌트 (Provider로 감싸기)
|
|
||||||
*/
|
|
||||||
interface FlowEditorProps {
|
interface FlowEditorProps {
|
||||||
initialFlowId?: number | null;
|
initialFlowId?: number | null;
|
||||||
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
|
|
||||||
onSaveComplete?: (flowId: number, flowName: string) => void;
|
onSaveComplete?: (flowId: number, flowName: string) => void;
|
||||||
/** 임베디드 모드 여부 (헤더 표시 여부 등) */
|
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FlowEditor({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorProps = {}) {
|
export function FlowEditor({
|
||||||
|
initialFlowId,
|
||||||
|
onSaveComplete,
|
||||||
|
embedded = false,
|
||||||
|
}: FlowEditorProps = {}) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* 플로우 에디터 상단 툴바
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Save, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react";
|
import {
|
||||||
import { Button } from "@/components/ui/button";
|
Save,
|
||||||
|
Undo2,
|
||||||
|
Redo2,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
Maximize2,
|
||||||
|
Download,
|
||||||
|
Trash2,
|
||||||
|
Plus,
|
||||||
|
} from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
import { useReactFlow } from "reactflow";
|
import { useReactFlow } from "reactflow";
|
||||||
|
|
@ -17,11 +22,15 @@ import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
interface FlowToolbarProps {
|
interface FlowToolbarProps {
|
||||||
validations?: FlowValidation[];
|
validations?: FlowValidation[];
|
||||||
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
|
|
||||||
onSaveComplete?: (flowId: number, flowName: string) => void;
|
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 { toast } = useToast();
|
||||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||||
const {
|
const {
|
||||||
|
|
@ -42,9 +51,7 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
|
||||||
|
|
||||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||||
|
|
||||||
// Ctrl+S 단축키: 플로우 저장
|
|
||||||
const handleSaveRef = useRef<() => void>();
|
const handleSaveRef = useRef<() => void>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleSaveRef.current = handleSave;
|
handleSaveRef.current = handleSave;
|
||||||
});
|
});
|
||||||
|
|
@ -53,28 +60,20 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!isSaving) {
|
if (!isSaving) handleSaveRef.current?.();
|
||||||
handleSaveRef.current?.();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [isSaving]);
|
}, [isSaving]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
// 검증 수행
|
const currentValidations =
|
||||||
const currentValidations = validations.length > 0 ? validations : validateFlow(nodes, edges);
|
validations.length > 0 ? validations : validateFlow(nodes, edges);
|
||||||
const summary = summarizeValidations(currentValidations);
|
|
||||||
|
|
||||||
// 오류나 경고가 있으면 다이얼로그 표시
|
|
||||||
if (currentValidations.length > 0) {
|
if (currentValidations.length > 0) {
|
||||||
setShowSaveDialog(true);
|
setShowSaveDialog(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 문제 없으면 바로 저장
|
|
||||||
await performSave();
|
await performSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -82,27 +81,22 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
|
||||||
const result = await saveFlow();
|
const result = await saveFlow();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast({
|
toast({
|
||||||
title: "저장 완료",
|
title: "저장했어요",
|
||||||
description: `${result.message}\nFlow ID: ${result.flowId}`,
|
description: `플로우가 안전하게 저장됐어요`,
|
||||||
variant: "default",
|
variant: "default",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 임베디드 모드에서 저장 완료 콜백 호출
|
|
||||||
if (onSaveComplete && result.flowId) {
|
if (onSaveComplete && result.flowId) {
|
||||||
onSaveComplete(result.flowId, flowName);
|
onSaveComplete(result.flowId, flowName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 부모 창이 있으면 postMessage로 알림 (새 창에서 열린 경우)
|
|
||||||
if (window.opener && result.flowId) {
|
if (window.opener && result.flowId) {
|
||||||
window.opener.postMessage({
|
window.opener.postMessage(
|
||||||
type: "FLOW_SAVED",
|
{ type: "FLOW_SAVED", flowId: result.flowId, flowName },
|
||||||
flowId: result.flowId,
|
"*",
|
||||||
flowName: flowName,
|
);
|
||||||
}, "*");
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: "저장 실패",
|
title: "저장에 실패했어요",
|
||||||
description: result.message,
|
description: result.message,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
|
|
@ -120,102 +114,128 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
toast({
|
toast({
|
||||||
title: "✅ 내보내기 완료",
|
title: "내보내기 완료",
|
||||||
description: "JSON 파일로 저장되었습니다.",
|
description: "JSON 파일로 저장했어요",
|
||||||
variant: "default",
|
variant: "default",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (selectedNodes.length === 0) {
|
if (selectedNodes.length === 0) return;
|
||||||
toast({
|
|
||||||
title: "⚠️ 선택된 노드 없음",
|
|
||||||
description: "삭제할 노드를 선택해주세요.",
|
|
||||||
variant: "default",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (confirm(`선택된 ${selectedNodes.length}개 노드를 삭제하시겠습니까?`)) {
|
|
||||||
removeNodes(selectedNodes);
|
removeNodes(selectedNodes);
|
||||||
toast({
|
toast({
|
||||||
title: "✅ 노드 삭제 완료",
|
title: "노드를 삭제했어요",
|
||||||
description: `${selectedNodes.length}개 노드가 삭제되었습니다.`,
|
description: `${selectedNodes.length}개 노드가 삭제됐어요`,
|
||||||
variant: "default",
|
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 (
|
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
|
<Input
|
||||||
value={flowName}
|
value={flowName}
|
||||||
onChange={(e) => setFlowName(e.target.value)}
|
onChange={(e) => setFlowName(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
// 입력 필드에서 키 이벤트가 FlowEditor로 전파되지 않도록 방지
|
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"
|
||||||
// FlowEditor의 Backspace/Delete 키로 노드가 삭제되는 것을 막음
|
placeholder="플로우 이름을 입력해요"
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
className="h-8 w-[200px] text-sm"
|
|
||||||
placeholder="플로우 이름"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="h-6 w-px bg-border" />
|
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
|
||||||
|
|
||||||
{/* 실행 취소/다시 실행 */}
|
{/* Undo / Redo */}
|
||||||
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
|
<ToolBtn onClick={undo} disabled={!canUndo()} title="실행 취소 (Ctrl+Z)">
|
||||||
<Undo2 className="h-4 w-4" />
|
<Undo2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</ToolBtn>
|
||||||
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
|
<ToolBtn onClick={redo} disabled={!canRedo()} title="다시 실행 (Ctrl+Y)">
|
||||||
<Redo2 className="h-4 w-4" />
|
<Redo2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 삭제 버튼 */}
|
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
|
||||||
<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="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>
|
||||||
|
|
||||||
{/* 줌 컨트롤 */}
|
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
|
||||||
<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" />
|
|
||||||
|
|
||||||
{/* 저장 */}
|
{/* 저장 */}
|
||||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-1">
|
<button
|
||||||
<Save className="h-4 w-4" />
|
onClick={handleSave}
|
||||||
<span className="text-xs">{isSaving ? "저장 중..." : "저장"}</span>
|
disabled={isSaving}
|
||||||
</Button>
|
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>
|
||||||
|
|
||||||
{/* 내보내기 */}
|
{/* JSON 내보내기 */}
|
||||||
<Button variant="outline" size="sm" onClick={handleExport} className="gap-1">
|
<ToolBtn onClick={handleExport} title="JSON 내보내기">
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
<span className="text-xs">JSON</span>
|
</ToolBtn>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 저장 확인 다이얼로그 */}
|
|
||||||
<SaveConfirmDialog
|
<SaveConfirmDialog
|
||||||
open={showSaveDialog}
|
open={showSaveDialog}
|
||||||
validations={validations.length > 0 ? validations : validateFlow(nodes, edges)}
|
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";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* 집계 노드 (Aggregate Node)
|
|
||||||
* SUM, COUNT, AVG, MIN, MAX 등 집계 연산을 수행
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
import { NodeProps } from "reactflow";
|
||||||
import { Calculator, Layers } from "lucide-react";
|
import { BarChart3 } from "lucide-react";
|
||||||
import type { AggregateNodeData, AggregateFunction } from "@/types/node-editor";
|
import { CompactNodeShell } from "./CompactNodeShell";
|
||||||
|
import type { AggregateNodeData } from "@/types/node-editor";
|
||||||
// 집계 함수별 아이콘/라벨
|
|
||||||
const AGGREGATE_FUNCTION_LABELS: Record<AggregateFunction, string> = {
|
|
||||||
SUM: "합계",
|
|
||||||
COUNT: "개수",
|
|
||||||
AVG: "평균",
|
|
||||||
MIN: "최소",
|
|
||||||
MAX: "최대",
|
|
||||||
FIRST: "첫번째",
|
|
||||||
LAST: "마지막",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AggregateNode = memo(({ data, selected }: NodeProps<AggregateNodeData>) => {
|
export const AggregateNode = memo(({ data, selected }: NodeProps<AggregateNodeData>) => {
|
||||||
const groupByCount = data.groupByFields?.length || 0;
|
const opCount = data.operations?.length || 0;
|
||||||
const aggregationCount = data.aggregations?.length || 0;
|
const groupCount = data.groupByFields?.length || 0;
|
||||||
|
const summary = opCount > 0
|
||||||
|
? `${opCount}개 연산${groupCount > 0 ? `, ${groupCount}개 그룹` : ""}`
|
||||||
|
: "집계 연산을 설정해 주세요";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CompactNodeShell
|
||||||
className={`min-w-[280px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
color="#A855F7"
|
||||||
selected ? "border-purple-500 shadow-lg" : "border-border"
|
label={data.displayName || "집계"}
|
||||||
}`}
|
summary={summary}
|
||||||
|
icon={<BarChart3 className="h-3.5 w-3.5" />}
|
||||||
|
selected={selected}
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{opCount > 0 && (
|
||||||
<div className="flex items-center gap-2 rounded-t-lg bg-purple-600 px-3 py-2 text-white">
|
<div className="space-y-0.5">
|
||||||
<Calculator className="h-4 w-4" />
|
{data.operations!.slice(0, 3).map((op: any, i: number) => (
|
||||||
<div className="flex-1">
|
<div key={i} className="flex items-center gap-1.5">
|
||||||
<div className="text-sm font-semibold">{data.displayName || "집계"}</div>
|
<span className="rounded bg-violet-500/20 px-1 py-0.5 font-mono text-[9px] font-semibold text-violet-400">
|
||||||
<div className="text-xs opacity-80">
|
{op.function || op.operation}
|
||||||
{groupByCount > 0 ? `${groupByCount}개 그룹` : "전체"} / {aggregationCount}개 집계
|
|
||||||
</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>
|
</span>
|
||||||
))}
|
<span>{op.field || op.sourceField}</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{data.aggregations.length > 4 && (
|
|
||||||
<div className="text-xs text-muted-foreground/70 text-center">
|
|
||||||
... 외 {data.aggregations.length - 4}개
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CompactNodeShell>
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
AggregateNode.displayName = "AggregateNode";
|
AggregateNode.displayName = "AggregateNode";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,21 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* 주석 노드 - 플로우 설명용
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { NodeProps } from "reactflow";
|
import { NodeProps } from "reactflow";
|
||||||
import { MessageSquare } from "lucide-react";
|
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 (
|
return (
|
||||||
<div
|
<CompactNodeShell
|
||||||
className={`max-w-[350px] min-w-[200px] rounded-lg border-2 border-dashed bg-amber-50 shadow-sm transition-all ${
|
color="#6B7280"
|
||||||
selected ? "border-yellow-500 shadow-md" : "border-amber-300"
|
label="메모"
|
||||||
}`}
|
summary={data.comment || data.text || "메모를 작성해 주세요"}
|
||||||
>
|
icon={<MessageSquare className="h-3.5 w-3.5" />}
|
||||||
<div className="p-3">
|
selected={selected}
|
||||||
<div className="mb-2 flex items-center gap-2">
|
hasInput={false}
|
||||||
<MessageSquare className="h-4 w-4 text-amber-600" />
|
hasOutput={false}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* 조건 분기 노드
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
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";
|
import type { ConditionNodeData } from "@/types/node-editor";
|
||||||
|
|
||||||
const OPERATOR_LABELS: Record<string, string> = {
|
const OPERATOR_LABELS: Record<string, string> = {
|
||||||
EQUALS: "=",
|
EQUALS: "=", NOT_EQUALS: "!=",
|
||||||
NOT_EQUALS: "≠",
|
GREATER_THAN: ">", LESS_THAN: "<",
|
||||||
GREATER_THAN: ">",
|
GREATER_THAN_OR_EQUAL: ">=", LESS_THAN_OR_EQUAL: "<=",
|
||||||
LESS_THAN: "<",
|
LIKE: "포함", NOT_LIKE: "미포함",
|
||||||
GREATER_THAN_OR_EQUAL: "≥",
|
IN: "IN", NOT_IN: "NOT IN",
|
||||||
LESS_THAN_OR_EQUAL: "≤",
|
IS_NULL: "NULL", IS_NOT_NULL: "NOT NULL",
|
||||||
LIKE: "포함",
|
EXISTS_IN: "EXISTS", NOT_EXISTS_IN: "NOT EXISTS",
|
||||||
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";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeData>) => {
|
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||||
|
const condCount = data.conditions?.length || 0;
|
||||||
|
const summary = condCount > 0
|
||||||
|
? `${condCount}개 조건 (${data.logic || "AND"})`
|
||||||
|
: "조건을 설정해 주세요";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`min-w-[280px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
className={`rounded-lg border bg-zinc-900 shadow-lg transition-all ${
|
||||||
selected ? "border-yellow-500 shadow-lg" : "border-border"
|
selected ? "border-violet-500 shadow-violet-500/20" : "border-zinc-700"
|
||||||
}`}
|
}`}
|
||||||
|
style={{ minWidth: "260px", maxWidth: "320px" }}
|
||||||
>
|
>
|
||||||
{/* 입력 핸들 */}
|
<Handle
|
||||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-yellow-500 !bg-white" />
|
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">
|
<div className="flex items-center gap-2.5 px-3 py-2.5">
|
||||||
<Zap className="h-4 w-4" />
|
<div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-amber-500/20">
|
||||||
<div className="flex-1">
|
<GitBranch className="h-3.5 w-3.5 text-amber-400" />
|
||||||
<div className="text-sm font-semibold">조건 검사</div>
|
</div>
|
||||||
<div className="text-xs opacity-80">{data.displayName || "조건 분기"}</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>
|
</div>
|
||||||
|
|
||||||
{/* 본문 */}
|
{/* 조건 미리보기 */}
|
||||||
<div className="p-3">
|
{condCount > 0 && (
|
||||||
{data.conditions && data.conditions.length > 0 ? (
|
<div className="space-y-0.5 border-t border-zinc-800 px-3 py-2 text-[10px] text-zinc-400">
|
||||||
<div className="space-y-2">
|
{data.conditions!.slice(0, 2).map((c, i) => (
|
||||||
<div className="text-xs font-medium text-foreground">조건식: ({data.conditions.length}개)</div>
|
<div key={i} className="flex items-center gap-1 flex-wrap">
|
||||||
<div className="max-h-[150px] space-y-1.5 overflow-y-auto">
|
{i > 0 && <span className="text-amber-500">{data.logic}</span>}
|
||||||
{data.conditions.slice(0, 4).map((condition, idx) => (
|
<span className="font-mono text-zinc-300">{c.field}</span>
|
||||||
<div key={idx} className="rounded bg-amber-50 px-2 py-1.5 text-xs">
|
<span className="text-amber-400">{OPERATOR_LABELS[c.operator] || c.operator}</span>
|
||||||
{idx > 0 && (
|
{c.value !== undefined && c.value !== null && (
|
||||||
<div className="mb-1 text-center text-xs font-semibold text-amber-600">{data.logic}</div>
|
<span className="text-zinc-500">{String(c.value)}</span>
|
||||||
)}
|
)}
|
||||||
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{data.conditions.length > 4 && (
|
{condCount > 2 && <span className="text-zinc-600">외 {condCount - 2}개</span>}
|
||||||
<div className="text-xs text-muted-foreground/70">... 외 {data.conditions.length - 4}개</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center text-xs text-muted-foreground/70">조건 없음</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 분기 출력 핸들 */}
|
{/* 분기 출력 */}
|
||||||
<div className="relative border-t">
|
<div className="border-t border-zinc-800">
|
||||||
{/* TRUE 출력 - 오른쪽 위 */}
|
<div className="relative flex items-center justify-end px-3 py-1.5">
|
||||||
<div className="relative border-b p-2">
|
<span className="text-[10px] font-medium text-emerald-400">통과</span>
|
||||||
<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>
|
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
id="true"
|
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>
|
</div>
|
||||||
|
<div className="relative flex items-center justify-end border-t border-zinc-800/50 px-3 py-1.5">
|
||||||
{/* FALSE 출력 - 오른쪽 아래 */}
|
<span className="text-[10px] font-medium text-pink-400">미통과</span>
|
||||||
<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>
|
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
id="false"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,87 +1,38 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* 데이터 변환 노드
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
import { NodeProps } from "reactflow";
|
||||||
import { Wand2, ArrowRight } from "lucide-react";
|
import { Repeat } from "lucide-react";
|
||||||
|
import { CompactNodeShell } from "./CompactNodeShell";
|
||||||
import type { DataTransformNodeData } from "@/types/node-editor";
|
import type { DataTransformNodeData } from "@/types/node-editor";
|
||||||
|
|
||||||
export const DataTransformNode = memo(({ data, selected }: NodeProps<DataTransformNodeData>) => {
|
export const DataTransformNode = memo(({ data, selected }: NodeProps<DataTransformNodeData>) => {
|
||||||
|
const ruleCount = data.transformRules?.length || 0;
|
||||||
|
const summary = ruleCount > 0
|
||||||
|
? `${ruleCount}개 변환 규칙`
|
||||||
|
: "변환 규칙을 설정해 주세요";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CompactNodeShell
|
||||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
color="#06B6D4"
|
||||||
selected ? "border-orange-500 shadow-lg" : "border-border"
|
label={data.displayName || "데이터 변환"}
|
||||||
}`}
|
summary={summary}
|
||||||
|
icon={<Repeat className="h-3.5 w-3.5" />}
|
||||||
|
selected={selected}
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{ruleCount > 0 && (
|
||||||
<div className="flex items-center gap-2 rounded-t-lg bg-primary px-3 py-2 text-white">
|
<div className="space-y-0.5">
|
||||||
<Wand2 className="h-4 w-4" />
|
{data.transformRules!.slice(0, 3).map((r: any, i: number) => (
|
||||||
<div className="flex-1">
|
<div key={i} className="flex items-center gap-1.5">
|
||||||
<div className="text-sm font-semibold">{data.displayName || "데이터 변환"}</div>
|
<div className="h-1 w-1 rounded-full bg-cyan-400" />
|
||||||
<div className="text-xs opacity-80">{data.transformations?.length || 0}개 변환</div>
|
<span>{r.sourceField || r.field || `규칙 ${i + 1}`}</span>
|
||||||
|
{r.targetField && <span className="text-zinc-600">→ {r.targetField}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
{ruleCount > 3 && <span className="text-zinc-600">외 {ruleCount - 3}개</span>}
|
||||||
{/* 본문 */}
|
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{transform.expression && (
|
</CompactNodeShell>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,25 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE 액션 노드
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
import { NodeProps } from "reactflow";
|
||||||
import { Trash2, AlertTriangle } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { CompactNodeShell } from "./CompactNodeShell";
|
||||||
import type { DeleteActionNodeData } from "@/types/node-editor";
|
import type { DeleteActionNodeData } from "@/types/node-editor";
|
||||||
|
|
||||||
export const DeleteActionNode = memo(({ data, selected }: NodeProps<DeleteActionNodeData>) => {
|
export const DeleteActionNode = memo(({ data, selected }: NodeProps<DeleteActionNodeData>) => {
|
||||||
|
const whereCount = data.whereConditions?.length || 0;
|
||||||
|
const summary = data.targetTable
|
||||||
|
? `${data.targetTable} (${whereCount}개 조건)`
|
||||||
|
: "대상 테이블을 선택해 주세요";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CompactNodeShell
|
||||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
color="#EF4444"
|
||||||
selected ? "border-destructive shadow-lg" : "border-border"
|
label={data.displayName || "DELETE"}
|
||||||
}`}
|
summary={summary}
|
||||||
>
|
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||||
{/* 입력 핸들 */}
|
selected={selected}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,104 +1,30 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* 메일 발송 액션 노드
|
|
||||||
* 등록된 메일 계정을 선택하여 이메일을 발송하는 노드
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
import { NodeProps } from "reactflow";
|
||||||
import { Mail, User, CheckCircle } from "lucide-react";
|
import { Mail } from "lucide-react";
|
||||||
import type { EmailActionNodeData } from "@/types/node-editor";
|
import { CompactNodeShell } from "./CompactNodeShell";
|
||||||
|
|
||||||
export const EmailActionNode = memo(({ data, selected }: NodeProps<EmailActionNodeData>) => {
|
export const EmailActionNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||||
const hasAccount = !!data.accountId;
|
const summary = data.to
|
||||||
const hasRecipient = data.to && data.to.trim().length > 0;
|
? `To: ${data.to}`
|
||||||
const hasSubject = data.subject && data.subject.trim().length > 0;
|
: "수신자를 설정해 주세요";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CompactNodeShell
|
||||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
color="#EC4899"
|
||||||
selected ? "border-pink-500 shadow-lg" : "border-border"
|
label={data.displayName || "메일 발송"}
|
||||||
}`}
|
summary={summary}
|
||||||
|
icon={<Mail className="h-3.5 w-3.5" />}
|
||||||
|
selected={selected}
|
||||||
>
|
>
|
||||||
{/* 입력 핸들 */}
|
{data.subject && (
|
||||||
<Handle
|
<div className="line-clamp-2">
|
||||||
type="target"
|
제목: {data.subject}
|
||||||
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>
|
|
||||||
</div>
|
</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>
|
</CompactNodeShell>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
EmailActionNode.displayName = "EmailActionNode";
|
EmailActionNode.displayName = "EmailActionNode";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,87 +1,25 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 DB 소스 노드
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
import { NodeProps } from "reactflow";
|
||||||
import { Plug } from "lucide-react";
|
import { HardDrive } from "lucide-react";
|
||||||
|
import { CompactNodeShell } from "./CompactNodeShell";
|
||||||
import type { ExternalDBSourceNodeData } from "@/types/node-editor";
|
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>) => {
|
export const ExternalDBSourceNode = memo(({ data, selected }: NodeProps<ExternalDBSourceNodeData>) => {
|
||||||
const dbColor = (data.dbType && DB_TYPE_COLORS[data.dbType]) || "#F59E0B";
|
const summary = data.connectionName
|
||||||
const dbIcon = (data.dbType && DB_TYPE_ICONS[data.dbType]) || "🔌";
|
? `${data.connectionName} → ${data.tableName || "..."}`
|
||||||
|
: "외부 DB 연결을 설정해 주세요";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CompactNodeShell
|
||||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
color="#F59E0B"
|
||||||
selected ? "border-orange-500 shadow-lg" : "border-border"
|
label={data.displayName || "외부 DB"}
|
||||||
}`}
|
summary={summary}
|
||||||
>
|
icon={<HardDrive className="h-3.5 w-3.5" />}
|
||||||
{/* 헤더 */}
|
selected={selected}
|
||||||
<div className="flex items-center gap-2 rounded-t-lg px-3 py-2 text-white" style={{ backgroundColor: dbColor }}>
|
hasInput={false}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,164 +1,23 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* 수식 변환 노드 (Formula Transform Node)
|
|
||||||
* 산술 연산, 함수, 조건문 등을 사용해 새로운 필드를 계산합니다.
|
|
||||||
* 타겟 테이블의 기존 값을 참조하여 UPSERT 시나리오를 지원합니다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
import { NodeProps } from "reactflow";
|
||||||
import { Calculator, Database, ArrowRight } from "lucide-react";
|
import { Calculator } from "lucide-react";
|
||||||
import type { FormulaTransformNodeData, FormulaType } from "@/types/node-editor";
|
import { CompactNodeShell } from "./CompactNodeShell";
|
||||||
|
|
||||||
// 수식 타입별 라벨
|
export const FormulaTransformNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||||
const FORMULA_TYPE_LABELS: Record<FormulaType, { label: string; color: string }> = {
|
const summary = data.formula
|
||||||
arithmetic: { label: "산술", color: "bg-amber-500" },
|
? `${data.formula.substring(0, 30)}${data.formula.length > 30 ? "..." : ""}`
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CompactNodeShell
|
||||||
className={`min-w-[300px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
color="#F97316"
|
||||||
selected ? "border-orange-500 shadow-lg" : "border-border"
|
label={data.displayName || "수식 변환"}
|
||||||
}`}
|
summary={summary}
|
||||||
>
|
icon={<Calculator className="h-3.5 w-3.5" />}
|
||||||
{/* 헤더 */}
|
selected={selected}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,124 +1,34 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP 요청 액션 노드
|
|
||||||
* REST API를 호출하는 노드
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
import { NodeProps } from "reactflow";
|
||||||
import { Globe, Lock, Unlock } from "lucide-react";
|
import { Send } from "lucide-react";
|
||||||
import type { HttpRequestActionNodeData } from "@/types/node-editor";
|
import { CompactNodeShell } from "./CompactNodeShell";
|
||||||
|
|
||||||
// HTTP 메서드별 색상
|
export const HttpRequestActionNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||||
const METHOD_COLORS: Record<string, { bg: string; text: string }> = {
|
const method = data.method || "GET";
|
||||||
GET: { bg: "bg-emerald-100", text: "text-emerald-700" },
|
const summary = data.url
|
||||||
POST: { bg: "bg-primary/10", text: "text-primary" },
|
? `${method} ${data.url}`
|
||||||
PUT: { bg: "bg-amber-100", text: "text-orange-700" },
|
: "요청 URL을 입력해 주세요";
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CompactNodeShell
|
||||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
color="#06B6D4"
|
||||||
selected ? "border-cyan-500 shadow-lg" : "border-border"
|
label={data.displayName || "HTTP 요청"}
|
||||||
}`}
|
summary={summary}
|
||||||
|
icon={<Send className="h-3.5 w-3.5" />}
|
||||||
|
selected={selected}
|
||||||
>
|
>
|
||||||
{/* 입력 핸들 */}
|
{data.url && (
|
||||||
<Handle
|
<div className="flex items-center gap-1.5">
|
||||||
type="target"
|
<span className="rounded bg-cyan-500/20 px-1 py-0.5 font-mono text-[9px] font-semibold text-cyan-400">
|
||||||
position={Position.Left}
|
{method}
|
||||||
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}
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</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>
|
</span>
|
||||||
|
<span className="break-all font-mono">{data.url}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</CompactNodeShell>
|
||||||
{/* 타임아웃 & 재시도 */}
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
HttpRequestActionNode.displayName = "HttpRequestActionNode";
|
HttpRequestActionNode.displayName = "HttpRequestActionNode";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,38 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* INSERT 액션 노드
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
import { NodeProps } from "reactflow";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
import { CompactNodeShell } from "./CompactNodeShell";
|
||||||
import type { InsertActionNodeData } from "@/types/node-editor";
|
import type { InsertActionNodeData } from "@/types/node-editor";
|
||||||
|
|
||||||
export const InsertActionNode = memo(({ data, selected }: NodeProps<InsertActionNodeData>) => {
|
export const InsertActionNode = memo(({ data, selected }: NodeProps<InsertActionNodeData>) => {
|
||||||
|
const mappingCount = data.fieldMappings?.length || 0;
|
||||||
|
const summary = data.targetTable
|
||||||
|
? `${data.targetTable} (${mappingCount}개 필드)`
|
||||||
|
: "대상 테이블을 선택해 주세요";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CompactNodeShell
|
||||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
color="#22C55E"
|
||||||
selected ? "border-emerald-500 shadow-lg" : "border-border"
|
label={data.displayName || "INSERT"}
|
||||||
}`}
|
summary={summary}
|
||||||
|
icon={<Plus className="h-3.5 w-3.5" />}
|
||||||
|
selected={selected}
|
||||||
>
|
>
|
||||||
{/* 입력 핸들 */}
|
{mappingCount > 0 && (
|
||||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-emerald-500 !bg-white" />
|
<div className="space-y-0.5">
|
||||||
|
{data.fieldMappings!.slice(0, 3).map((m, i) => (
|
||||||
{/* 헤더 */}
|
<div key={i} className="flex items-center gap-1">
|
||||||
<div className="flex items-center gap-2 rounded-t-lg bg-emerald-500 px-3 py-2 text-white">
|
<span>{m.sourceFieldLabel || m.sourceField || "?"}</span>
|
||||||
<Plus className="h-4 w-4" />
|
<span className="text-zinc-600">→</span>
|
||||||
<div className="flex-1">
|
<span className="font-mono text-zinc-300">{m.targetFieldLabel || m.targetField}</span>
|
||||||
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{data.fieldMappings.length > 4 && (
|
{mappingCount > 3 && <span className="text-zinc-600">외 {mappingCount - 3}개</span>}
|
||||||
<div className="text-xs text-muted-foreground/70">... 외 {data.fieldMappings.length - 4}개</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</CompactNodeShell>
|
||||||
{/* 옵션 */}
|
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,24 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그 노드 - 디버깅 및 모니터링용
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
import { NodeProps } from "reactflow";
|
||||||
import { FileText, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
import { FileText } from "lucide-react";
|
||||||
import type { LogNodeData } from "@/types/node-editor";
|
import { CompactNodeShell } from "./CompactNodeShell";
|
||||||
|
|
||||||
const LOG_LEVEL_CONFIG = {
|
export const LogNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||||
debug: { icon: Info, color: "text-primary", bg: "bg-primary/10", border: "border-primary/20" },
|
const summary = data.logLevel
|
||||||
info: { icon: Info, color: "text-emerald-600", bg: "bg-emerald-50", border: "border-emerald-200" },
|
? `${data.logLevel} 레벨 로깅`
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CompactNodeShell
|
||||||
className={`min-w-[200px] rounded-lg border-2 bg-white shadow-sm transition-all ${
|
color="#6B7280"
|
||||||
selected ? `${config.border} shadow-md` : "border-border"
|
label={data.displayName || "로그"}
|
||||||
}`}
|
summary={summary}
|
||||||
>
|
icon={<FileText className="h-3.5 w-3.5" />}
|
||||||
{/* 헤더 */}
|
selected={selected}
|
||||||
<div className={`flex items-center gap-2 rounded-t-lg ${config.bg} px-3 py-2`}>
|
hasOutput={false}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,121 +1,24 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* 프로시저/함수 호출 액션 노드
|
|
||||||
* 내부 또는 외부 DB의 프로시저/함수를 호출하는 노드
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
import { NodeProps } from "reactflow";
|
||||||
import { Database, Workflow } from "lucide-react";
|
import { Database } from "lucide-react";
|
||||||
import type { ProcedureCallActionNodeData } from "@/types/node-editor";
|
import { CompactNodeShell } from "./CompactNodeShell";
|
||||||
|
|
||||||
export const ProcedureCallActionNode = memo(
|
export const ProcedureCallActionNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||||
({ data, selected }: NodeProps<ProcedureCallActionNodeData>) => {
|
const summary = data.procedureName
|
||||||
const hasProcedure = !!data.procedureName;
|
? `${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") ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CompactNodeShell
|
||||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
color="#8B5CF6"
|
||||||
selected ? "border-violet-500 shadow-lg" : "border-border"
|
label={data.displayName || "프로시저 호출"}
|
||||||
}`}
|
summary={summary}
|
||||||
>
|
icon={<Database 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-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>
|
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
ProcedureCallActionNode.displayName = "ProcedureCallActionNode";
|
ProcedureCallActionNode.displayName = "ProcedureCallActionNode";
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,35 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* REST API 소스 노드
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
import { NodeProps } from "reactflow";
|
||||||
import { Globe, Lock } from "lucide-react";
|
import { Globe } from "lucide-react";
|
||||||
|
import { CompactNodeShell } from "./CompactNodeShell";
|
||||||
import type { RestAPISourceNodeData } from "@/types/node-editor";
|
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>) => {
|
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 (
|
return (
|
||||||
<div
|
<CompactNodeShell
|
||||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
color="#10B981"
|
||||||
selected ? "border-orange-500 shadow-lg" : "border-border"
|
label={data.displayName || "REST API"}
|
||||||
}`}
|
summary={summary}
|
||||||
|
icon={<Globe className="h-3.5 w-3.5" />}
|
||||||
|
selected={selected}
|
||||||
|
hasInput={false}
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{data.url && (
|
||||||
<div className="flex items-center gap-2 rounded-t-lg bg-teal-600 px-3 py-2 text-white">
|
<div className="flex items-center gap-1.5">
|
||||||
<Globe className="h-4 w-4" />
|
<span className="rounded bg-emerald-500/20 px-1 py-0.5 font-mono text-[9px] font-semibold text-emerald-400">
|
||||||
<div className="flex-1">
|
{method}
|
||||||
<div className="text-sm font-semibold">{data.displayName || "REST API"}</div>
|
</span>
|
||||||
<div className="text-xs opacity-80">{data.url || "URL 미설정"}</div>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</CompactNodeShell>
|
||||||
{/* 응답 매핑 */}
|
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,118 +1,31 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* 스크립트 실행 액션 노드
|
|
||||||
* Python, Shell, PowerShell 등 외부 스크립트를 실행하는 노드
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
import { NodeProps } from "reactflow";
|
||||||
import { Terminal, FileCode, Play } from "lucide-react";
|
import { Terminal } from "lucide-react";
|
||||||
import type { ScriptActionNodeData } from "@/types/node-editor";
|
import { CompactNodeShell } from "./CompactNodeShell";
|
||||||
|
|
||||||
// 스크립트 타입별 아이콘 색상
|
export const ScriptActionNode = memo(({ data, selected }: NodeProps<any>) => {
|
||||||
const SCRIPT_TYPE_COLORS: Record<string, { bg: string; text: string; label: string }> = {
|
const scriptType = data.scriptType || "python";
|
||||||
python: { bg: "bg-amber-100", text: "text-yellow-700", label: "Python" },
|
const summary = data.inlineScript
|
||||||
shell: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Shell" },
|
? `${scriptType} 스크립트 (${data.inlineScript.split("\n").length}줄)`
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CompactNodeShell
|
||||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
color="#10B981"
|
||||||
selected ? "border-emerald-500 shadow-lg" : "border-border"
|
label={data.displayName || "스크립트 실행"}
|
||||||
}`}
|
summary={summary}
|
||||||
|
icon={<Terminal className="h-3.5 w-3.5" />}
|
||||||
|
selected={selected}
|
||||||
>
|
>
|
||||||
{/* 입력 핸들 */}
|
{data.scriptType && (
|
||||||
<Handle
|
<span className="rounded bg-emerald-500/20 px-1 py-0.5 font-mono text-[9px] font-semibold text-emerald-400">
|
||||||
type="target"
|
{scriptType}
|
||||||
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>
|
||||||
<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>
|
</CompactNodeShell>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
ScriptActionNode.displayName = "ScriptActionNode";
|
ScriptActionNode.displayName = "ScriptActionNode";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,40 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* 테이블 소스 노드
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
import { NodeProps } from "reactflow";
|
||||||
import { Database } from "lucide-react";
|
import { Database } from "lucide-react";
|
||||||
|
import { CompactNodeShell } from "./CompactNodeShell";
|
||||||
import type { TableSourceNodeData } from "@/types/node-editor";
|
import type { TableSourceNodeData } from "@/types/node-editor";
|
||||||
|
|
||||||
export const TableSourceNode = memo(({ data, selected }: NodeProps<TableSourceNodeData>) => {
|
export const TableSourceNode = memo(({ data, selected }: NodeProps<TableSourceNodeData>) => {
|
||||||
// 디버깅: 필드 데이터 확인
|
const fieldCount = data.fields?.length || 0;
|
||||||
if (data.fields && data.fields.length > 0) {
|
const summary = data.tableName
|
||||||
console.log("🔍 TableSource 필드 데이터:", data.fields);
|
? `${data.tableName} (${fieldCount}개 필드)`
|
||||||
}
|
: "테이블을 선택해 주세요";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CompactNodeShell
|
||||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
color="#3B82F6"
|
||||||
selected ? "border-primary shadow-lg" : "border-border"
|
label={data.displayName || data.tableName || "테이블 소스"}
|
||||||
}`}
|
summary={summary}
|
||||||
|
icon={<Database className="h-3.5 w-3.5" />}
|
||||||
|
selected={selected}
|
||||||
|
hasInput={false}
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{fieldCount > 0 && (
|
||||||
<div className="flex items-center gap-2 rounded-t-lg bg-primary px-3 py-2 text-white">
|
<div className="space-y-0.5">
|
||||||
<Database className="h-4 w-4" />
|
{data.fields!.slice(0, 4).map((f) => (
|
||||||
<div className="flex-1">
|
<div key={f.name} className="flex items-center gap-1.5">
|
||||||
<div className="text-sm font-semibold">{data.displayName || data.tableName || "테이블 소스"}</div>
|
<div className="h-1 w-1 flex-shrink-0 rounded-full bg-blue-400" />
|
||||||
{data.tableName && data.displayName !== data.tableName && (
|
<span>{f.label || f.displayName || f.name}</span>
|
||||||
<div className="text-xs opacity-80">{data.tableName}</div>
|
</div>
|
||||||
|
))}
|
||||||
|
{fieldCount > 4 && (
|
||||||
|
<span className="text-zinc-600">외 {fieldCount - 4}개</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</CompactNodeShell>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,97 +1,26 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* UPDATE 액션 노드
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
import { NodeProps } from "reactflow";
|
||||||
import { Edit } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
|
import { CompactNodeShell } from "./CompactNodeShell";
|
||||||
import type { UpdateActionNodeData } from "@/types/node-editor";
|
import type { UpdateActionNodeData } from "@/types/node-editor";
|
||||||
|
|
||||||
export const UpdateActionNode = memo(({ data, selected }: NodeProps<UpdateActionNodeData>) => {
|
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 (
|
return (
|
||||||
<div
|
<CompactNodeShell
|
||||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
color="#3B82F6"
|
||||||
selected ? "border-primary shadow-lg" : "border-border"
|
label={data.displayName || "UPDATE"}
|
||||||
}`}
|
summary={summary}
|
||||||
>
|
icon={<Pencil className="h-3.5 w-3.5" />}
|
||||||
{/* 입력 핸들 */}
|
selected={selected}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,93 +1,26 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* UPSERT 액션 노드
|
|
||||||
* INSERT와 UPDATE를 결합한 노드
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
import { NodeProps } from "reactflow";
|
||||||
import { Database, RefreshCw } from "lucide-react";
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { CompactNodeShell } from "./CompactNodeShell";
|
||||||
import type { UpsertActionNodeData } from "@/types/node-editor";
|
import type { UpsertActionNodeData } from "@/types/node-editor";
|
||||||
|
|
||||||
export const UpsertActionNode = memo(({ data, selected }: NodeProps<UpsertActionNodeData>) => {
|
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 (
|
return (
|
||||||
<div
|
<CompactNodeShell
|
||||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
color="#8B5CF6"
|
||||||
selected ? "border-orange-500 shadow-lg" : "border-border"
|
label={data.displayName || "UPSERT"}
|
||||||
}`}
|
summary={summary}
|
||||||
>
|
icon={<RefreshCw 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">
|
/>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@
|
||||||
* 노드 속성 편집 패널
|
* 노드 속성 편집 패널
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
import { TableSourceProperties } from "./properties/TableSourceProperties";
|
import { TableSourceProperties } from "./properties/TableSourceProperties";
|
||||||
import { InsertActionProperties } from "./properties/InsertActionProperties";
|
import { InsertActionProperties } from "./properties/InsertActionProperties";
|
||||||
|
|
@ -29,70 +27,32 @@ import type { NodeType } from "@/types/node-editor";
|
||||||
export function PropertiesPanel() {
|
export function PropertiesPanel() {
|
||||||
const { nodes, selectedNodes, setShowPropertiesPanel } = useFlowEditorStore();
|
const { nodes, selectedNodes, setShowPropertiesPanel } = useFlowEditorStore();
|
||||||
|
|
||||||
// 선택된 노드가 하나일 경우 해당 노드 데이터 가져오기
|
|
||||||
const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null;
|
const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null;
|
||||||
|
|
||||||
|
if (selectedNodes.length === 0) {
|
||||||
return (
|
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>
|
|
||||||
)}
|
|
||||||
</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="flex h-full items-center justify-center p-4">
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
<div className="mb-2 text-2xl">📝</div>
|
<p>노드를 선택해 주세요</p>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedNode) return null;
|
||||||
|
|
||||||
|
return <NodePropertiesRenderer node={selectedNode} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -155,14 +115,10 @@ function NodePropertiesRenderer({ node }: { node: any }) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="rounded border border-amber-200 bg-amber-50 p-4 text-sm">
|
<div className="rounded border border-amber-200 bg-amber-50 p-4 text-sm">
|
||||||
<p className="font-medium text-yellow-800">🚧 속성 편집 준비 중</p>
|
<p className="font-medium text-amber-800">속성 편집 준비 중이에요</p>
|
||||||
<p className="mt-2 text-xs text-yellow-700">
|
<p className="mt-2 text-xs text-amber-700">
|
||||||
{getNodeTypeLabel(node.type as NodeType)} 노드의 속성 편집 UI는 곧 구현될 예정입니다.
|
{getNodeTypeLabel(node.type as NodeType)} 노드의 속성 편집은 곧 지원될 예정이에요.
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,29 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { toast } from "sonner";
|
||||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||||
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
|
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
|
||||||
import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule";
|
import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule";
|
||||||
import { NumberingRuleCard } from "./NumberingRuleCard";
|
import { NumberingRuleCard } from "./NumberingRuleCard";
|
||||||
import { NumberingRulePreview, computePartDisplayItems, getPartTypeColorClass } from "./NumberingRulePreview";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface NumberingColumn {
|
||||||
|
tableName: string;
|
||||||
|
tableLabel: string;
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupedColumns {
|
||||||
|
tableLabel: string;
|
||||||
|
columns: NumberingColumn[];
|
||||||
|
}
|
||||||
|
|
||||||
interface NumberingRuleDesignerProps {
|
interface NumberingRuleDesignerProps {
|
||||||
initialConfig?: NumberingRuleConfig;
|
initialConfig?: NumberingRuleConfig;
|
||||||
onSave?: (config: NumberingRuleConfig) => void;
|
onSave?: (config: NumberingRuleConfig) => void;
|
||||||
|
|
@ -36,64 +49,95 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
currentTableName,
|
currentTableName,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
}) => {
|
}) => {
|
||||||
const [rulesList, setRulesList] = useState<NumberingRuleConfig[]>([]);
|
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
|
||||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
|
||||||
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
||||||
const [selectedPartOrder, setSelectedPartOrder] = useState<number | null>(null);
|
const [selectedPartOrder, setSelectedPartOrder] = useState<number | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [columnSearch, setColumnSearch] = useState("");
|
||||||
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
|
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
|
||||||
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
const selectedRule = rulesList.find((r) => r.ruleId === selectedRuleId) ?? currentRule;
|
|
||||||
|
|
||||||
// 좌측: 규칙 목록 로드
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRules();
|
loadNumberingColumns();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadRules = async () => {
|
const loadNumberingColumns = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await getNumberingRules();
|
const response = await apiClient.get("/table-management/numbering-columns");
|
||||||
if (response.success && response.data) {
|
if (response.data.success && response.data.data) {
|
||||||
setRulesList(response.data);
|
setNumberingColumns(response.data.data);
|
||||||
if (response.data.length > 0 && !selectedRuleId) {
|
|
||||||
const first = response.data[0];
|
|
||||||
setSelectedRuleId(first.ruleId);
|
|
||||||
setCurrentRule(JSON.parse(JSON.stringify(first)));
|
|
||||||
}
|
}
|
||||||
}
|
} catch (error: any) {
|
||||||
} catch (e) {
|
console.error("채번 컬럼 목록 로드 실패:", error);
|
||||||
console.error("채번 규칙 목록 로드 실패:", e);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectRule = (rule: NumberingRuleConfig) => {
|
const handleSelectColumn = async (tableName: string, columnName: string) => {
|
||||||
setSelectedRuleId(rule.ruleId);
|
setSelectedColumn({ tableName, columnName });
|
||||||
setCurrentRule(JSON.parse(JSON.stringify(rule)));
|
|
||||||
setSelectedPartOrder(null);
|
setSelectedPartOrder(null);
|
||||||
};
|
setLoading(true);
|
||||||
|
try {
|
||||||
const handleAddNewRule = () => {
|
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 = {
|
const newRule: NumberingRuleConfig = {
|
||||||
ruleId: `rule-${Date.now()}`,
|
ruleId: `rule-${Date.now()}`,
|
||||||
ruleName: "새 규칙",
|
ruleName: `${columnName} 채번`,
|
||||||
parts: [],
|
parts: [],
|
||||||
separator: "-",
|
separator: "-",
|
||||||
resetPeriod: "none",
|
resetPeriod: "none",
|
||||||
currentSequence: 1,
|
currentSequence: 1,
|
||||||
scopeType: "global",
|
scopeType: "table",
|
||||||
tableName: currentTableName ?? "",
|
tableName,
|
||||||
columnName: "",
|
columnName,
|
||||||
};
|
};
|
||||||
setRulesList((prev) => [...prev, newRule]);
|
setCurrentRule(newRule);
|
||||||
setSelectedRuleId(newRule.ruleId);
|
}
|
||||||
setCurrentRule(JSON.parse(JSON.stringify(newRule)));
|
} catch {
|
||||||
setSelectedPartOrder(null);
|
const newRule: NumberingRuleConfig = {
|
||||||
toast.success("새 규칙이 추가되었습니다");
|
ruleId: `rule-${Date.now()}`,
|
||||||
|
ruleName: `${columnName} 채번`,
|
||||||
|
parts: [],
|
||||||
|
separator: "-",
|
||||||
|
resetPeriod: "none",
|
||||||
|
currentSequence: 1,
|
||||||
|
scopeType: "table",
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
};
|
};
|
||||||
|
setCurrentRule(newRule);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블별 그룹화
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (currentRule) onChange?.(currentRule);
|
if (currentRule) onChange?.(currentRule);
|
||||||
|
|
@ -225,24 +269,14 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
const ruleToSave = {
|
const ruleToSave = {
|
||||||
...currentRule,
|
...currentRule,
|
||||||
parts: partsWithDefaults,
|
parts: partsWithDefaults,
|
||||||
scopeType: "global" as const,
|
scopeType: "table" as const,
|
||||||
tableName: currentRule.tableName || currentTableName || "",
|
tableName: selectedColumn?.tableName || currentRule.tableName || "",
|
||||||
columnName: currentRule.columnName || "",
|
columnName: selectedColumn?.columnName || currentRule.columnName || "",
|
||||||
};
|
};
|
||||||
const response = await saveNumberingRuleToTest(ruleToSave);
|
const response = await saveNumberingRuleToTest(ruleToSave);
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
const saved: NumberingRuleConfig = JSON.parse(JSON.stringify(response.data));
|
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
|
||||||
setCurrentRule(saved);
|
setCurrentRule(currentData);
|
||||||
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);
|
|
||||||
await onSave?.(response.data);
|
await onSave?.(response.data);
|
||||||
toast.success("채번 규칙이 저장되었습니다");
|
toast.success("채번 규칙이 저장되었습니다");
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -257,7 +291,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentRule, onSave, currentTableName]);
|
}, [currentRule, onSave, selectedColumn]);
|
||||||
|
|
||||||
const selectedPart = currentRule?.parts.find((p) => p.order === selectedPartOrder) ?? null;
|
const selectedPart = currentRule?.parts.find((p) => p.order === selectedPartOrder) ?? null;
|
||||||
const globalSep = currentRule?.separator ?? "-";
|
const globalSep = currentRule?.separator ?? "-";
|
||||||
|
|
@ -265,77 +299,94 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex h-full", className)}>
|
<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 flex w-[240px] 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="code-nav-head flex flex-col gap-2 border-b border-border px-3 py-2.5">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ListOrdered className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<Hash className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<span className="truncate text-xs font-bold">채번 규칙 ({rulesList.length})</span>
|
<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>
|
</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>
|
||||||
<div className="code-nav-list flex-1 overflow-y-auto">
|
<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 className="flex h-24 items-center justify-center text-xs text-muted-foreground">
|
||||||
로딩 중...
|
로딩 중...
|
||||||
</div>
|
</div>
|
||||||
) : rulesList.length === 0 ? (
|
) : filteredGroups.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">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
rulesList.map((rule) => {
|
filteredGroups.map(([tableName, group]) => (
|
||||||
const isSelected = selectedRuleId === rule.ruleId;
|
<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>
|
||||||
|
</div>
|
||||||
|
{group.columns.map((col) => {
|
||||||
|
const isSelected =
|
||||||
|
selectedColumn?.tableName === col.tableName &&
|
||||||
|
selectedColumn?.columnName === col.columnName;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={rule.ruleId}
|
key={`${col.tableName}.${col.columnName}`}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
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",
|
"flex w-full items-center gap-2 border-b border-border/30 px-3 py-2 text-left transition-colors",
|
||||||
isSelected
|
isSelected
|
||||||
? "border-l-[3px] border-primary bg-primary/5 pl-2.5 font-bold"
|
? "border-l-[3px] border-l-primary bg-primary/5 pl-2.5 font-bold"
|
||||||
: "hover:bg-accent"
|
: "pl-5 hover:bg-accent"
|
||||||
)}
|
)}
|
||||||
onClick={() => handleSelectRule(rule)}
|
onClick={() => handleSelectColumn(col.tableName, col.columnName)}
|
||||||
>
|
>
|
||||||
<span className="rule-name min-w-0 flex-1 truncate text-xs font-semibold">
|
<Hash className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
{rule.ruleName}
|
<div className="min-w-0 flex-1">
|
||||||
</span>
|
<div className="truncate text-xs font-semibold">
|
||||||
<span className="rule-table max-w-[70px] shrink-0 truncate text-[9px] text-muted-foreground">
|
{col.columnLabel || col.columnName}
|
||||||
{rule.tableName || "-"}
|
</div>
|
||||||
</span>
|
<div className="truncate text-[9px] text-muted-foreground">
|
||||||
<span className="rule-parts shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[8px] font-bold text-muted-foreground">
|
{col.columnName}
|
||||||
{rule.parts?.length ?? 0}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 (code-main) */}
|
{/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 */}
|
||||||
<div className="code-main flex min-w-0 flex-1 flex-col overflow-hidden">
|
<div className="code-main flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
{!currentRule ? (
|
{!currentRule ? (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center text-center">
|
<div className="flex flex-1 flex-col items-center justify-center text-center">
|
||||||
<ListOrdered className="mb-3 h-10 w-10 text-muted-foreground" />
|
<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="mb-2 text-lg font-medium text-muted-foreground">컬럼을 선택하세요</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
좌측에서 채번 규칙을 선택하거나 "추가"로 새 규칙을 만드세요
|
좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* 헤더: 규칙명 + 적용 대상 표시 */}
|
||||||
<div className="flex flex-col gap-2 px-6 pt-4">
|
<div className="flex flex-col gap-2 px-6 pt-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
<Label className="text-xs font-medium">규칙명</Label>
|
<Label className="text-xs font-medium">규칙명</Label>
|
||||||
<Input
|
<Input
|
||||||
value={currentRule.ruleName}
|
value={currentRule.ruleName}
|
||||||
|
|
@ -344,13 +395,22 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
className="h-9 text-sm"
|
className="h-9 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="code-preview-strip flex-shrink-0 border-b border-border px-6 py-5">
|
||||||
<NumberingRulePreview config={currentRule} variant="strip" />
|
<NumberingRulePreview config={currentRule} variant="strip" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 파이프라인 영역 (code-pipeline-area) */}
|
{/* 파이프라인 영역 */}
|
||||||
<div className="code-pipeline-area flex flex-col gap-3 border-b border-border px-6 py-5">
|
<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">
|
<div className="area-label flex items-center gap-1.5">
|
||||||
<span className="text-xs font-bold">코드 구성</span>
|
<span className="text-xs font-bold">코드 구성</span>
|
||||||
|
|
@ -360,15 +420,21 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
</div>
|
</div>
|
||||||
<div className="code-pipeline flex flex-1 flex-wrap items-center gap-0 overflow-x-auto overflow-y-hidden pb-2">
|
<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 ? (
|
{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">
|
<button
|
||||||
규칙을 추가하여 코드를 구성하세요
|
type="button"
|
||||||
</div>
|
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) => {
|
{currentRule.parts.map((part, index) => {
|
||||||
const item = partItems.find((i) => i.order === part.order);
|
const item = partItems.find((i) => i.order === part.order);
|
||||||
const sep = part.separatorAfter ?? globalSep;
|
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;
|
const typeLabel = CODE_PART_TYPE_OPTIONS.find((o) => o.value === part.partType)?.label ?? part.partType;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={`part-${part.order}-${index}`}>
|
<React.Fragment key={`part-${part.order}-${index}`}>
|
||||||
|
|
@ -380,7 +446,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
part.partType === "text" && "border-primary",
|
part.partType === "text" && "border-primary",
|
||||||
part.partType === "sequence" && "border-primary",
|
part.partType === "sequence" && "border-primary",
|
||||||
(part.partType === "number" || part.partType === "category" || part.partType === "reference") && "border-border",
|
(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)}
|
onClick={() => setSelectedPartOrder(part.order)}
|
||||||
>
|
>
|
||||||
|
|
@ -416,7 +482,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 설정 패널 (선택된 세그먼트 상세, code-config-panel) */}
|
{/* 설정 패널 */}
|
||||||
{selectedPart && (
|
{selectedPart && (
|
||||||
<div className="code-config-panel min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
<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">
|
<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>
|
</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="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">
|
<div className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||||
{currentRule.tableName && (
|
{currentRule.tableName && (
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
|
|
||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export type BatchExecutionType = "mapping" | "node_flow";
|
||||||
|
|
||||||
export interface BatchConfig {
|
export interface BatchConfig {
|
||||||
id?: number;
|
id?: number;
|
||||||
batch_name: string;
|
batch_name: string;
|
||||||
|
|
@ -10,14 +12,55 @@ export interface BatchConfig {
|
||||||
cron_schedule: string;
|
cron_schedule: string;
|
||||||
is_active?: string;
|
is_active?: string;
|
||||||
company_code?: string;
|
company_code?: string;
|
||||||
save_mode?: 'INSERT' | 'UPSERT'; // 저장 모드 (기본: INSERT)
|
save_mode?: 'INSERT' | 'UPSERT';
|
||||||
conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명
|
conflict_key?: string;
|
||||||
auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명
|
auth_service_name?: string;
|
||||||
|
execution_type?: BatchExecutionType;
|
||||||
|
node_flow_id?: number;
|
||||||
|
node_flow_context?: Record<string, any>;
|
||||||
created_date?: Date;
|
created_date?: Date;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
updated_date?: Date;
|
updated_date?: Date;
|
||||||
updated_by?: string;
|
updated_by?: string;
|
||||||
batch_mappings?: BatchMapping[];
|
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 {
|
export interface BatchMapping {
|
||||||
|
|
@ -97,6 +140,9 @@ export interface BatchMappingRequest {
|
||||||
cronSchedule: string;
|
cronSchedule: string;
|
||||||
mappings: BatchMapping[];
|
mappings: BatchMapping[];
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
executionType?: BatchExecutionType;
|
||||||
|
nodeFlowId?: number;
|
||||||
|
nodeFlowContext?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiResponse<T> {
|
export interface ApiResponse<T> {
|
||||||
|
|
@ -192,7 +238,7 @@ export class BatchAPI {
|
||||||
static async createBatchConfig(data: BatchMappingRequest): Promise<BatchConfig> {
|
static async createBatchConfig(data: BatchMappingRequest): Promise<BatchConfig> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post<ApiResponse<BatchConfig>>(
|
const response = await apiClient.post<ApiResponse<BatchConfig>>(
|
||||||
`/batch-configs`,
|
`/batch-management/batch-configs`,
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -462,4 +508,76 @@ export class BatchAPI {
|
||||||
return [];
|
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