Merge branch 'gbpark-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs 2026-03-19 15:09:05 +09:00
commit 8c946312fe
47 changed files with 5340 additions and 3178 deletions

View File

@ -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,
@ -768,4 +780,287 @@ 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
* totalBatches, activeBatches, todayExecutions, todayFailures, prevDayExecutions, prevDayFailures
* 멀티테넌시: company_code
*/
static async getBatchStats(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
// 전체/활성 배치 수
let configQuery: string;
let configParams: any[] = [];
if (companyCode === "*") {
configQuery = `
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE is_active = 'Y')::int AS active
FROM batch_configs
`;
} else {
configQuery = `
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE is_active = 'Y')::int AS active
FROM batch_configs
WHERE company_code = $1
`;
configParams = [companyCode];
}
const configResult = await query<{ total: number; active: number }>(
configQuery,
configParams
);
// 오늘/어제 실행·실패 수 (KST 기준 날짜)
const logParams: any[] = [];
let logWhere = "";
if (companyCode && companyCode !== "*") {
logWhere = " AND company_code = $1";
logParams.push(companyCode);
}
const todayLogQuery = `
SELECT
COUNT(*)::int AS today_executions,
COUNT(*) FILTER (WHERE execution_status = 'FAILED')::int AS today_failures
FROM batch_execution_logs
WHERE (start_time AT TIME ZONE 'Asia/Seoul')::date = (NOW() AT TIME ZONE 'Asia/Seoul')::date
${logWhere}
`;
const prevDayLogQuery = `
SELECT
COUNT(*)::int AS prev_executions,
COUNT(*) FILTER (WHERE execution_status = 'FAILED')::int AS prev_failures
FROM batch_execution_logs
WHERE (start_time AT TIME ZONE 'Asia/Seoul')::date = (NOW() AT TIME ZONE 'Asia/Seoul')::date - INTERVAL '1 day'
${logWhere}
`;
const [todayResult, prevResult] = await Promise.all([
query<{ today_executions: number; today_failures: number }>(
todayLogQuery,
logParams
),
query<{ prev_executions: number; prev_failures: number }>(
prevDayLogQuery,
logParams
),
]);
const config = configResult[0];
const today = todayResult[0];
const prev = prevResult[0];
return res.json({
success: true,
data: {
totalBatches: config?.total ?? 0,
activeBatches: config?.active ?? 0,
todayExecutions: today?.today_executions ?? 0,
todayFailures: today?.today_failures ?? 0,
prevDayExecutions: prev?.prev_executions ?? 0,
prevDayFailures: prev?.prev_failures ?? 0,
},
});
} catch (error) {
console.error("배치 통계 조회 오류:", error);
return res.status(500).json({
success: false,
message: "배치 통계 조회 실패",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
* 24 (1 )
* GET /api/batch-management/batch-configs/:id/sparkline
* 멀티테넌시: company_code
*/
static async getBatchSparkline(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode;
const batchId = Number(id);
if (!id || isNaN(batchId)) {
return res.status(400).json({
success: false,
message: "올바른 배치 ID를 제공해주세요.",
});
}
const params: any[] = [batchId];
let companyFilter = "";
if (companyCode && companyCode !== "*") {
companyFilter = " AND bel.company_code = $2";
params.push(companyCode);
}
// KST 기준 최근 24시간 1시간 단위 슬롯 + 집계 (generate_series로 24개 보장)
const sparklineQuery = `
WITH kst_slots AS (
SELECT to_char(s, 'YYYY-MM-DD"T"HH24:00:00') AS hour
FROM generate_series(
(NOW() AT TIME ZONE 'Asia/Seoul') - INTERVAL '23 hours',
(NOW() AT TIME ZONE 'Asia/Seoul'),
INTERVAL '1 hour'
) AS s
),
agg AS (
SELECT
to_char(date_trunc('hour', (bel.start_time AT TIME ZONE 'Asia/Seoul')) AT TIME ZONE 'Asia/Seoul', 'YYYY-MM-DD"T"HH24:00:00') AS hour,
COUNT(*) FILTER (WHERE bel.execution_status = 'SUCCESS')::int AS success,
COUNT(*) FILTER (WHERE bel.execution_status = 'FAILED')::int AS failed
FROM batch_execution_logs bel
WHERE bel.batch_config_id = $1
AND bel.start_time >= (NOW() AT TIME ZONE 'Asia/Seoul') - INTERVAL '24 hours'
${companyFilter}
GROUP BY date_trunc('hour', (bel.start_time AT TIME ZONE 'Asia/Seoul'))
)
SELECT
k.hour,
COALESCE(a.success, 0) AS success,
COALESCE(a.failed, 0) AS failed
FROM kst_slots k
LEFT JOIN agg a ON k.hour = a.hour
ORDER BY k.hour
`;
const data = await query<{
hour: string;
success: number;
failed: number;
}>(sparklineQuery, params);
return res.json({ success: true, data });
} catch (error) {
console.error("스파크라인 조회 오류:", error);
return res.status(500).json({
success: false,
message: "스파크라인 데이터 조회 실패",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
* ( 20)
* GET /api/batch-management/batch-configs/:id/recent-logs
* 멀티테넌시: company_code
*/
static async getBatchRecentLogs(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode;
const batchId = Number(id);
const limit = Math.min(Number(req.query.limit) || 20, 20);
if (!id || isNaN(batchId)) {
return res.status(400).json({
success: false,
message: "올바른 배치 ID를 제공해주세요.",
});
}
let logsQuery: string;
let logsParams: any[];
if (companyCode === "*") {
logsQuery = `
SELECT
id,
start_time AS started_at,
end_time AS finished_at,
execution_status AS status,
total_records,
success_records,
failed_records,
error_message,
duration_ms
FROM batch_execution_logs
WHERE batch_config_id = $1
ORDER BY start_time DESC
LIMIT $2
`;
logsParams = [batchId, limit];
} else {
logsQuery = `
SELECT
id,
start_time AS started_at,
end_time AS finished_at,
execution_status AS status,
total_records,
success_records,
failed_records,
error_message,
duration_ms
FROM batch_execution_logs
WHERE batch_config_id = $1 AND company_code = $2
ORDER BY start_time DESC
LIMIT $3
`;
logsParams = [batchId, companyCode, limit];
}
const result = await query(logsQuery, logsParams);
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 : "알 수 없는 오류",
});
}
}
} }

View File

@ -7,6 +7,19 @@ import { authenticateToken } from "../middleware/authMiddleware";
const router = Router(); const router = Router();
/**
* GET /api/batch-management/stats
* (/ , · / )
* /batch-configs (/:id )
*/
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
* *
@ -55,6 +68,18 @@ router.get("/batch-configs", authenticateToken, BatchManagementController.getBat
*/ */
router.get("/batch-configs/:id", authenticateToken, BatchManagementController.getBatchConfigById); router.get("/batch-configs/:id", authenticateToken, BatchManagementController.getBatchConfigById);
/**
* GET /api/batch-management/batch-configs/:id/sparkline
* 24 1
*/
router.get("/batch-configs/:id/sparkline", authenticateToken, BatchManagementController.getBatchSparkline);
/**
* GET /api/batch-management/batch-configs/:id/recent-logs
* ( 20)
*/
router.get("/batch-configs/:id/recent-logs", authenticateToken, BatchManagementController.getBatchRecentLogs);
/** /**
* PUT /api/batch-management/batch-configs/:id * PUT /api/batch-management/batch-configs/:id
* *

View File

@ -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);

View File

@ -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,
};
}
/** /**
* ( ) * ( )
*/ */

View File

@ -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(

View File

@ -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[];
} }

View File

@ -0,0 +1,909 @@
# 배치 스케줄러 + 노드 플로우 연동 계획서
## 1. 배경 및 목적
### 현재 상태
현재 시스템에는 두 개의 독립적인 실행 엔진이 있다:
| 시스템 | 역할 | 트리거 방식 |
|--------|------|-------------|
| **배치 스케줄러** | Cron 기반 자동 실행 (데이터 복사만 가능) | 시간 기반 (node-cron) |
| **노드 플로우 엔진** | 조건/변환/INSERT/UPDATE/DELETE 등 복합 로직 | 버튼 클릭 (수동) |
### 문제
- 배치는 **INSERT/UPSERT만** 가능하고, 조건 기반 UPDATE/DELETE를 못 함
- 노드 플로우는 강력하지만 **수동 실행만** 가능 (버튼 클릭 필수)
- "퇴사일이 지나면 자동으로 퇴사 처리" 같은 **시간 기반 비즈니스 로직**을 구현할 수 없음
### 목표
배치 스케줄러가 노드 플로우를 자동 실행할 수 있도록 연동하여,
시간 기반 비즈니스 로직 자동화를 지원한다.
```
[배치 스케줄러] ──Cron 트리거──> [노드 플로우 실행 엔진]
│ │
│ ├── 테이블 소스 조회
│ ├── 조건 분기
│ ├── UPDATE / DELETE / INSERT
│ ├── 이메일 발송
│ └── 로깅
└── 실행 로그 기록 (batch_execution_logs)
```
---
## 2. 사용 시나리오
### 시나리오 A: 자동 퇴사 처리
```
매일 자정 실행:
1. user_info에서 퇴사일 <= NOW() AND 상태 != '퇴사' 인 사람 조회
2. 해당 사용자의 상태를 '퇴사'로 UPDATE
3. 관리자에게 이메일 알림 발송
```
### 시나리오 B: 월말 재고 마감
```
매월 1일 00:00 실행:
1. 전월 재고 데이터를 재고마감 테이블로 INSERT
2. 이월 수량 계산 후 UPDATE
```
### 시나리오 C: 미납 알림
```
매일 09:00 실행:
1. 납기일이 지난 미납 주문 조회
2. 담당자에게 이메일 발송
3. 알림 로그 INSERT
```
### 시나리오 D: 외부 API 연동 자동화
```
매시간 실행:
1. 외부 REST API에서 데이터 조회
2. 조건 필터링 (변경된 데이터만)
3. 내부 테이블에 UPSERT
```
---
## 3. 구현 범위
### 3.1 DB 변경 (batch_configs 테이블 확장)
```sql
-- batch_configs 테이블에 컬럼 추가
ALTER TABLE batch_configs
ADD COLUMN execution_type VARCHAR(20) DEFAULT 'mapping',
ADD COLUMN node_flow_id INTEGER DEFAULT NULL,
ADD COLUMN node_flow_context JSONB DEFAULT NULL;
-- execution_type: 'mapping' (기존 데이터 복사) | 'node_flow' (노드 플로우 실행)
-- node_flow_id: node_flows 테이블의 flow_id (FK)
-- node_flow_context: 플로우 실행 시 전달할 컨텍스트 데이터 (선택)
COMMENT ON COLUMN batch_configs.execution_type IS '실행 타입: mapping(기존 데이터 복사), node_flow(노드 플로우 실행)';
COMMENT ON COLUMN batch_configs.node_flow_id IS '연결된 노드 플로우 ID (execution_type이 node_flow일 때 사용)';
COMMENT ON COLUMN batch_configs.node_flow_context IS '플로우 실행 시 전달할 컨텍스트 데이터 (JSON)';
```
기존 데이터에 영향 없음 (`DEFAULT 'mapping'`으로 하위 호환성 보장)
### 3.2 백엔드 변경
#### BatchSchedulerService 수정 (핵심)
`executeBatchConfig()` 메서드에서 `execution_type` 분기:
```
executeBatchConfig(config)
├── config.execution_type === 'mapping'
│ └── 기존 executeBatchMappings() (변경 없음)
└── config.execution_type === 'node_flow'
└── NodeFlowExecutionService.executeFlow()
├── 노드 플로우 조회
├── 위상 정렬
├── 레벨별 실행
└── 결과 반환
```
수정 파일:
- `backend-node/src/services/batchSchedulerService.ts`
- `executeBatchConfig()` 에 node_flow 분기 추가
- 노드 플로우 실행 결과를 배치 로그 형식으로 변환
#### 배치 설정 API 수정
수정 파일:
- `backend-node/src/types/batchTypes.ts`
- `BatchConfig` 인터페이스에 `execution_type`, `node_flow_id`, `node_flow_context` 추가
- `CreateBatchConfigRequest`, `UpdateBatchConfigRequest` 에도 추가
- `backend-node/src/services/batchService.ts`
- `createBatchConfig()` - 새 필드 INSERT
- `updateBatchConfig()` - 새 필드 UPDATE
- `backend-node/src/controllers/batchManagementController.ts`
- 생성/수정 시 새 필드 처리
#### 노드 플로우 목록 API (배치용)
추가 파일/수정:
- `backend-node/src/routes/batchManagementRoutes.ts`
- `GET /api/batch-management/node-flows` 추가 (배치 설정 UI에서 플로우 선택용)
### 3.3 프론트엔드 변경
#### 배치 생성/편집 UI 수정
수정 파일:
- `frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx`
- `frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx`
변경 내용:
- "실행 타입" 선택 추가 (기존 매핑 / 노드 플로우)
- 노드 플로우 선택 시: 플로우 드롭다운 표시 (기존 매핑 설정 숨김)
- 노드 플로우 선택 시: 컨텍스트 데이터 입력 (선택사항, JSON)
```
┌─────────────────────────────────────────┐
│ 배치 설정 │
├─────────────────────────────────────────┤
│ 배치명: [자동 퇴사 처리 ] │
│ 설명: [퇴사일 경과 사용자 자동 처리] │
│ Cron: [0 0 * * * ] │
│ │
│ 실행 타입: ○ 데이터 매핑 ● 노드 플로우 │
│ │
│ ┌─ 노드 플로우 선택 ─────────────────┐ │
│ │ [▾ 자동 퇴사 처리 플로우 ] │ │
│ │ │ │
│ │ 플로우 설명: user_info에서 퇴사일..│ │
│ │ 노드 수: 4개 │ │
│ └────────────────────────────────────┘ │
│ │
│ [취소] [저장] │
└─────────────────────────────────────────┘
```
#### 배치 목록 UI - Ops 대시보드 리디자인
현재 배치 목록은 단순 테이블인데, Vercel/Railway 스타일의 **운영 대시보드**로 전면 리디자인한다.
노드 플로우 연동과 함께 적용하면 새로운 실행 타입도 자연스럽게 표현 가능.
디자인 컨셉: **"편집기"가 아닌 "운영 대시보드"**
- 데이터 타입 관리 = 컬럼 편집기 → 3패널(리스트/그리드/설정)이 적합
- 배치 관리 = 운영 모니터링 → 테이블 + 인라인 상태 표시가 적합
- 역할이 다르면 레이아웃도 달라야 함
---
##### 전체 레이아웃
```
┌──────────────────────────────────────────────────────────────┐
│ [헤더] 배치 관리 [새로고침] [새 배치] │
│ └ 데이터 동기화 배치 작업을 모니터링하고 관리합니다 │
├──────────────────────────────────────────────────────────────┤
│ [통계 카드 4열 그리드] │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 전체 배치 │ │ 활성 배치 │ │ 오늘 실행 │ │ 오늘 실패 │ │
│ │ 8 │ │ 6 │ │ 142 │ │ 3 │ │
│ │ +2 이번달│ │ 2 비활성 │ │+12% 전일 │ │+1 전일 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
├──────────────────────────────────────────────────────────────┤
│ [툴바] │
│ 🔍 검색... [전체|활성|비활성] [전체|DB-DB|API-DB|플로우] 총 8건 │
├──────────────────────────────────────────────────────────────┤
│ [테이블 헤더] │
│ ● 배치 타입 스케줄 최근24h 마지막실행 │
├──────────────────────────────────────────────────────────────┤
│ ● 품목 마스터 동기화 DB→DB */30**** ▌▌▌▐▌▌▌ 14:30 ▶✎🗑 │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ [확장 상세 패널 - 클릭 시 토글] │ │
│ │ 내러티브 + 파이프라인 + 매핑 + 설정 + 타임라인 │ │
│ └────────────────────────────────────────────────────────┘ │
│ ● 거래처 ERP 연동 API→DB 0*/2*** ▌▌▌▌▌▌▌ 14:00 ▶✎🗑 │
│ ◉ 재고 현황 수집 API→DB 06,18** ▌▌▐▌▌▌░ 실행중 ▶✎🗑 │
│ ○ BOM 백업 DB→DB 0 3**0 ░░░░░░░ 비활성 ▶✎🗑 │
│ ... │
└──────────────────────────────────────────────────────────────┘
```
---
##### 1. 페이지 헤더
```
구조: flex, align-items: flex-end, justify-content: space-between
하단 보더: 1px solid border
하단 마진: 24px
좌측:
- 제목: "배치 관리" (text-xl font-extrabold tracking-tight)
- 부제: "데이터 동기화 배치 작업을 모니터링하고 관리합니다" (text-xs text-muted-foreground)
우측 버튼 그룹 (gap-2):
- [새로고침] 버튼: variant="outline", RefreshCw 아이콘
- [새 배치] 버튼: variant="default" (primary), Plus 아이콘
```
---
##### 2. 통계 카드 영역
```
레이아웃: grid grid-cols-4 gap-3
각 카드: rounded-xl border bg-card p-4
카드 구조:
┌──────────────────────────┐
│ [라벨] [아이콘] │ ← stat-top: flex justify-between
│ │
│ 숫자값 (28px 모노 볼드) │ ← stat-val: font-mono text-3xl font-extrabold
│ │
│ [변화량 배지] 기간 텍스트 │ ← stat-footer: flex items-center gap-1.5
└──────────────────────────┘
4개 카드 상세:
┌─────────────┬────────────┬───────────────────────────────┐
│ 카드 │ 아이콘 색상 │ 값 색상 │
├─────────────┼────────────┼───────────────────────────────┤
│ 전체 배치 │ indigo bg │ foreground (기본) │
│ 활성 배치 │ green bg │ green (--success) │
│ 오늘 실행 │ cyan bg │ cyan (--info 계열) │
│ 오늘 실패 │ red bg │ red (--destructive) │
└─────────────┴────────────┴───────────────────────────────┘
변화량 배지:
- 증가: green 배경 + green 텍스트, "+N" 또는 "+N%"
- 감소/악화: red 배경 + red 텍스트
- 크기: text-[10px] font-bold px-1.5 py-0.5 rounded
아이콘 박스: 28x28px rounded-lg, 배경색 투명도 10%
아이콘: lucide-react (LayoutGrid, CheckCircle, Activity, XCircle)
```
**데이터 소스:**
```
GET /api/batch-management/stats
→ {
totalBatches: number, // batch_configs COUNT(*)
activeBatches: number, // batch_configs WHERE is_active='Y'
todayExecutions: number, // batch_execution_logs WHERE DATE(start_time)=TODAY
todayFailures: number, // batch_execution_logs WHERE DATE(start_time)=TODAY AND status='FAILED'
// 선택사항: 전일 대비 변화량
prevDayExecutions?: number,
prevDayFailures?: number
}
```
---
##### 3. 툴바
```
레이아웃: flex items-center gap-2.5
요소 1 - 검색:
- 위치: 좌측, flex-1 max-w-[320px]
- 구조: relative div + input + Search 아이콘(absolute left)
- input: h-9, rounded-lg, border, bg-card, text-xs
- placeholder: "배치 이름으로 검색..."
- focus: ring-2 ring-primary
요소 2 - 상태 필터 (pill-group):
- 컨테이너: flex gap-0.5, bg-card, border, rounded-lg, p-0.5
- 각 pill: text-[11px] font-semibold px-3 py-1.5 rounded-md
- 활성 pill: bg-primary/10 text-primary
- 비활성 pill: text-muted-foreground, hover시 밝아짐
- 항목: [전체] [활성] [비활성]
요소 3 - 타입 필터 (pill-group):
- 동일 스타일
- 항목: [전체] [DB-DB] [API-DB] [노드 플로우] ← 노드 플로우는 신규
요소 4 - 건수 표시:
- 위치: ml-auto (우측 정렬)
- 텍스트: "총 N건" (text-[11px] text-muted-foreground, N은 font-bold)
```
---
##### 4. 배치 테이블
```
컨테이너: border rounded-xl overflow-hidden bg-card
테이블 헤더:
- 배경: bg-muted/50
- 높이: 40px
- 글자: text-[10px] font-bold text-muted-foreground uppercase tracking-wider
- 그리드 컬럼: 44px 1fr 100px 130px 160px 100px 120px
- 컬럼: [LED] [배치] [타입] [스케줄] [최근 24h] [마지막 실행] [액션]
```
---
##### 5. 배치 테이블 행 (핵심)
```
그리드: 44px 1fr 100px 130px 160px 100px 120px
높이: min-height 60px
하단 보더: 1px solid border
hover: bg-card/80 (약간 밝아짐)
선택됨: bg-primary/10 + 좌측 3px primary 박스 섀도우 (inset)
클릭 시: 상세 패널 토글
[셀 1] LED 상태 표시:
┌──────────────────────────────────────┐
│ 원형 8x8px, 센터 정렬 │
│ │
│ 활성(on): green + box-shadow glow │
│ 실행중(run): amber + 1.5s blink 애니 │
│ 비활성(off): muted-foreground (회색) │
│ 에러(err): red + box-shadow glow │
└──────────────────────────────────────┘
[셀 2] 배치 정보:
┌──────────────────────────────────────┐
│ 배치명: text-[13px] font-bold │
│ 설명: text-[10px] text-muted-fg │
│ overflow ellipsis (1줄) │
│ │
│ 비활성 배치: 배치명도 muted 색상 │
└──────────────────────────────────────┘
[셀 3] 타입 배지:
┌──────────────────────────────────────┐
│ inline-flex, text-[10px] font-bold │
│ px-2 py-0.5 rounded-[5px] │
│ │
│ DB → DB: cyan 배경/텍스트 │
│ API → DB: violet 배경/텍스트 │
│ 노드 플로우: indigo 배경/텍스트 (신규) │
└──────────────────────────────────────┘
[셀 4] Cron 스케줄:
┌──────────────────────────────────────┐
│ Cron 표현식: font-mono text-[11px] │
│ font-medium │
│ 한글 설명: text-[9px] text-muted │
│ "매 30분", "매일 01:00" │
│ │
│ 비활성: muted 색상 │
└──────────────────────────────────────┘
Cron → 한글 변환 예시:
- */30 * * * * → "매 30분"
- 0 */2 * * * → "매 2시간"
- 0 6,18 * * * → "06:00, 18:00"
- 0 1 * * * → "매일 01:00"
- 0 3 * * 0 → "매주 일 03:00"
- 0 0 1 * * → "매월 1일 00:00"
[셀 5] 스파크라인 (최근 24h):
┌──────────────────────────────────────┐
│ flex, items-end, gap-[1px], h-6 │
│ │
│ 24개 바 (시간당 1개): │
│ - 성공(ok): green, opacity 60% │
│ - 실패(fail): red, opacity 80% │
│ - 미실행(none): muted, opacity 15% │
│ │
│ 각 바: flex-1, min-w-[3px] │
│ rounded-t-[1px] │
│ 높이: 실행시간 비례 또는 고정 │
│ hover: opacity 100% │
└──────────────────────────────────────┘
데이터: 최근 24시간을 1시간 단위로 슬라이싱
각 슬롯별 가장 최근 실행의 status 사용
높이: 성공=80~95%, 실패=20~40%, 미실행=5%
[셀 6] 마지막 실행:
┌──────────────────────────────────────┐
│ 시간: font-mono text-[10px] │
│ "14:30:00" │
│ 경과: text-[9px] muted │
│ "12분 전" │
│ │
│ 실행 중: amber 색상 "실행 중..." │
│ 비활성: muted "-" + "비활성" │
└──────────────────────────────────────┘
[셀 7] 액션 버튼:
┌──────────────────────────────────────┐
│ flex gap-1, justify-end │
│ │
│ 3개 아이콘 버튼 (28x28 rounded-md): │
│ │
│ [▶] 수동 실행 │
│ hover: green 테두리+배경+아이콘 │
│ 아이콘: Play (lucide) │
│ │
│ [✎] 편집 │
│ hover: 기본 밝아짐 │
│ 아이콘: Pencil (lucide) │
│ │
│ [🗑] 삭제 │
│ hover: red 테두리+배경+아이콘 │
│ 아이콘: Trash2 (lucide) │
└──────────────────────────────────────┘
```
---
##### 6. 행 확장 상세 패널 (클릭 시 토글)
행을 클릭하면 아래로 펼쳐지는 상세 패널. 매핑 타입과 노드 플로우 타입에 따라 내용이 달라진다.
```
컨테이너:
- border (상단 border 없음, 행과 이어짐)
- rounded-b-xl
- bg-muted/30 (행보다 약간 어두운 배경)
- padding: 20px 24px
내부 구조:
┌────────────────────────────────────────────────────────────┐
│ [내러티브 박스] │
│ "ERP_SOURCE DB의 item_master 테이블에서 현재 DB의 │
│ item_info 테이블로 12개 컬럼을 매 30분마다 동기화하고 │
│ 있어요. 오늘 48회 실행, 마지막 실행은 12분 전이에요." │
├────────────────────────────────────────────────────────────┤
│ [파이프라인 플로우 다이어그램] │
│ │
│ ┌─────────────┐ 12 컬럼 UPSERT ┌─────────────┐ │
│ │ 🗄 DB아이콘 │ ─────────────────→ │ 🗄 DB아이콘 │ │
│ │ ERP_SOURCE │ WHERE USE_YN='Y' │ 현재 DB │ │
│ │ item_master │ │ item_info │ │
│ └─────────────┘ └─────────────┘ │
├──────────────────────┬─────────────────────────────────────┤
│ [좌측: 매핑 + 설정] │ [우측: 실행 이력 타임라인] │
│ │ │
│ --- 컬럼 매핑 (12) --- │ --- 실행 이력 (최근 5건) --- │
│ ITEM_CD → item_code PK│ ● 14:30:00 [성공] 1,842건 3.2s │
│ ITEM_NM → item_name │ │ │
│ ITEM_SPEC → spec... │ ● 14:00:00 [성공] 1,840건 3.1s │
│ UNIT_CD → unit_code │ │ │
│ STD_PRICE → std_price │ ✕ 13:30:00 [실패] Timeout │
│ + 7개 더 보기 │ │ │
│ │ ● 13:00:00 [성공] 1,838건 2.9s │
│ --- 설정 --- │ │ │
│ 배치 크기: 500 │ ● 12:30:00 [성공] 1,835건 3.5s │
│ 타임아웃: 30s │ │
│ 실패 시: 3회 재시도 │ │
│ 매칭 키: item_code │ │
│ 모드: [UPSERT] │ │
└──────────────────────┴─────────────────────────────────────┘
```
**6-1. 내러티브 박스 (Toss 스타일 자연어 설명)**
```
스타일:
- rounded-lg
- 배경: linear-gradient(135deg, primary/6%, info/4%)
- 보더: 1px solid primary/8%
- padding: 12px 14px
- margin-bottom: 16px
텍스트: text-[11px] text-muted-foreground leading-relaxed
강조 텍스트:
- 굵은 텍스트(b): foreground font-semibold
- 하이라이트(hl): primary font-bold
매핑 타입 예시:
"ERP_SOURCE 데이터베이스의 item_master 테이블에서 현재 DB의
item_info 테이블로 12개 컬럼을 매 30분마다 동기화하고 있어요.
오늘 48회 실행, 마지막 실행은 12분 전이에요."
노드 플로우 타입 예시:
"자동 퇴사 처리 노드 플로우를 매일 00:00에 실행하고 있어요.
user_info 테이블에서 퇴사일이 지난 사용자를 조회하여
상태를 '퇴사'로 변경합니다. 4개 노드로 구성되어 있어요."
```
**6-2. 파이프라인 플로우 다이어그램**
```
컨테이너:
- flex items-center
- rounded-lg border bg-card p-4
- margin-bottom: 16px
구조: [소스 노드] ──[커넥터]──> [타겟 노드]
소스 노드 (pipe-node src):
- 배경: cyan/6%, 보더: cyan/12%
- 아이콘: 32x32 rounded-lg, cyan/12% 배경
- DB 타입: Database 아이콘 (lucide)
- API 타입: Cloud 아이콘 (lucide) + violet 색상
- 이름: text-xs font-bold cyan 색상
- 부제: font-mono text-[10px] muted (테이블명/URL)
커넥터 (pipe-connector):
- flex-1, flex-col items-center
- 상단 라벨: text-[9px] font-bold muted ("12 컬럼 UPSERT")
- 라인: width 100%, h-[2px], gradient(cyan → green)
- 라인 끝: 삼각형 화살표 (CSS ::after)
- 하단 라벨: text-[9px] font-bold muted ("WHERE USE_YN='Y'")
타겟 노드 (pipe-node tgt):
- 배경: green/6%, 보더: green/12%
- 아이콘: green/12% 배경
- 이름: text-xs font-bold green 색상
- 부제: 테이블명
노드 플로우 타입일 때:
- 소스/타겟 대신 노드 플로우 요약 카드로 대체
- 아이콘: Workflow 아이콘 (lucide) + indigo 색상
- 이름: 플로우명
- 부제: "N개 노드 | 조건 분기 포함"
- 노드 목록: 간략 리스트 (Source → Condition → Update → Email)
```
**6-3. 하단 2열 그리드**
```
레이아웃: grid grid-cols-2 gap-5
[좌측 컬럼] 매핑 + 설정:
섹션 1 - 컬럼 매핑:
헤더: flex items-center gap-1.5
- Link 아이콘 (lucide, 13px, muted)
- "컬럼 매핑" (text-[11px] font-bold muted)
- 건수 배지 (ml-auto, text-[9px] font-bold, primary/10% bg, primary 색)
매핑 행 (map-row):
- flex items-center gap-1.5
- rounded-md border bg-card px-2.5 py-1.5
- margin-bottom: 2px
구조: [소스 컬럼] → [타겟 컬럼] [태그]
소스: font-mono font-semibold text-[11px] cyan
화살표: "→" muted
타겟: font-mono font-semibold text-[11px] green
태그: text-[8px] font-bold px-1.5 py-0.5 rounded-sm
PK = green 배경 + dark 텍스트
5개까지 표시 후 "+ N개 더 보기" 접기/펼치기
노드 플로우 타입일 때:
매핑 대신 "노드 구성" 섹션으로 대체
각 행: [노드 아이콘] [노드 타입] [노드 설명]
예: 🔍 테이블 소스 | user_info 조회
🔀 조건 분기 | 퇴사일 <= NOW()
✏️ UPDATE | status → '퇴사'
📧 이메일 | 관리자 알림
섹션 2 - 설정 (cprop 리스트):
헤더: Settings 아이콘 + "설정"
각 행 (cprop):
- flex justify-between py-1.5
- 하단 보더: 1px solid white/3%
- 키: text-[11px] muted
- 값: text-[11px] font-semibold, mono체는 font-mono text-[10px]
- 특수 값: UPSERT 배지 (green/10% bg, green 색, text-[10px] font-bold)
매핑 타입 설정:
- 배치 크기: 500
- 타임아웃: 30s
- 실패 시 재시도: 3회 (green)
- 매칭 키: item_code (mono)
- 모드: [UPSERT] (배지)
노드 플로우 타입 설정:
- 플로우 ID: 42
- 노드 수: 4개
- 실행 타임아웃: 60s
- 컨텍스트: { ... } (mono, 접기 가능)
[우측 컬럼] 실행 이력 타임라인:
헤더: Clock 아이콘 + "실행 이력" + "최근 5건" 배지 (green)
타임라인 (timeline):
flex-col, gap-0
각 항목 (tl-item):
- flex items-start gap-3
- padding: 10px 0
- 하단 보더: 1px solid white/3%
좌측 - 점+선 (tl-dot-wrap):
- flex-col items-center, width 16px
- 점 (tl-dot): 8x8 rounded-full
성공(ok): green
실패(fail): red
실행중(run): amber + blink 애니메이션
- 선 (tl-line): width 1px, bg border, min-h 12px
마지막 항목에는 선 없음
우측 - 내용 (tl-body):
- 시간: font-mono text-[10px] font-semibold
- 상태 배지: text-[9px] font-bold px-1.5 py-0.5 rounded
성공: green/10% bg + green 색
실패: red/10% bg + red 색
- 메시지: text-[10px] muted, margin-top 2px
성공: "1,842건 처리 / 3.2s 소요"
실패: "Connection timeout: ERP_SOURCE 응답 없음"
```
---
##### 7. 반응형 대응
```
1024px 이하 (태블릿):
- 통계 카드: grid-cols-2
- 테이블 그리드: 36px 1fr 80px 110px 120px 80px (액션 숨김)
- 상세 패널 2열 그리드 → 1열
640px 이하 (모바일):
- 컨테이너 padding: 16px
- 통계 카드: grid-cols-2 gap-2
- 테이블 헤더: 숨김
- 테이블 행: grid-cols-1, 카드형태 (padding 16px, gap 8px)
```
---
##### 8. 필요한 백엔드 API
```
1. GET /api/batch-management/stats
→ {
totalBatches: number,
activeBatches: number,
todayExecutions: number,
todayFailures: number,
prevDayExecutions?: number,
prevDayFailures?: number
}
쿼리: batch_configs COUNT + batch_execution_logs 오늘/어제 집계
2. GET /api/batch-management/batch-configs/:id/sparkline
→ [{ hour: 0~23, status: 'success'|'failed'|'none', count: number }]
쿼리: batch_execution_logs WHERE batch_config_id=$1
AND start_time >= NOW() - INTERVAL '24 hours'
GROUP BY EXTRACT(HOUR FROM start_time)
3. GET /api/batch-management/batch-configs/:id/recent-logs?limit=5
→ [{ start_time, end_time, execution_status, total_records,
success_records, failed_records, error_message, duration_ms }]
쿼리: batch_execution_logs WHERE batch_config_id=$1
ORDER BY start_time DESC LIMIT $2
4. GET /api/batch-management/batch-configs (기존 수정)
→ 각 배치에 sparkline 요약 + last_execution 포함하여 반환
목록 페이지에서 개별 sparkline API를 N번 호출하지 않도록
한번에 가져오기 (LEFT JOIN + 서브쿼리)
```
---
## 4. 변경 파일 목록
### DB
| 파일 | 변경 | 설명 |
|------|------|------|
| `db/migrations/XXXX_batch_node_flow_integration.sql` | 신규 | ALTER TABLE batch_configs |
### 백엔드
| 파일 | 변경 | 설명 |
|------|------|------|
| `backend-node/src/services/batchSchedulerService.ts` | 수정 | executeBatchConfig에 node_flow 분기 |
| `backend-node/src/types/batchTypes.ts` | 수정 | BatchConfig 타입에 새 필드 추가 |
| `backend-node/src/services/batchService.ts` | 수정 | create/update에 새 필드 처리 |
| `backend-node/src/controllers/batchManagementController.ts` | 수정 | 새 필드 API + stats/sparkline/recent-logs API |
| `backend-node/src/routes/batchManagementRoutes.ts` | 수정 | node-flows/stats/sparkline 엔드포인트 추가 |
### 프론트엔드
| 파일 | 변경 | 설명 |
|------|------|------|
| `frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx` | **리디자인** | Ops 대시보드 스타일로 전면 재작성 |
| `frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx` | 수정 | 실행 타입 선택 + 플로우 선택 |
| `frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx` | 수정 | 실행 타입 선택 + 플로우 선택 |
---
## 5. 핵심 구현 상세
### 5.1 BatchSchedulerService 변경 (가장 중요)
```typescript
// batchSchedulerService.ts - executeBatchConfig 메서드 수정
static async executeBatchConfig(config: any) {
const startTime = new Date();
let executionLog: any = null;
try {
// ... 실행 로그 생성 (기존 코드 유지) ...
let result;
// 실행 타입에 따라 분기
if (config.execution_type === 'node_flow' && config.node_flow_id) {
// 노드 플로우 실행
result = await this.executeNodeFlow(config);
} else {
// 기존 매핑 실행 (하위 호환)
result = await this.executeBatchMappings(config);
}
// ... 실행 로그 업데이트 (기존 코드 유지) ...
return result;
} catch (error) {
// ... 에러 처리 (기존 코드 유지) ...
}
}
/**
* 노드 플로우 실행 (신규)
*/
private static async executeNodeFlow(config: any) {
const { NodeFlowExecutionService } = await import('./nodeFlowExecutionService');
const context = {
sourceData: [],
dataSourceType: 'none',
nodeResults: new Map(),
executionOrder: [],
buttonContext: {
buttonId: `batch_${config.id}`,
companyCode: config.company_code,
userId: config.created_by || 'batch_system',
formData: config.node_flow_context || {},
},
};
const flowResult = await NodeFlowExecutionService.executeFlow(
config.node_flow_id,
context
);
// 노드 플로우 결과를 배치 로그 형식으로 변환
return {
totalRecords: flowResult.totalNodes || 0,
successRecords: flowResult.successNodes || 0,
failedRecords: flowResult.failedNodes || 0,
};
}
```
### 5.2 실행 결과 매핑
노드 플로우 결과 → 배치 로그 변환:
| 노드 플로우 결과 | 배치 로그 필드 | 설명 |
|------------------|---------------|------|
| 전체 노드 수 | total_records | 실행 대상 노드 수 |
| 성공 노드 수 | success_records | 성공적으로 실행된 노드 |
| 실패 노드 수 | failed_records | 실패한 노드 |
| 에러 메시지 | error_message | 첫 번째 실패 노드의 에러 |
### 5.3 보안 고려사항
- 배치에서 실행되는 노드 플로우도 **company_code** 필터링 적용
- 배치 설정의 company_code와 노드 플로우의 company_code가 일치해야 함
- 최고 관리자(`*`)는 모든 플로우 실행 가능
- 실행 로그에 `batch_system`으로 사용자 기록
---
## 6. 구현 순서
### Phase 1: DB + 백엔드 코어 (1일)
1. 마이그레이션 SQL 작성 및 실행
2. `batchTypes.ts` 타입 수정
3. `batchService.ts` create/update 수정
4. `batchSchedulerService.ts` 핵심 분기 로직 추가
5. `batchManagementRoutes.ts` 노드 플로우 목록 API 추가
6. 수동 실행 테스트 (`POST /batch-configs/:id/execute`)
### Phase 2: 백엔드 대시보드 API (0.5일)
1. `GET /api/batch-management/stats` - 전체/활성/오늘실행/오늘실패 집계 API
2. `GET /api/batch-management/batch-configs/:id/sparkline` - 최근 24h 실행 결과 (시간대별 성공/실패/미실행)
3. `GET /api/batch-management/batch-configs/:id/recent-logs?limit=5` - 최근 N건 실행 이력
4. 기존 목록 API에 sparkline 요약 데이터 포함 옵션 추가
### Phase 3: 프론트엔드 - 배치 목록 Ops 대시보드 (1.5일)
상세 UI 명세는 위 "3.3 배치 목록 UI - Ops 대시보드 리디자인" 섹션 참조.
1. **페이지 헤더**: 제목 + 부제 + 새로고침/새배치 버튼 (명세 항목 1)
2. **통계 카드 영역**: 4개 카드 + stats API 연동 (명세 항목 2)
3. **툴바**: 검색 + 상태/타입 필터 pill-group + 건수 표시 (명세 항목 3)
4. **배치 테이블**: 7열 그리드 헤더 + 행 (명세 항목 4~5)
5. **행 확장 상세 패널**: 내러티브 + 파이프라인 + 매핑/플로우 + 설정 + 타임라인 (명세 항목 6)
6. **반응형**: 1024px/640px 브레이크포인트 (명세 항목 7)
7. 배치 생성/편집 모달에 실행 타입 선택 + 노드 플로우 드롭다운
### Phase 4: 테스트 및 검증 (0.5일)
1. 테스트용 노드 플로우 생성 (간단한 UPDATE)
2. 배치 설정에 연결
3. 수동 실행 테스트
4. Cron 스케줄 자동 실행 테스트
5. 실행 로그 확인
6. 대시보드 통계/스파크라인 정확성 확인
---
## 7. 리스크 및 대응
### 7.1 노드 플로우 실행 시간 초과
- **리스크**: 복잡한 플로우가 오래 걸려서 다음 스케줄과 겹칠 수 있음
- **대응**: 실행 중인 배치는 중복 실행 방지 (락 메커니즘) - Phase 2 이후 고려
### 7.2 노드 플로우 삭제 시 배치 깨짐
- **리스크**: 연결된 노드 플로우가 삭제되면 배치 실행 실패
- **대응**:
- 플로우 존재 여부 체크 후 실행
- 실패 시 로그에 "플로우를 찾을 수 없습니다" 기록
- (향후) 플로우 삭제 시 연결된 배치가 있으면 경고
### 7.3 멀티 인스턴스 환경
- **리스크**: 서버가 여러 대일 때 같은 배치가 중복 실행
- **대응**: 현재 단일 인스턴스 운영이므로 당장은 문제 없음. 향후 Redis 기반 분산 락 고려
---
## 8. 기대 효과
1. **시간 기반 비즈니스 자동화**: 수동 작업 없이 조건 충족 시 자동 처리
2. **기존 인프라 재활용**: 검증된 배치 스케줄러(1,200+건 성공) + 강력한 노드 플로우 엔진
3. **최소 코드 변경**: DB 컬럼 3개 + 백엔드 분기 1개 + 프론트 UI 확장
4. **운영 가시성 극대화**: Ops 대시보드로 배치 상태/건강도를 한눈에 파악 (스파크라인, LED, 타임라인)
5. **확장성**: 향후 이벤트 트리거(데이터 변경 감지) 등으로 확장 가능
---
## 9. 설계 의도 - 왜 기존 화면과 다른 레이아웃인가
| 비교 항목 | 데이터 타입 관리 (편집기) | 배치 관리 (대시보드) |
|-----------|------------------------|-------------------|
| 역할 | 컬럼 메타데이터 편집 | 운영 상태 모니터링 |
| 레이아웃 | 3패널 (리스트/그리드/설정) | 테이블 + 인라인 모니터링 |
| 주요 행위 | 필드 추가/삭제/수정 | 상태 확인, 수동 실행, 이력 조회 |
| 시각적 요소 | 폼, 드래그앤드롭 | LED, 스파크라인, 타임라인 |
| 참고 UI | IDE, Figma 속성 패널 | Vercel Functions, Railway |
### 디자인 키포인트 6가지
1. **스파크라인 = 건강 상태 한눈에**: Vercel의 Function 목록처럼 각 배치 행에 최근 24h 실행 결과를 미니 바 차트로 표현. 숫자 읽을 필요 없이 패턴으로 건강 상태 파악.
2. **Expandable Row 패턴**: 3패널 대신 클릭하면 행이 확장되어 상세 정보 표시. 파이프라인 플로우 + 매핑 + 타임라인이 한 번에. Railway/GitHub Actions의 Job 상세 패턴.
3. **LED 상태 표시**: 카드의 Badge(활성/비활성) 대신 LED 점으로 상태 표현. 초록=활성, 주황깜빡임=실행중, 회색=비활성. 운영실 모니터 느낌.
4. **파이프라인 플로우 다이어그램**: 소스 → 화살표 → 타겟을 수평 파이프라인으로 시각화. DB-DB는 DB 아이콘 둘, API-DB는 클라우드+DB. 데이터 흐름이 직관적.
5. **내러티브 박스**: 설정값을 나열하는 대신 자연어로 요약. "A에서 B로 N개 컬럼을 매 30분마다 동기화하고 있어요" 식. Toss 스타일 UX Writing.
6. **타임라인 실행 이력**: 테이블 로그 대신 세로 타임라인(점+선). 성공/실패가 시각적으로 즉시 구분. 문제 발생 시점 빠르게 특정 가능.
### 디자인 원본
HTML 프리뷰 파일: `_local/batch-management-v3-preview.html` (브라우저에서 열어 시각적 확인 가능)

View File

@ -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 || "설명 없음"} &middot; {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>

View File

@ -1,369 +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, BatchAPI,
BatchConfig, type BatchConfig,
BatchMapping, type BatchMapping,
type BatchStats,
type SparklineData,
type RecentLog,
} from "@/lib/api/batch"; } from "@/lib/api/batch";
import BatchCard from "@/components/admin/BatchCard";
import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScrollToTop } from "@/components/common/ScrollToTop";
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 });
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">&mdash;</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>
); );

View File

@ -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>

View File

@ -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>
); );
} }

View File

@ -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();
}, []); }, []);
// 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드 // 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드
@ -1633,7 +1604,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") {

View File

@ -306,16 +306,126 @@ select {
} }
} }
/* ===== Sonner 토스트 애니메이션 완전 제거 ===== */ /* ===== Sonner Toast - B안 (하단 중앙 스낵바) ===== */
[data-sonner-toaster] [data-sonner-toast] {
animation: none !important; /* 기본 토스트: 다크 배경 스낵바 */
transition: none !important; [data-sonner-toaster] [data-sonner-toast].sonner-toast-snackbar {
opacity: 1 !important; --normal-bg: hsl(222 30% 16%);
transform: none !important; --normal-text: hsl(210 20% 92%);
--normal-border: hsl(222 20% 24%);
background: var(--normal-bg);
color: var(--normal-text);
border: 1px solid var(--normal-border);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
padding: 10px 16px;
font-size: 13px;
font-weight: 500;
gap: 10px;
} }
[data-sonner-toaster] [data-sonner-toast][data-mounted="true"] {
animation: none !important; /* 다크모드 토스트 */
.dark [data-sonner-toaster] [data-sonner-toast].sonner-toast-snackbar {
--normal-bg: hsl(220 25% 14%);
--normal-border: hsl(220 20% 22%);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
} }
/* 성공 토스트 - 좌측 초록 바 */
[data-sonner-toaster] [data-sonner-toast].sonner-toast-success {
--success-bg: hsl(222 30% 16%);
--success-text: hsl(210 20% 92%);
--success-border: hsl(222 20% 24%);
background: var(--success-bg) !important;
color: var(--success-text) !important;
border: 1px solid var(--success-border) !important;
border-left: 3px solid hsl(142 76% 42%) !important;
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
}
.dark [data-sonner-toaster] [data-sonner-toast].sonner-toast-success {
--success-bg: hsl(220 25% 14%) !important;
--success-border: hsl(220 20% 22%) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
/* 에러 토스트 - 좌측 빨간 바 + 약간 붉은 배경 */
[data-sonner-toaster] [data-sonner-toast].sonner-toast-error {
--error-bg: hsl(0 30% 14%);
--error-text: hsl(0 20% 92%);
--error-border: hsl(0 20% 22%);
background: var(--error-bg) !important;
color: var(--error-text) !important;
border: 1px solid var(--error-border) !important;
border-left: 3px solid hsl(0 72% 55%) !important;
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
}
.dark [data-sonner-toaster] [data-sonner-toast].sonner-toast-error {
--error-bg: hsl(0 25% 10%) !important;
--error-border: hsl(0 20% 18%) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
/* 경고 토스트 - 좌측 노란 바 */
[data-sonner-toaster] [data-sonner-toast].sonner-toast-warning {
background: hsl(222 30% 16%) !important;
color: hsl(210 20% 92%) !important;
border: 1px solid hsl(222 20% 24%) !important;
border-left: 3px solid hsl(38 92% 50%) !important;
border-radius: 10px;
}
/* info 토스트 - 좌측 파란 바 */
[data-sonner-toaster] [data-sonner-toast].sonner-toast-info {
background: hsl(222 30% 16%) !important;
color: hsl(210 20% 92%) !important;
border: 1px solid hsl(222 20% 24%) !important;
border-left: 3px solid hsl(217 91% 60%) !important;
border-radius: 10px;
}
/* 토스트 내부 설명 텍스트 */
[data-sonner-toaster] [data-sonner-toast] [data-description] {
color: hsl(210 15% 70%) !important;
font-size: 12px !important;
}
/* 토스트 닫기 버튼: 토스트 안쪽 우측 상단 배치 */
[data-sonner-toaster] [data-sonner-toast] [data-close-button] {
position: absolute !important;
top: 50% !important;
right: 8px !important;
left: auto !important;
transform: translateY(-50%) !important;
width: 20px !important;
height: 20px !important;
background: transparent !important;
border: none !important;
border-radius: 4px !important;
color: hsl(210 15% 55%) !important;
opacity: 0.6;
transition: opacity 0.15s, background 0.15s;
}
[data-sonner-toaster] [data-sonner-toast] [data-close-button]:hover {
background: hsl(220 20% 24%) !important;
color: hsl(210 20% 85%) !important;
opacity: 1;
}
/* 토스트 액션 버튼 */
[data-sonner-toaster] [data-sonner-toast] [data-button] {
color: hsl(217 91% 68%) !important;
font-weight: 700;
font-size: 12px;
}
/* 애니메이션 제어: 부드러운 슬라이드 업만 허용, 나머지 제거 */
[data-sonner-toaster] [data-sonner-toast][data-removed="true"] { [data-sonner-toaster] [data-sonner-toast][data-removed="true"] {
animation: none !important; animation: none !important;
} }

View File

@ -4,7 +4,7 @@ import "./globals.css";
import { ThemeProvider } from "@/components/providers/ThemeProvider"; import { ThemeProvider } from "@/components/providers/ThemeProvider";
import { QueryProvider } from "@/providers/QueryProvider"; import { QueryProvider } from "@/providers/QueryProvider";
import { RegistryProvider } from "./registry-provider"; import { RegistryProvider } from "./registry-provider";
import { Toaster } from "sonner"; import { Toaster } from "@/components/ui/sonner";
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
@ -45,7 +45,7 @@ export default function RootLayout({
<ThemeProvider> <ThemeProvider>
<QueryProvider> <QueryProvider>
<RegistryProvider>{children}</RegistryProvider> <RegistryProvider>{children}</RegistryProvider>
<Toaster position="top-right" /> <Toaster />
</QueryProvider> </QueryProvider>
</ThemeProvider> </ThemeProvider>
{/* Portal 컨테이너 */} {/* Portal 컨테이너 */}

View File

@ -16,6 +16,7 @@ import {
Activity, Activity,
Settings Settings
} from "lucide-react"; } from "lucide-react";
import { apiClient } from "@/lib/api/client";
import { BatchConfig } from "@/lib/api/batch"; import { BatchConfig } from "@/lib/api/batch";
interface BatchCardProps { interface BatchCardProps {
@ -78,7 +79,7 @@ export default function BatchCard({
</span> </span>
<span className="font-medium"> <span className="font-medium">
{new Date(batch.created_date).toLocaleDateString('ko-KR')} {batch.created_date ? new Date(batch.created_date).toLocaleDateString('ko-KR') : '-'}
</span> </span>
</div> </div>
@ -118,7 +119,7 @@ export default function BatchCard({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onExecute(batch.id)} onClick={() => batch.id != null && onExecute(batch.id)}
disabled={isExecuting} disabled={isExecuting}
className="h-9 flex-1 gap-2 text-sm" className="h-9 flex-1 gap-2 text-sm"
> >
@ -134,7 +135,7 @@ export default function BatchCard({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onToggleStatus(batch.id, batch.is_active)} onClick={() => batch.id != null && onToggleStatus(batch.id, batch.is_active || 'N')}
className="h-9 flex-1 gap-2 text-sm" className="h-9 flex-1 gap-2 text-sm"
> >
{isActive ? ( {isActive ? (
@ -149,7 +150,7 @@ export default function BatchCard({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onEdit(batch.id)} onClick={() => batch.id != null && onEdit(batch.id)}
className="h-9 flex-1 gap-2 text-sm" className="h-9 flex-1 gap-2 text-sm"
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
@ -160,7 +161,7 @@ export default function BatchCard({
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={() => onDelete(batch.id, batch.batch_name)} onClick={() => batch.id != null && onDelete(batch.id, batch.batch_name)}
className="h-9 flex-1 gap-2 text-sm" className="h-9 flex-1 gap-2 text-sm"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />

View File

@ -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> &gt; .
<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>
)} )}

View File

@ -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">
&ldquo;{selectedFlow?.flowName}&rdquo; ? &ldquo;{selectedFlow?.flowName}&rdquo; .
<br /> <br />
<span className="font-medium text-destructive"> <span className="text-destructive font-medium">
, . , .
</span> </span>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@ -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">
&ldquo;{query}&rdquo;
</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>
);
}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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)}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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";

View File

@ -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>
); );
}); });

View File

@ -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";

View File

@ -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>

View File

@ -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>
); );
}); });

View File

@ -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>
); );
}); });

View File

@ -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";

View File

@ -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>
); );
}); });

View File

@ -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>
); );
}); });

View File

@ -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";

View File

@ -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>
); );
}); });

View File

@ -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>
); );
}); });

View File

@ -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";

View File

@ -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>
); );
}); });

View File

@ -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";

View File

@ -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>
); );
}); });

View File

@ -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>
); );
}); });

View File

@ -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>
); );
}); });

View File

@ -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>
); );

View File

@ -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">
&quot;&quot;
</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 && (

View File

@ -0,0 +1,28 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as SonnerToaster } from "sonner";
export function Toaster() {
const { theme = "system" } = useTheme();
return (
<SonnerToaster
position="bottom-center"
theme={theme as "light" | "dark" | "system"}
closeButton
richColors
duration={2500}
toastOptions={{
classNames: {
toast: "sonner-toast-snackbar",
success: "sonner-toast-success",
error: "sonner-toast-error",
warning: "sonner-toast-warning",
info: "sonner-toast-info",
closeButton: "sonner-close-btn",
},
}}
/>
);
}

View File

@ -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 {
@ -48,6 +91,8 @@ export interface BatchConfigFilter {
is_active?: string; is_active?: string;
company_code?: string; company_code?: string;
search?: string; search?: string;
page?: number;
limit?: number;
} }
export interface BatchJob { export interface BatchJob {
@ -95,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> {
@ -190,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,
); );
@ -460,7 +508,76 @@ export class BatchAPI {
return []; return [];
} }
} }
}
// BatchJob export 추가 (이미 위에서 interface로 정의됨) /**
export { BatchJob }; * ( )
*/
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 [];
}
}
}

View File

@ -6488,7 +6488,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
"hover:bg-accent cursor-pointer border-b border-border/50 transition-[background] duration-75", "hover:bg-accent cursor-pointer border-b border-border/50 transition-[background] duration-75",
index % 2 === 0 ? "bg-background" : "bg-muted/20", index % 2 === 0 ? "bg-background" : "bg-muted/20",
isRowSelected && "!bg-primary/10 hover:!bg-primary/15", isRowSelected && "!bg-primary/10 hover:!bg-primary/15",
isRowFocused && "ring-primary/50 ring-1 ring-inset", isRowFocused && "bg-accent/50",
isDragEnabled && "cursor-grab active:cursor-grabbing", isDragEnabled && "cursor-grab active:cursor-grabbing",
isDragging && "bg-muted opacity-50", isDragging && "bg-muted opacity-50",
isDropTarget && "border-t-primary border-t-2", isDropTarget && "border-t-primary border-t-2",
@ -6552,10 +6552,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap max-w-[170px]", inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap max-w-[170px]",
column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]", column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]",
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]", isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset", isCellFocused && !editingCell && "bg-primary/5 shadow-[inset_0_0_0_2px_hsl(var(--primary))]",
editingCell?.rowIndex === index && editingCell?.colIndex === colIndex && "p-0", editingCell?.rowIndex === index && editingCell?.colIndex === colIndex && "p-0",
isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40", isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40",
cellValidationError && "bg-red-50 ring-2 ring-red-500 ring-inset dark:bg-red-950/40", cellValidationError && "bg-red-50 shadow-[inset_0_0_0_2px_hsl(0_84%_60%)] dark:bg-red-950/40",
isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50", isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50",
column.editable === false && "bg-muted/10 dark:bg-muted/10", column.editable === false && "bg-muted/10 dark:bg-muted/10",
// 코드 컬럼: mono 폰트 + primary 색상 // 코드 컬럼: mono 폰트 + primary 색상