Compare commits

..

16 Commits

Author SHA1 Message Date
kjs 8c946312fe Merge branch 'gbpark-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-19 15:09:05 +09:00
DDD1542 43cf91e748 Enhance batch management functionality by adding node flow execution support and improving batch configuration creation. Introduce new API endpoint for retrieving node flows and update existing batch services to handle execution types. Update frontend components to support new scheduling options and node flow selection. 2026-03-19 15:07:07 +09:00
DDD1542 7f781b0177 [agent-pipeline] pipe-20260318044621-56k5 round-10 2026-03-18 14:56:46 +09:00
DDD1542 d8b56a1a78 [agent-pipeline] pipe-20260318044621-56k5 round-9 2026-03-18 14:48:13 +09:00
DDD1542 cd0f0df34d [agent-pipeline] rollback to 7f33b3fd 2026-03-18 14:37:57 +09:00
DDD1542 cbb8b24e70 [agent-pipeline] pipe-20260318044621-56k5 round-8 2026-03-18 14:37:57 +09:00
DDD1542 7f33b3fd8b [agent-pipeline] pipe-20260318044621-56k5 round-7 2026-03-18 14:24:48 +09:00
DDD1542 0f15644651 [agent-pipeline] rollback to 577e9c12 2026-03-18 14:18:02 +09:00
DDD1542 27efe672b9 [agent-pipeline] pipe-20260318044621-56k5 round-6 2026-03-18 14:18:02 +09:00
DDD1542 577e9c12d1 [agent-pipeline] pipe-20260318044621-56k5 round-5 2026-03-18 14:07:14 +09:00
DDD1542 ab477abf8b [agent-pipeline] rollback to 609460cd 2026-03-18 14:03:34 +09:00
DDD1542 609460cd8d [agent-pipeline] pipe-20260318044621-56k5 round-3 2026-03-18 14:00:44 +09:00
DDD1542 360a9ab1aa [agent-pipeline] rollback to 8e4791c5 2026-03-18 13:56:03 +09:00
DDD1542 351e57dd31 [agent-pipeline] pipe-20260318044621-56k5 round-2 2026-03-18 13:56:03 +09:00
DDD1542 8e4791c57a [agent-pipeline] pipe-20260318044621-56k5 round-1 2026-03-18 13:51:25 +09:00
DDD1542 5949ea22b5 Enhance Sonner Toast styles for improved user experience. Implement various toast types (success, error, warning, info) with distinct visual cues. Update layout to utilize the new toast component from the UI library, ensuring consistent styling across the application. 2026-03-18 12:13:40 +09:00
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) {
try {
const { batchName, description, cronSchedule, mappings, isActive } =
req.body;
const {
batchName, description, cronSchedule, mappings, isActive,
executionType, nodeFlowId, nodeFlowContext,
} = req.body;
const companyCode = req.user?.companyCode;
if (
!batchName ||
!cronSchedule ||
!mappings ||
!Array.isArray(mappings)
) {
if (!batchName || !cronSchedule) {
return res.status(400).json({
success: false,
message:
"필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)",
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)",
});
}
const batchConfig = await BatchService.createBatchConfig({
batchName,
description,
cronSchedule,
mappings,
isActive: isActive !== undefined ? isActive : true,
} as CreateBatchConfigRequest);
// 노드 플로우 타입은 매핑 없이 생성 가능
if (executionType !== "node_flow" && (!mappings || !Array.isArray(mappings))) {
return res.status(400).json({
success: false,
message: "매핑 타입은 mappings 배열이 필요합니다.",
});
}
const batchConfig = await BatchService.createBatchConfig(
{
batchName,
description,
cronSchedule,
mappings: mappings || [],
isActive: isActive === false || isActive === "N" ? "N" : "Y",
companyCode: companyCode || "",
executionType: executionType || "mapping",
nodeFlowId: nodeFlowId || null,
nodeFlowContext: nodeFlowContext || null,
} as CreateBatchConfigRequest,
req.user?.userId
);
return res.status(201).json({
success: true,
@ -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();
/**
* 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
*
@ -55,6 +68,18 @@ router.get("/batch-configs", authenticateToken, BatchManagementController.getBat
*/
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
*

View File

@ -13,7 +13,54 @@ import { auditLogService, getClientIp } from "../../services/auditLogService";
const router = Router();
/**
*
* flow_data에서
*/
function extractFlowSummary(flowData: any) {
try {
const parsed = typeof flowData === "string" ? JSON.parse(flowData) : flowData;
const nodes = parsed?.nodes || [];
const edges = parsed?.edges || [];
const nodeTypes: Record<string, number> = {};
nodes.forEach((n: any) => {
const t = n.type || "unknown";
nodeTypes[t] = (nodeTypes[t] || 0) + 1;
});
// 미니 토폴로지용 간소화된 좌표 (0~1 정규화)
let topology = null;
if (nodes.length > 0) {
const xs = nodes.map((n: any) => n.position?.x || 0);
const ys = nodes.map((n: any) => n.position?.y || 0);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...ys), maxY = Math.max(...ys);
const rangeX = maxX - minX || 1;
const rangeY = maxY - minY || 1;
topology = {
nodes: nodes.map((n: any) => ({
id: n.id,
type: n.type,
x: (((n.position?.x || 0) - minX) / rangeX),
y: (((n.position?.y || 0) - minY) / rangeY),
})),
edges: edges.map((e: any) => [e.source, e.target]),
};
}
return {
nodeCount: nodes.length,
edgeCount: edges.length,
nodeTypes,
topology,
};
} catch {
return { nodeCount: 0, edgeCount: 0, nodeTypes: {}, topology: null };
}
}
/**
* (summary )
*/
router.get("/", async (req: AuthenticatedRequest, res: Response) => {
try {
@ -24,6 +71,7 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => {
flow_id as "flowId",
flow_name as "flowName",
flow_description as "flowDescription",
flow_data as "flowData",
company_code as "companyCode",
created_at as "createdAt",
updated_at as "updatedAt"
@ -32,7 +80,6 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => {
const params: any[] = [];
// 슈퍼 관리자가 아니면 회사별 필터링
if (userCompanyCode && userCompanyCode !== "*") {
sqlQuery += ` WHERE company_code = $1`;
params.push(userCompanyCode);
@ -42,9 +89,15 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => {
const flows = await query(sqlQuery, params);
const flowsWithSummary = flows.map((flow: any) => {
const summary = extractFlowSummary(flow.flowData);
const { flowData, ...rest } = flow;
return { ...rest, summary };
});
return res.json({
success: true,
data: flows,
data: flowsWithSummary,
});
} catch (error) {
logger.error("플로우 목록 조회 실패:", error);

View File

@ -122,20 +122,22 @@ export class BatchSchedulerService {
}
/**
*
* - execution_type에
*/
static async executeBatchConfig(config: any) {
const startTime = new Date();
let executionLog: any = null;
try {
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id}, type: ${config.execution_type || "mapping"})`);
// 매핑 정보가 없으면 상세 조회로 다시 가져오기
if (!config.batch_mappings || config.batch_mappings.length === 0) {
const fullConfig = await BatchService.getBatchConfigById(config.id);
if (fullConfig.success && fullConfig.data) {
config = fullConfig.data;
// 상세 조회 (매핑 또는 노드플로우 정보가 없을 수 있음)
if (!config.execution_type || config.execution_type === "mapping") {
if (!config.batch_mappings || config.batch_mappings.length === 0) {
const fullConfig = await BatchService.getBatchConfigById(config.id);
if (fullConfig.success && fullConfig.data) {
config = fullConfig.data;
}
}
}
@ -165,12 +167,17 @@ export class BatchSchedulerService {
executionLog = executionLogResponse.data;
// 실제 배치 실행 로직 (수동 실행과 동일한 로직 사용)
const result = await this.executeBatchMappings(config);
let result: { totalRecords: number; successRecords: number; failedRecords: number };
if (config.execution_type === "node_flow") {
result = await this.executeNodeFlow(config);
} else {
result = await this.executeBatchMappings(config);
}
// 실행 로그 업데이트 (성공)
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
execution_status: "SUCCESS",
execution_status: result.failedRecords > 0 ? "PARTIAL" : "SUCCESS",
end_time: new Date(),
duration_ms: Date.now() - startTime.getTime(),
total_records: result.totalRecords,
@ -182,12 +189,10 @@ export class BatchSchedulerService {
`배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})`
);
// 성공 결과 반환
return result;
} catch (error) {
logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error);
// 실행 로그 업데이트 (실패)
if (executionLog) {
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
execution_status: "FAILED",
@ -198,7 +203,6 @@ export class BatchSchedulerService {
});
}
// 실패 결과 반환
return {
totalRecords: 0,
successRecords: 0,
@ -207,6 +211,43 @@ export class BatchSchedulerService {
}
}
/**
* - NodeFlowExecutionService에
*/
private static async executeNodeFlow(config: any) {
if (!config.node_flow_id) {
throw new Error("노드 플로우 ID가 설정되지 않았습니다.");
}
const { NodeFlowExecutionService } = await import(
"./nodeFlowExecutionService"
);
const contextData: Record<string, any> = {
companyCode: config.company_code,
batchConfigId: config.id,
batchName: config.batch_name,
executionSource: "batch_scheduler",
...(config.node_flow_context || {}),
};
logger.info(
`노드 플로우 실행: flowId=${config.node_flow_id}, batch=${config.batch_name}`
);
const flowResult = await NodeFlowExecutionService.executeFlow(
config.node_flow_id,
contextData
);
// 노드 플로우 실행 결과를 배치 로그 형식으로 변환
return {
totalRecords: flowResult.summary.total,
successRecords: flowResult.summary.success,
failedRecords: flowResult.summary.failed,
};
}
/**
* ( )
*/

View File

@ -72,9 +72,12 @@ export class BatchService {
const total = parseInt(countResult[0].count);
const totalPages = Math.ceil(total / limit);
// 목록 조회
// 목록 조회 (최근 실행 정보 포함)
const configs = await query<any>(
`SELECT bc.*
`SELECT bc.*,
(SELECT bel.execution_status FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_status,
(SELECT bel.start_time FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_executed_at,
(SELECT bel.total_records FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_total_records
FROM batch_configs bc
${whereClause}
ORDER BY bc.created_date DESC
@ -82,9 +85,6 @@ export class BatchService {
[...values, limit, offset]
);
// 매핑 정보 조회 (N+1 문제 해결을 위해 별도 쿼리 대신 여기서는 생략하고 상세 조회에서 처리)
// 하지만 목록에서도 간단한 정보는 필요할 수 있음
return {
success: true,
data: configs as BatchConfig[],
@ -176,8 +176,8 @@ export class BatchService {
// 배치 설정 생성
const batchConfigResult = await client.query(
`INSERT INTO batch_configs
(batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, created_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
(batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, execution_type, node_flow_id, node_flow_context, created_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW())
RETURNING *`,
[
data.batchName,
@ -189,6 +189,9 @@ export class BatchService {
data.conflictKey || null,
data.authServiceName || null,
data.dataArrayPath || null,
data.executionType || "mapping",
data.nodeFlowId || null,
data.nodeFlowContext ? JSON.stringify(data.nodeFlowContext) : null,
userId,
]
);
@ -332,6 +335,22 @@ export class BatchService {
updateFields.push(`data_array_path = $${paramIndex++}`);
updateValues.push(data.dataArrayPath || null);
}
if (data.executionType !== undefined) {
updateFields.push(`execution_type = $${paramIndex++}`);
updateValues.push(data.executionType);
}
if (data.nodeFlowId !== undefined) {
updateFields.push(`node_flow_id = $${paramIndex++}`);
updateValues.push(data.nodeFlowId || null);
}
if (data.nodeFlowContext !== undefined) {
updateFields.push(`node_flow_context = $${paramIndex++}`);
updateValues.push(
data.nodeFlowContext
? JSON.stringify(data.nodeFlowContext)
: null
);
}
// 배치 설정 업데이트
const batchConfigResult = await client.query(

View File

@ -79,6 +79,9 @@ export interface BatchMapping {
created_date?: Date;
}
// 배치 실행 타입: 기존 매핑 방식 또는 노드 플로우 실행
export type BatchExecutionType = "mapping" | "node_flow";
// 배치 설정 타입
export interface BatchConfig {
id?: number;
@ -87,15 +90,21 @@ export interface BatchConfig {
cron_schedule: string;
is_active: "Y" | "N";
company_code?: string;
save_mode?: "INSERT" | "UPSERT"; // 저장 모드 (기본: INSERT)
conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명
auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명
data_array_path?: string; // REST API 응답에서 데이터 배열 경로 (예: response, data.items)
save_mode?: "INSERT" | "UPSERT";
conflict_key?: string;
auth_service_name?: string;
data_array_path?: string;
execution_type?: BatchExecutionType;
node_flow_id?: number;
node_flow_context?: Record<string, any>;
created_by?: string;
created_date?: Date;
updated_by?: string;
updated_date?: Date;
batch_mappings?: BatchMapping[];
last_status?: string;
last_executed_at?: string;
last_total_records?: number;
}
export interface BatchConnectionInfo {
@ -149,7 +158,10 @@ export interface CreateBatchConfigRequest {
saveMode?: "INSERT" | "UPSERT";
conflictKey?: string;
authServiceName?: string;
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
dataArrayPath?: string;
executionType?: BatchExecutionType;
nodeFlowId?: number;
nodeFlowContext?: Record<string, any>;
mappings: BatchMappingRequest[];
}
@ -161,7 +173,10 @@ export interface UpdateBatchConfigRequest {
saveMode?: "INSERT" | "UPSERT";
conflictKey?: string;
authServiceName?: string;
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
dataArrayPath?: string;
executionType?: BatchExecutionType;
nodeFlowId?: number;
nodeFlowContext?: Record<string, any>;
mappings?: BatchMappingRequest[];
}

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";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import { useParams, useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTabStore } from "@/stores/tabStore";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
@ -15,17 +16,58 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
import { RefreshCw, Save, ArrowLeft, Plus, Trash2, Database, Workflow, Clock, Info, Layers, Link, Search } from "lucide-react";
import { toast } from "sonner";
import {
BatchAPI,
BatchConfig,
BatchMapping,
ConnectionInfo,
type NodeFlowInfo,
type BatchExecutionType,
} from "@/lib/api/batch";
import { BatchManagementAPI } from "@/lib/api/batchManagement";
const SCHEDULE_PRESETS = [
{ label: "5분마다", cron: "*/5 * * * *", preview: "5분마다 실행돼요" },
{ label: "30분마다", cron: "*/30 * * * *", preview: "30분마다 실행돼요" },
{ label: "매시간", cron: "0 * * * *", preview: "매시간 정각에 실행돼요" },
{ label: "매일 오전 7시", cron: "0 7 * * *", preview: "매일 오전 7시에 실행돼요" },
{ label: "매일 오전 9시", cron: "0 9 * * *", preview: "매일 오전 9시에 실행돼요" },
{ label: "매일 자정", cron: "0 0 * * *", preview: "매일 밤 12시에 실행돼요" },
{ label: "매주 월요일", cron: "0 9 * * 1", preview: "매주 월요일 오전 9시에 실행돼요" },
{ label: "매월 1일", cron: "0 9 1 * *", preview: "매월 1일 오전 9시에 실행돼요" },
];
function buildCustomCron(repeat: string, dow: string, hour: string, minute: string): string {
if (repeat === "daily") return `${minute} ${hour} * * *`;
if (repeat === "weekly") return `${minute} ${hour} * * ${dow}`;
if (repeat === "monthly") return `${minute} ${hour} 1 * *`;
return `${minute} ${hour} * * *`;
}
function customCronPreview(repeat: string, dow: string, hour: string, minute: string): string {
const dowNames: Record<string, string> = { "1": "월요일", "2": "화요일", "3": "수요일", "4": "목요일", "5": "금요일", "6": "토요일", "0": "일요일" };
const h = Number(hour);
const ampm = h < 12 ? "오전" : "오후";
const displayH = h === 0 ? 12 : h > 12 ? h - 12 : h;
const time = `${ampm} ${displayH}${minute !== "0" ? ` ${minute}` : ""}`;
if (repeat === "daily") return `매일 ${time}에 실행돼요`;
if (repeat === "weekly") return `매주 ${dowNames[dow] || dow} ${time}에 실행돼요`;
if (repeat === "monthly") return `매월 1일 ${time}에 실행돼요`;
return `매일 ${time}에 실행돼요`;
}
function parseCronToScheduleState(cron: string): { mode: "preset" | "custom"; presetIndex: number; repeat: string; dow: string; hour: string; minute: string } {
const presetIdx = SCHEDULE_PRESETS.findIndex(p => p.cron === cron);
if (presetIdx >= 0) return { mode: "preset", presetIndex: presetIdx, repeat: "daily", dow: "1", hour: "9", minute: "0" };
const parts = cron.split(" ");
if (parts.length < 5) return { mode: "preset", presetIndex: 3, repeat: "daily", dow: "1", hour: "9", minute: "0" };
const [m, h, dom, , dw] = parts;
const repeat = dw !== "*" ? "weekly" : dom !== "*" ? "monthly" : "daily";
return { mode: "custom", presetIndex: -1, repeat, dow: dw !== "*" ? dw : "1", hour: h !== "*" ? h : "9", minute: m.startsWith("*/") ? "0" : m };
}
interface BatchColumnInfo {
column_name: string;
data_type: string;
@ -49,15 +91,33 @@ const detectBatchType = (mapping: BatchMapping): 'db-to-db' | 'restapi-to-db' |
export default function BatchEditPage() {
const params = useParams();
const router = useRouter();
const { openTab } = useTabStore();
const batchId = parseInt(params.id as string);
// 기본 상태
const [loading, setLoading] = useState(false);
const [batchConfig, setBatchConfig] = useState<BatchConfig | null>(null);
const [batchName, setBatchName] = useState("");
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
const [description, setDescription] = useState("");
const [isActive, setIsActive] = useState("Y");
// 스케줄 관련
const [scheduleMode, setScheduleMode] = useState<"preset" | "custom">("preset");
const [selectedPresetIndex, setSelectedPresetIndex] = useState(3);
const [customRepeat, setCustomRepeat] = useState("daily");
const [customDow, setCustomDow] = useState("1");
const [customHour, setCustomHour] = useState("9");
const [customMinute, setCustomMinute] = useState("0");
const cronSchedule = useMemo(() => {
if (scheduleMode === "preset" && selectedPresetIndex >= 0) return SCHEDULE_PRESETS[selectedPresetIndex].cron;
return buildCustomCron(customRepeat, customDow, customHour, customMinute);
}, [scheduleMode, selectedPresetIndex, customRepeat, customDow, customHour, customMinute]);
const schedulePreview = useMemo(() => {
if (scheduleMode === "preset" && selectedPresetIndex >= 0) return SCHEDULE_PRESETS[selectedPresetIndex].preview;
return customCronPreview(customRepeat, customDow, customHour, customMinute);
}, [scheduleMode, selectedPresetIndex, customRepeat, customDow, customHour, customMinute]);
const [saveMode, setSaveMode] = useState<"INSERT" | "UPSERT">("INSERT");
const [conflictKey, setConflictKey] = useState("");
const [authServiceName, setAuthServiceName] = useState("");
@ -83,6 +143,13 @@ export default function BatchEditPage() {
// 배치 타입 감지
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null);
// 실행 타입 (mapping 또는 node_flow)
const [executionType, setExecutionType] = useState<BatchExecutionType>("mapping");
const [nodeFlows, setNodeFlows] = useState<NodeFlowInfo[]>([]);
const [selectedFlowId, setSelectedFlowId] = useState<number | null>(null);
const [nodeFlowContext, setNodeFlowContext] = useState("");
const [flowSearch, setFlowSearch] = useState("");
// REST API 미리보기 상태
const [apiPreviewData, setApiPreviewData] = useState<any[]>([]);
const [fromApiFields, setFromApiFields] = useState<string[]>([]);
@ -217,13 +284,30 @@ export default function BatchEditPage() {
setBatchConfig(config);
setBatchName(config.batch_name);
setCronSchedule(config.cron_schedule);
setDescription(config.description || "");
// 스케줄 파싱
const schedState = parseCronToScheduleState(config.cron_schedule);
setScheduleMode(schedState.mode);
setSelectedPresetIndex(schedState.presetIndex);
setCustomRepeat(schedState.repeat);
setCustomDow(schedState.dow);
setCustomHour(schedState.hour);
setCustomMinute(schedState.minute);
setIsActive(config.is_active || "Y");
setSaveMode((config as any).save_mode || "INSERT");
setConflictKey((config as any).conflict_key || "");
setAuthServiceName((config as any).auth_service_name || "");
setDataArrayPath((config as any).data_array_path || "");
// 실행 타입 복원
const configExecType = (config as any).execution_type as BatchExecutionType | undefined;
if (configExecType === "node_flow") {
setExecutionType("node_flow");
setSelectedFlowId((config as any).node_flow_id || null);
setNodeFlowContext((config as any).node_flow_context ? JSON.stringify((config as any).node_flow_context, null, 2) : "");
BatchAPI.getNodeFlows().then(setNodeFlows);
}
// 인증 토큰 모드 설정
if ((config as any).auth_service_name) {
@ -539,11 +623,49 @@ export default function BatchEditPage() {
// 배치 설정 저장
const saveBatchConfig = async () => {
// restapi-to-db인 경우 mappingList 사용, 아닌 경우 mappings 사용
if (!batchName || !cronSchedule) {
toast.error("배치명과 실행 스케줄은 필수입니다.");
return;
}
// 노드 플로우 타입 저장
if (executionType === "node_flow") {
if (!selectedFlowId) {
toast.error("노드 플로우를 선택해주세요.");
return;
}
let parsedContext: Record<string, any> | undefined;
if (nodeFlowContext.trim()) {
try { parsedContext = JSON.parse(nodeFlowContext); } catch { toast.error("컨텍스트 JSON 형식이 올바르지 않습니다."); return; }
}
setLoading(true);
try {
await BatchAPI.updateBatchConfig(batchId, {
batchName,
description,
cronSchedule,
isActive: isActive as "Y" | "N",
mappings: [],
executionType: "node_flow",
nodeFlowId: selectedFlowId,
nodeFlowContext: parsedContext,
});
toast.success("배치 설정이 저장되었습니다!");
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
} catch (error) {
console.error("배치 저장 실패:", error);
toast.error("배치 저장에 실패했습니다.");
} finally {
setLoading(false);
}
return;
}
// 매핑 타입 저장 - restapi-to-db인 경우 mappingList 사용, 아닌 경우 mappings 사용
const effectiveMappings = batchType === "restapi-to-db" ? mappingList : mappings;
if (!batchName || !cronSchedule || effectiveMappings.length === 0) {
toast.error("필수 항목을 모두 입력해주세요.");
if (effectiveMappings.length === 0) {
toast.error("매핑을 최소 하나 이상 설정해주세요.");
return;
}
@ -592,7 +714,7 @@ export default function BatchEditPage() {
});
toast.success("배치 설정이 성공적으로 수정되었습니다.");
router.push("/admin/batchmng");
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
} catch (error) {
console.error("배치 설정 수정 실패:", error);
@ -602,98 +724,277 @@ export default function BatchEditPage() {
}
};
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
const selectedFlow = nodeFlows.find(f => f.flow_id === selectedFlowId);
if (loading && !batchConfig) {
return (
<div className="container mx-auto p-6">
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-8 h-8 animate-spin" />
<span className="ml-2"> ...</span>
<div className="mx-auto max-w-5xl p-4 sm:p-6">
<div className="flex h-64 items-center justify-center gap-2">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
</div>
);
}
return (
<div className="container mx-auto space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="flex items-center gap-4 border-b pb-4">
<Button
variant="outline"
onClick={() => router.push("/admin/batchmng")}
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-3xl font-bold"> </h1>
<div className="mx-auto h-full max-w-[640px] space-y-7 overflow-y-auto p-4 sm:p-6">
{/* 헤더 */}
<div>
<button onClick={goBack} className="mb-2 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
<ArrowLeft className="h-3.5 w-3.5" />
</button>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold tracking-tight"> </h1>
{batchType && (
<Badge variant="outline" className="h-5 text-[10px]">
{batchType === "db-to-db" && "DB → DB"}
{batchType === "restapi-to-db" && "API → DB"}
{batchType === "db-to-restapi" && "DB → API"}
</Badge>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground">#{batchId} </p>
</div>
<Button size="sm" onClick={saveBatchConfig} disabled={loading} className="h-8 gap-1 text-xs">
{loading ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{loading ? "저장 중..." : "저장하기"}
</Button>
</div>
</div>
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{batchType && (
<Badge variant="outline">
{batchType === "db-to-db" && "DB -> DB"}
{batchType === "restapi-to-db" && "REST API -> DB"}
{batchType === "db-to-restapi" && "DB -> REST API"}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<h2 className="mb-3 text-sm font-bold"> </h2>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="batchName" className="text-xs font-medium"> <span className="text-destructive">*</span></Label>
<Input id="batchName" value={batchName} onChange={e => setBatchName(e.target.value)} placeholder="예: 매출 데이터 동기화" className="h-10 text-sm" />
</div>
<div className="space-y-1.5">
<Label htmlFor="description" className="text-xs font-medium"></Label>
<Textarea id="description" value={description} onChange={e => setDescription(e.target.value)} placeholder="이 배치가 어떤 일을 하는지 적어주세요" rows={2} className="resize-none text-sm" />
</div>
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<Label htmlFor="batchName"> *</Label>
<Input
id="batchName"
value={batchName}
onChange={(e) => setBatchName(e.target.value)}
placeholder="배치명을 입력하세요"
/>
<p className="text-sm font-medium"> </p>
<p className="text-[11px] text-muted-foreground">{isActive === "Y" ? "스케줄에 따라 자동으로 실행돼요" : "배치가 꺼져 있어요"}</p>
</div>
<div>
<Label htmlFor="cronSchedule"> (Cron) *</Label>
<Input
id="cronSchedule"
value={cronSchedule}
onChange={(e) => setCronSchedule(e.target.value)}
placeholder="0 12 * * *"
/>
<Switch checked={isActive === "Y"} onCheckedChange={checked => setIsActive(checked ? "Y" : "N")} />
</div>
</div>
</div>
{/* 실행 스케줄 */}
<div>
<h2 className="mb-1 text-sm font-bold"> ?</h2>
<p className="mb-3 text-[12px] text-muted-foreground"> . .</p>
<div className="rounded-xl border bg-card p-5">
<div className="mb-4 flex flex-wrap gap-2">
{SCHEDULE_PRESETS.map((preset, i) => (
<button
key={preset.cron}
onClick={() => { setScheduleMode("preset"); setSelectedPresetIndex(i); }}
className={`rounded-full border px-3.5 py-1.5 text-[12px] font-medium transition-all ${
scheduleMode === "preset" && selectedPresetIndex === i
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-primary/50 hover:text-primary"
}`}
>
{preset.label}
</button>
))}
<button
onClick={() => setScheduleMode("custom")}
className={`rounded-full border border-dashed px-3.5 py-1.5 text-[12px] font-medium transition-all ${
scheduleMode === "custom"
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-primary/50 hover:text-primary"
}`}
>
</button>
</div>
{scheduleMode === "custom" && (
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="space-y-1">
<span className="text-[11px] font-medium text-muted-foreground"></span>
<Select value={customRepeat} onValueChange={setCustomRepeat}>
<SelectTrigger className="h-9 w-[100px] text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="daily"></SelectItem>
<SelectItem value="weekly"></SelectItem>
<SelectItem value="monthly"></SelectItem>
</SelectContent>
</Select>
</div>
{customRepeat === "weekly" && (
<div className="space-y-1">
<span className="text-[11px] font-medium text-muted-foreground"></span>
<Select value={customDow} onValueChange={setCustomDow}>
<SelectTrigger className="h-9 w-[100px] text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1"></SelectItem>
<SelectItem value="2"></SelectItem>
<SelectItem value="3"></SelectItem>
<SelectItem value="4"></SelectItem>
<SelectItem value="5"></SelectItem>
<SelectItem value="6"></SelectItem>
<SelectItem value="0"></SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1">
<span className="text-[11px] font-medium text-muted-foreground"></span>
<Select value={customHour} onValueChange={setCustomHour}>
<SelectTrigger className="h-9 w-[90px] text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{Array.from({ length: 24 }).map((_, h) => (
<SelectItem key={h} value={String(h)}>
{h < 12 ? `오전 ${h === 0 ? 12 : h}` : `오후 ${h === 12 ? 12 : h - 12}`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<span className="text-[11px] font-medium text-muted-foreground"></span>
<Select value={customMinute} onValueChange={setCustomMinute}>
<SelectTrigger className="h-9 w-[80px] text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0">0</SelectItem>
<SelectItem value="15">15</SelectItem>
<SelectItem value="30">30</SelectItem>
<SelectItem value="45">45</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
)}
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="배치에 대한 설명을 입력하세요"
rows={3}
/>
<div className="flex items-center gap-2 rounded-lg bg-primary/5 px-4 py-3">
<Clock className="h-4 w-4 shrink-0 text-primary" />
<span className="text-[13px] font-medium text-primary">{schedulePreview}</span>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="isActive"
checked={isActive === "Y"}
onCheckedChange={(checked) => setIsActive(checked ? "Y" : "N")}
/>
<Label htmlFor="isActive"></Label>
</div>
</CardContent>
</Card>
{/* 실행 타입 선택 */}
<div>
<h2 className="mb-3 text-sm font-bold"> </h2>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => setExecutionType("mapping")}
className={`group relative flex items-center gap-3 rounded-xl border-2 p-4 text-left transition-all ${executionType === "mapping" ? "border-primary bg-primary/5" : "border-border hover:border-muted-foreground/30 hover:bg-muted/50"}`}
>
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${executionType === "mapping" ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
<Database className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold"> </div>
<div className="text-[11px] text-muted-foreground"> </div>
</div>
{executionType === "mapping" && <div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />}
</button>
<button
onClick={() => { setExecutionType("node_flow"); if (nodeFlows.length === 0) BatchAPI.getNodeFlows().then(setNodeFlows); }}
className={`group relative flex items-center gap-3 rounded-xl border-2 p-4 text-left transition-all ${executionType === "node_flow" ? "border-primary bg-primary/5" : "border-border hover:border-muted-foreground/30 hover:bg-muted/50"}`}
>
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${executionType === "node_flow" ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
<Workflow className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold"> </div>
<div className="text-[11px] text-muted-foreground"> </div>
</div>
{executionType === "node_flow" && <div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />}
</button>
</div>
</div>
{/* FROM/TO 섹션 가로 배치 */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* 노드 플로우 설정 */}
{executionType === "node_flow" && (
<div>
<h2 className="mb-1 text-sm font-bold"> ?</h2>
<p className="mb-3 text-[12px] text-muted-foreground"> </p>
{nodeFlows.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed">
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={flowSearch}
onChange={e => setFlowSearch(e.target.value)}
placeholder="플로우 이름으로 검색하세요"
className="h-8 pl-9 text-xs"
/>
</div>
<div className="max-h-[240px] space-y-2 overflow-y-auto">
{nodeFlows
.filter(flow => !flowSearch || flow.flow_name.toLowerCase().includes(flowSearch.toLowerCase()) || (flow.description || "").toLowerCase().includes(flowSearch.toLowerCase()))
.map(flow => (
<button
key={flow.flow_id}
onClick={() => setSelectedFlowId(flow.flow_id === selectedFlowId ? null : flow.flow_id)}
className={`flex w-full items-center gap-3 rounded-lg border p-3.5 text-left transition-all ${
selectedFlowId === flow.flow_id
? "border-primary bg-primary/5"
: "border-border hover:border-primary/30"
}`}
>
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${selectedFlowId === flow.flow_id ? "bg-primary/10 text-primary" : "bg-indigo-500/10 text-indigo-500"}`}>
<Workflow className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">{flow.flow_name}</p>
<p className="text-[11px] text-muted-foreground">
{flow.description || "설명 없음"} &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>
)}
{selectedFlow && (
<div className="mt-4 space-y-1.5">
<Label className="text-xs font-medium"> <span className="text-muted-foreground">()</span></Label>
<Textarea value={nodeFlowContext} onChange={e => setNodeFlowContext(e.target.value)} placeholder='예: {"target_status": "퇴사"}' rows={3} className="resize-none font-mono text-xs" />
<p className="text-[11px] text-muted-foreground"> JSON . .</p>
</div>
)}
</div>
)}
{/* FROM/TO 섹션 가로 배치 (매핑 타입일 때만) */}
{executionType === "mapping" && (
<>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* FROM 설정 */}
<Card>
<CardHeader>
<CardTitle>FROM ()</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3 rounded-lg border border-emerald-500/20 p-4 sm:p-5">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded bg-emerald-500/10 text-emerald-500">
<Database className="h-3.5 w-3.5" />
</div>
<span className="text-sm font-medium">FROM ()</span>
</div>
{batchType === "db-to-db" && (
<>
<div>
@ -1000,21 +1301,22 @@ export default function BatchEditPage() {
{batchType === "db-to-restapi" && mappings.length > 0 && (
<>
<div>
<Label> </Label>
<Input value={mappings[0]?.from_table_name || ""} readOnly />
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Input value={mappings[0]?.from_table_name || ""} readOnly className="h-9 text-sm" />
</div>
</>
)}
</CardContent>
</Card>
</div>
{/* TO 설정 */}
<Card>
<CardHeader>
<CardTitle>TO ()</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3 rounded-lg border border-sky-500/20 p-4 sm:p-5">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded bg-sky-500/10 text-sky-500">
<Database className="h-3.5 w-3.5" />
</div>
<span className="text-sm font-medium">TO ()</span>
</div>
{batchType === "db-to-db" && (
<>
<div>
@ -1188,8 +1490,7 @@ export default function BatchEditPage() {
UPSERT .
</p>
</div>
</CardContent>
</Card>
</div>
</div>
{/* API 데이터 미리보기 버튼 */}
@ -1206,19 +1507,19 @@ export default function BatchEditPage() {
</div>
)}
{/* 컬럼 매핑 섹션 - 좌우 분리 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
{/* 컬럼 매핑 섹션 */}
<div className="space-y-3 rounded-lg border p-4 sm:p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<Link className="h-4 w-4 text-muted-foreground" />
{batchType === "db-to-db" && "컬럼 매핑"}
{batchType === "restapi-to-db" && "컬럼 매핑 설정"}
{batchType === "db-to-restapi" && "DB 컬럼 -> API 필드 매핑"}
</CardTitle>
{batchType === "db-to-restapi" && "DB API 필드 매핑"}
</div>
{batchType === "restapi-to-db" && (
<p className="text-muted-foreground text-sm">DB API .</p>
<p className="text-xs text-muted-foreground">DB API </p>
)}
</CardHeader>
<CardContent>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* 왼쪽: 샘플 데이터 */}
<div className="flex flex-col">
@ -1526,24 +1827,21 @@ export default function BatchEditPage() {
)}
</div>
</div>
</CardContent>
</Card>
</div>
</>
)}
{/* 하단 버튼 */}
<div className="flex justify-end space-x-2 border-t pt-6">
<Button variant="outline" onClick={() => router.push("/admin/batchmng")}>
</Button>
<div className="flex justify-end gap-2 border-t pt-5">
<Button variant="outline" size="sm" onClick={goBack} className="h-9 text-xs"></Button>
<Button
size="sm"
onClick={saveBatchConfig}
disabled={loading || (batchType === "restapi-to-db" ? mappingList.length === 0 : mappings.length === 0)}
disabled={loading || (executionType === "node_flow" ? !selectedFlowId : (batchType === "restapi-to-db" ? mappingList.length === 0 : mappings.length === 0))}
className="h-9 gap-1 text-xs"
>
{loading ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{loading ? "저장 중..." : "배치 설정 저장"}
{loading ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{loading ? "저장 중..." : "저장하기"}
</Button>
</div>
</div>

View File

@ -1,370 +1,708 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Plus,
Search,
import { Switch } from "@/components/ui/switch";
import {
Plus,
Search,
RefreshCw,
Database
CheckCircle,
Play,
Pencil,
Trash2,
Clock,
Link,
Settings,
Database,
Cloud,
Workflow,
ChevronDown,
AlertCircle,
BarChart3,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { useRouter } from "next/navigation";
import {
BatchAPI,
BatchConfig,
BatchMapping,
type BatchConfig,
type BatchMapping,
type BatchStats,
type SparklineData,
type RecentLog,
} from "@/lib/api/batch";
import BatchCard from "@/components/admin/BatchCard";
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() {
const router = useRouter();
// 상태 관리
const { openTab } = useTabStore();
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
const [expandedBatch, setExpandedBatch] = useState<number | null>(null);
const [stats, setStats] = useState<BatchStats | null>(null);
const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({});
const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({});
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
const [togglingBatch, setTogglingBatch] = useState<number | null>(null);
// 페이지 로드 시 배치 목록 조회
useEffect(() => {
loadBatchConfigs();
}, [currentPage, searchTerm]);
// 배치 설정 목록 조회
const loadBatchConfigs = async () => {
const loadBatchConfigs = useCallback(async () => {
setLoading(true);
try {
const response = await BatchAPI.getBatchConfigs({
page: currentPage,
limit: 10,
search: searchTerm || undefined,
});
if (response.success && response.data) {
setBatchConfigs(response.data);
if (response.pagination) {
setTotalPages(response.pagination.totalPages);
}
const [configsResponse, statsData] = await Promise.all([
BatchAPI.getBatchConfigs({ page: 1, limit: 200 }),
BatchAPI.getBatchStats(),
]);
if (configsResponse.success && configsResponse.data) {
setBatchConfigs(configsResponse.data);
// 각 배치의 스파크라인을 백그라운드로 로드
const ids = configsResponse.data.map(b => b.id!).filter(Boolean);
Promise.all(ids.map(id => BatchAPI.getBatchSparkline(id).then(data => ({ id, data })))).then(results => {
const cache: Record<number, SparklineData[]> = {};
results.forEach(r => { cache[r.id] = r.data; });
setSparklineCache(prev => ({ ...prev, ...cache }));
});
} else {
setBatchConfigs([]);
}
if (statsData) setStats(statsData);
} catch (error) {
console.error("배치 목록 조회 실패:", error);
toast.error("배치 목록을 불러오는데 실패했습니다.");
toast.error("배치 목록을 불러올 수 없어요");
setBatchConfigs([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadBatchConfigs(); }, [loadBatchConfigs]);
const handleRowClick = async (batchId: number) => {
if (expandedBatch === batchId) { setExpandedBatch(null); return; }
setExpandedBatch(batchId);
if (!sparklineCache[batchId]) {
const [spark, logs] = await Promise.all([
BatchAPI.getBatchSparkline(batchId),
BatchAPI.getBatchRecentLogs(batchId, 5),
]);
setSparklineCache((prev) => ({ ...prev, [batchId]: spark }));
setRecentLogsCache((prev) => ({ ...prev, [batchId]: logs }));
}
};
// 배치 수동 실행
const executeBatch = async (batchId: number) => {
const toggleBatchActive = async (batchId: number, currentActive: string) => {
const newActive = currentActive === "Y" ? "N" : "Y";
setTogglingBatch(batchId);
try {
await BatchAPI.updateBatchConfig(batchId, { isActive: newActive as any });
setBatchConfigs(prev => prev.map(b => b.id === batchId ? { ...b, is_active: newActive as "Y" | "N" } : b));
toast.success(newActive === "Y" ? "배치를 켰어요" : "배치를 껐어요");
} catch {
toast.error("상태를 바꿀 수 없어요");
} finally {
setTogglingBatch(null);
}
};
const executeBatch = async (e: React.MouseEvent, batchId: number) => {
e.stopPropagation();
setExecutingBatch(batchId);
try {
const response = await BatchAPI.executeBatchConfig(batchId);
if (response.success) {
toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`);
toast.success(`실행 완료! ${response.data?.totalRecords || 0}건 처리했어요`);
setSparklineCache((prev) => { const c = { ...prev }; delete c[batchId]; return c; });
setRecentLogsCache((prev) => { const c = { ...prev }; delete c[batchId]; return c; });
loadBatchConfigs();
} else {
toast.error("배치 실행에 실패했습니다.");
toast.error("배치 실행에 실패했어요");
}
} catch (error) {
console.error("배치 실행 실패:", error);
showErrorToast("배치 실행에 실패했습니다", error, {
guidance: "배치 설정을 확인하고 다시 시도해 주세요.",
});
showErrorToast("배치 실행 실패", error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
} finally {
setExecutingBatch(null);
}
};
// 배치 활성화/비활성화 토글
const toggleBatchStatus = async (batchId: number, currentStatus: string) => {
console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus });
try {
const newStatus = currentStatus === 'Y' ? 'N' : 'Y';
console.log("📝 새로운 상태:", newStatus);
const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus });
console.log("✅ API 호출 성공:", result);
toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`);
loadBatchConfigs(); // 목록 새로고침
} catch (error) {
console.error("❌ 배치 상태 변경 실패:", error);
toast.error("배치 상태 변경에 실패했습니다.");
}
};
// 배치 삭제
const deleteBatch = async (batchId: number, batchName: string) => {
if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) {
return;
}
const deleteBatch = async (e: React.MouseEvent, batchId: number, batchName: string) => {
e.stopPropagation();
if (!confirm(`'${batchName}' 배치를 삭제할까요?`)) return;
try {
await BatchAPI.deleteBatchConfig(batchId);
toast.success("배치가 삭제되었습니다.");
loadBatchConfigs(); // 목록 새로고침
} catch (error) {
console.error("배치 삭제 실패:", error);
toast.error("배치 삭제에 실패했습니다.");
toast.success("배치를 삭제했어요");
loadBatchConfigs();
} catch {
toast.error("배치 삭제에 실패했어요");
}
};
// 검색 처리
const handleSearch = (value: string) => {
setSearchTerm(value);
setCurrentPage(1); // 검색 시 첫 페이지로 이동
};
// 매핑 정보 요약 생성
const getMappingSummary = (mappings: BatchMapping[]) => {
if (!mappings || mappings.length === 0) {
return "매핑 없음";
}
const tableGroups = new Map<string, number>();
mappings.forEach(mapping => {
const key = `${mapping.from_table_name}${mapping.to_table_name}`;
tableGroups.set(key, (tableGroups.get(key) || 0) + 1);
});
const summaries = Array.from(tableGroups.entries()).map(([key, count]) =>
`${key} (${count}개 컬럼)`
);
return summaries.join(", ");
};
// 배치 추가 버튼 클릭 핸들러
const handleCreateBatch = () => {
setIsBatchTypeModalOpen(true);
};
// 배치 타입 선택 핸들러
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => {
console.log("배치 타입 선택:", type);
const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db" | "node-flow") => {
setIsBatchTypeModalOpen(false);
if (type === 'db-to-db') {
// 기존 DB → DB 배치 생성 페이지로 이동
console.log("DB → DB 페이지로 이동:", '/admin/batchmng/create');
router.push('/admin/batchmng/create');
} else if (type === 'restapi-to-db') {
// 새로운 REST API 배치 페이지로 이동
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
try {
router.push('/admin/batch-management-new');
console.log("라우터 push 실행 완료");
} catch (error) {
console.error("라우터 push 오류:", error);
// 대안: window.location 사용
window.location.href = '/admin/batch-management-new';
}
if (type === "db-to-db") {
sessionStorage.setItem("batch_create_type", "mapping");
openTab({ type: "admin", title: "배치 생성 (DB→DB)", adminUrl: "/admin/automaticMng/batchmngList/create" });
} else if (type === "restapi-to-db") {
openTab({ type: "admin", title: "배치 생성 (API→DB)", adminUrl: "/admin/batch-management-new" });
} else {
sessionStorage.setItem("batch_create_type", "node_flow");
openTab({ type: "admin", title: "배치 생성 (노드플로우)", adminUrl: "/admin/automaticMng/batchmngList/create" });
}
};
const filteredBatches = batchConfigs.filter((batch) => {
if (searchTerm && !batch.batch_name.toLowerCase().includes(searchTerm.toLowerCase()) && !(batch.description || "").toLowerCase().includes(searchTerm.toLowerCase())) return false;
if (statusFilter === "active" && batch.is_active !== "Y") return false;
if (statusFilter === "inactive" && batch.is_active !== "N") return false;
return true;
});
const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length;
const inactiveBatches = batchConfigs.length - activeBatches;
const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0;
const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0;
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> .</p>
</div>
<div className="mx-auto w-full max-w-[720px] space-y-4 px-4 py-6 sm:px-6">
{/* 검색 및 액션 영역 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
{/* 검색 영역 */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="w-full sm:w-[400px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="배치명 또는 설명으로 검색..."
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
</div>
<Button
variant="outline"
onClick={loadBatchConfigs}
disabled={loading}
className="h-10 gap-2 text-sm font-medium"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-bold tracking-tight"> </h1>
<p className="text-xs text-muted-foreground"> </p>
</div>
{/* 액션 버튼 영역 */}
<div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground">
{" "}
<span className="font-semibold text-foreground">
{batchConfigs.length.toLocaleString()}
</span>{" "}
</div>
<Button
onClick={handleCreateBatch}
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
<div className="flex items-center gap-2">
<button onClick={loadBatchConfigs} disabled={loading} className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</button>
<Button size="sm" onClick={() => setIsBatchTypeModalOpen(true)} className="h-8 gap-1 text-xs">
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 배치 목록 */}
{batchConfigs.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="flex flex-col items-center gap-4 text-center">
<Database className="h-12 w-12 text-muted-foreground" />
<div className="space-y-2">
<h3 className="text-lg font-semibold"> </h3>
<p className="text-sm text-muted-foreground">
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
</p>
</div>
{!searchTerm && (
<Button
onClick={handleCreateBatch}
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
</Button>
{/* 통계 요약 스트립 */}
{stats && (
<div className="flex items-center gap-0 rounded-lg border bg-card">
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"></span>
<span className="text-lg font-bold">{batchConfigs.length}</span>
</div>
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"> </span>
<span className="text-lg font-bold text-primary">{activeBatches}</span>
</div>
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"> </span>
<span className="text-lg font-bold text-emerald-600">{stats.todayExecutions}</span>
{execDiff !== 0 && (
<span className={`text-[10px] ${execDiff > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
{execDiff > 0 ? "+" : ""}{execDiff}
</span>
)}
</div>
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"></span>
<span className={`text-lg font-bold ${stats.todayFailures > 0 ? "text-destructive" : "text-muted-foreground"}`}>
{stats.todayFailures}
</span>
{failDiff !== 0 && (
<span className={`text-[10px] ${failDiff > 0 ? "text-destructive" : "text-emerald-500"}`}>
{failDiff > 0 ? "+" : ""}{failDiff}
</span>
)}
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{batchConfigs.map((batch) => (
<BatchCard
key={batch.id}
batch={batch}
executingBatch={executingBatch}
onExecute={executeBatch}
onToggleStatus={(batchId, currentStatus) => {
toggleBatchStatus(batchId, currentStatus);
}}
onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)}
onDelete={deleteBatch}
getMappingSummary={getMappingSummary}
/>
))}
</div>
)}
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="h-10 text-sm font-medium"
>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const pageNum = i + 1;
return (
<Button
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
onClick={() => setCurrentPage(pageNum)}
className="h-10 min-w-[40px] text-sm"
>
{pageNum}
</Button>
);
})}
</div>
<Button
variant="outline"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="h-10 text-sm font-medium"
>
</Button>
{/* 24시간 차트 */}
<GlobalSparkline stats={stats} />
{/* 검색 + 필터 */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[180px] flex-1">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="배치 이름으로 검색하세요" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="h-8 pl-9 text-xs" />
</div>
)}
<div className="flex gap-0.5 rounded-lg border bg-muted/30 p-0.5">
{([
{ value: "all", label: `전체 ${batchConfigs.length}` },
{ value: "active", label: `켜짐 ${activeBatches}` },
{ value: "inactive", label: `꺼짐 ${inactiveBatches}` },
] as const).map((item) => (
<button
key={item.value}
className={`rounded-md px-2.5 py-1 text-[11px] font-semibold transition-colors ${statusFilter === item.value ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => setStatusFilter(item.value)}
>
{item.label}
</button>
))}
</div>
</div>
{/* 배치 리스트 */}
<div className="space-y-1.5">
{loading && batchConfigs.length === 0 && (
<div className="flex h-40 items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!loading && filteredBatches.length === 0 && (
<div className="flex h-40 flex-col items-center justify-center gap-2">
<Database className="h-6 w-6 text-muted-foreground/40" />
<p className="text-xs text-muted-foreground">{searchTerm ? "검색 결과가 없어요" : "등록된 배치가 없어요"}</p>
</div>
)}
{filteredBatches.map((batch) => {
const batchId = batch.id!;
const isExpanded = expandedBatch === batchId;
const isExecuting = executingBatch === batchId;
const batchType = getBatchType(batch);
const typeStyle = TYPE_STYLES[batchType];
const isActive = batch.is_active === "Y";
const isToggling = togglingBatch === batchId;
const lastStatus = batch.last_status;
const lastAt = batch.last_executed_at;
const isFailed = lastStatus === "FAILED";
const isSuccess = lastStatus === "SUCCESS";
return (
<div key={batchId} className={`overflow-hidden rounded-lg border transition-all ${isExpanded ? "ring-1 ring-primary/20" : "hover:border-muted-foreground/20"} ${!isActive ? "opacity-55" : ""}`}>
{/* 행 */}
<div className="flex cursor-pointer items-center gap-3 px-4 py-3.5 sm:gap-4" onClick={() => handleRowClick(batchId)}>
{/* 토글 */}
<div onClick={(e) => e.stopPropagation()} className="shrink-0">
<Switch
checked={isActive}
onCheckedChange={() => toggleBatchActive(batchId, batch.is_active || "N")}
disabled={isToggling}
className="scale-[0.7]"
/>
</div>
{/* 배치 이름 + 설명 */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{batch.batch_name}</p>
<p className="truncate text-[11px] text-muted-foreground">{batch.description || ""}</p>
</div>
{/* 타입 뱃지 */}
<span className={`hidden shrink-0 rounded border px-2 py-0.5 text-[10px] font-semibold sm:inline-flex ${typeStyle.className}`}>
{typeStyle.label}
</span>
{/* 스케줄 */}
<div className="hidden shrink-0 text-right sm:block" style={{ minWidth: 90 }}>
<p className="text-[12px] font-medium">{cronToKorean(batch.cron_schedule)}</p>
<p className="text-[10px] text-muted-foreground">
{getNextExecution(batch.cron_schedule, isActive)
? `다음: ${getNextExecution(batch.cron_schedule, isActive)}`
: ""}
</p>
</div>
{/* 인라인 미니 스파크라인 */}
<div className="hidden shrink-0 sm:block" style={{ width: 64 }}>
<Sparkline data={sparklineCache[batchId] || []} />
</div>
{/* 마지막 실행 */}
<div className="hidden shrink-0 text-right sm:block" style={{ minWidth: 70 }}>
{isExecuting ? (
<p className="text-[11px] font-semibold text-amber-500"> ...</p>
) : lastAt ? (
<>
<div className="flex items-center justify-end gap-1">
{isFailed ? (
<AlertCircle className="h-3 w-3 text-destructive" />
) : isSuccess ? (
<CheckCircle className="h-3 w-3 text-emerald-500" />
) : null}
<span className={`text-[11px] font-semibold ${isFailed ? "text-destructive" : "text-emerald-500"}`}>
{isFailed ? "실패" : "성공"}
</span>
</div>
<p className="text-[10px] text-muted-foreground">{timeAgo(lastAt)}</p>
</>
) : (
<p className="text-[11px] text-muted-foreground">&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="지금 실행하기"
>
{isExecuting ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
</button>
<button
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={(e) => { e.stopPropagation(); openTab({ type: "admin", title: `배치 편집 #${batchId}`, adminUrl: `/admin/automaticMng/batchmngList/edit/${batchId}` }); }}
title="수정하기"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => deleteBatch(e, batchId, batch.batch_name)}
title="삭제하기"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<ChevronDown className={`ml-0.5 h-3.5 w-3.5 text-muted-foreground transition-transform ${isExpanded ? "rotate-180" : ""}`} />
</div>
</div>
{/* 모바일 메타 */}
<div className="flex items-center gap-2 px-4 pb-2 sm:hidden">
<span className={`rounded border px-1.5 py-0.5 text-[9px] font-semibold ${typeStyle.className}`}>{typeStyle.label}</span>
<span className="text-[10px] text-muted-foreground">{cronToKorean(batch.cron_schedule)}</span>
{lastAt && (
<span className={`ml-auto text-[10px] font-semibold ${isFailed ? "text-destructive" : "text-emerald-500"}`}>
{isFailed ? "실패" : "성공"} {timeAgo(lastAt)}
</span>
)}
</div>
{/* 확장 패널 */}
{isExpanded && (
<BatchDetailPanel batch={batch} sparkline={sparklineCache[batchId] || []} recentLogs={recentLogsCache[batchId] || []} />
)}
</div>
);
})}
</div>
{/* 배치 타입 선택 모달 */}
{isBatchTypeModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="w-full max-w-2xl rounded-lg border bg-card p-6 shadow-lg">
<div className="space-y-6">
<h2 className="text-xl font-semibold text-center"> </h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* DB → DB */}
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" onClick={() => setIsBatchTypeModalOpen(false)}>
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-lg" onClick={(e) => e.stopPropagation()}>
<h2 className="mb-1 text-base font-bold"> ?</h2>
<p className="mb-5 text-xs text-muted-foreground"> </p>
<div className="space-y-2">
{[
{ type: "db-to-db" as const, icon: Database, iconColor: "text-cyan-500", title: "DB → DB", desc: "테이블 데이터를 다른 테이블로 복사해요" },
{ type: "restapi-to-db" as const, icon: Cloud, iconColor: "text-violet-500", title: "API → DB", desc: "외부 API에서 데이터를 가져와 저장해요" },
{ type: "node-flow" as const, icon: Workflow, iconColor: "text-indigo-500", title: "노드 플로우", desc: "만들어 둔 플로우를 자동으로 실행해요" },
].map((opt) => (
<button
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
onClick={() => handleBatchTypeSelect('db-to-db')}
key={opt.type}
className="flex w-full items-center gap-3.5 rounded-lg border p-4 text-left transition-all hover:border-primary/30 hover:bg-primary/5"
onClick={() => handleBatchTypeSelect(opt.type)}
>
<div className="flex items-center gap-2">
<Database className="h-8 w-8 text-primary" />
<span className="text-muted-foreground"></span>
<Database className="h-8 w-8 text-primary" />
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<opt.icon className={`h-[18px] w-[18px] ${opt.iconColor}`} />
</div>
<div className="space-y-1 text-center">
<div className="text-lg font-medium">DB DB</div>
<div className="text-sm text-muted-foreground"> </div>
<div>
<p className="text-sm font-semibold">{opt.title}</p>
<p className="text-[11px] text-muted-foreground">{opt.desc}</p>
</div>
</button>
{/* REST API → DB */}
<button
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
onClick={() => handleBatchTypeSelect('restapi-to-db')}
>
<div className="flex items-center gap-2">
<span className="text-2xl">🌐</span>
<span className="text-muted-foreground"></span>
<Database className="h-8 w-8 text-primary" />
</div>
<div className="space-y-1 text-center">
<div className="text-lg font-medium">REST API DB</div>
<div className="text-sm text-muted-foreground">REST API에서 </div>
</div>
</button>
</div>
<div className="flex justify-center pt-2">
<Button
variant="outline"
onClick={() => setIsBatchTypeModalOpen(false)}
className="h-10 text-sm font-medium"
>
</Button>
</div>
))}
</div>
<button onClick={() => setIsBatchTypeModalOpen(false)} className="mt-4 w-full rounded-md border py-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
</button>
</div>
</div>
)}
</div>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}
}

View File

@ -2,6 +2,7 @@
import React, { useState, useEffect, useMemo, memo } from "react";
import { useRouter } from "next/navigation";
import { useTabStore } from "@/stores/tabStore";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -62,6 +63,7 @@ interface DbToRestApiMappingCardProps {
export default function BatchManagementNewPage() {
const router = useRouter();
const { openTab } = useTabStore();
// 기본 상태
const [batchName, setBatchName] = useState("");
@ -463,7 +465,7 @@ export default function BatchManagementNewPage() {
if (result.success) {
toast.success(result.message || "REST API 배치 설정이 저장되었습니다.");
setTimeout(() => {
router.push("/admin/batchmng");
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
}, 1000);
} else {
toast.error(result.message || "배치 저장에 실패했습니다.");
@ -554,7 +556,7 @@ export default function BatchManagementNewPage() {
if (result.success) {
toast.success(result.message || "DB → REST API 배치 설정이 저장되었습니다.");
setTimeout(() => {
router.push("/admin/batchmng");
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
}, 1000);
} else {
toast.error(result.message || "배치 저장에 실패했습니다.");
@ -571,79 +573,68 @@ export default function BatchManagementNewPage() {
toast.error("지원하지 않는 배치 타입입니다.");
};
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
return (
<div className="container mx-auto space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="border-b pb-4">
<h1 className="text-3xl font-bold"> </h1>
<div className="mx-auto max-w-5xl space-y-6 p-4 sm:p-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={goBack} className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
<ArrowLeft className="h-4 w-4" />
</button>
<div>
<h1 className="text-lg font-semibold sm:text-xl"> </h1>
<p className="text-xs text-muted-foreground">REST API / DB </p>
</div>
</div>
</div>
{/* 배치 타입 선택 */}
<div className="grid grid-cols-2 gap-3">
{batchTypeOptions.map((option) => (
<button
key={option.value}
onClick={() => setBatchType(option.value)}
className={`group relative flex items-center gap-3 rounded-lg border p-4 text-left transition-all ${
batchType === option.value
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/30 hover:bg-muted/50"
}`}
>
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${batchType === option.value ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
{option.value === "restapi-to-db" ? <Globe className="h-5 w-5" /> : <Database className="h-5 w-5" />}
</div>
<div className="min-w-0">
<div className="text-sm font-medium">{option.label}</div>
<div className="text-[11px] text-muted-foreground">{option.description}</div>
</div>
{batchType === option.value && <div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />}
</button>
))}
</div>
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 배치 타입 선택 */}
<div>
<Label> *</Label>
<div className="mt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
{batchTypeOptions.map((option) => (
<div
key={option.value}
className={`cursor-pointer rounded-lg border p-3 transition-all ${
batchType === option.value ? "border-primary bg-primary/10" : "border-border hover:border-input"
}`}
onClick={() => setBatchType(option.value)}
>
<div className="flex items-center space-x-2">
{option.value === "restapi-to-db" ? (
<Globe className="h-4 w-4 text-primary" />
) : (
<Database className="h-4 w-4 text-emerald-600" />
)}
<div>
<div className="text-sm font-medium">{option.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{option.description}</div>
</div>
</div>
</div>
))}
</div>
<div className="space-y-4 rounded-lg border p-4 sm:p-5">
<div className="flex items-center gap-2 text-sm font-medium">
<Eye className="h-4 w-4 text-muted-foreground" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="batchName" className="text-xs"> <span className="text-destructive">*</span></Label>
<Input id="batchName" value={batchName} onChange={e => setBatchName(e.target.value)} placeholder="배치명을 입력하세요" className="h-9 text-sm" />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label htmlFor="batchName"> *</Label>
<Input
id="batchName"
value={batchName}
onChange={(e) => setBatchName(e.target.value)}
placeholder="배치명을 입력하세요"
/>
</div>
<div>
<Label htmlFor="cronSchedule"> *</Label>
<Input
id="cronSchedule"
value={cronSchedule}
onChange={(e) => setCronSchedule(e.target.value)}
placeholder="0 12 * * *"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cronSchedule" className="text-xs"> <span className="text-destructive">*</span></Label>
<Input id="cronSchedule" value={cronSchedule} onChange={e => setCronSchedule(e.target.value)} placeholder="0 12 * * *" className="h-9 font-mono text-sm" />
</div>
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="배치에 대한 설명을 입력하세요"
/>
</div>
</CardContent>
</Card>
</div>
<div className="space-y-1.5">
<Label htmlFor="description" className="text-xs"></Label>
<Textarea id="description" value={description} onChange={e => setDescription(e.target.value)} placeholder="배치에 대한 설명을 입력하세요" rows={2} className="resize-none text-sm" />
</div>
</div>
{/* FROM/TO 설정 - 가로 배치 */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
@ -1426,13 +1417,14 @@ export default function BatchManagementNewPage() {
)}
{/* 하단 액션 버튼 */}
<div className="flex items-center justify-end gap-2 border-t pt-6">
<Button onClick={loadConnections} variant="outline" className="gap-2">
<RefreshCw className="h-4 w-4" />
<div className="flex items-center justify-end gap-2 border-t pt-4">
<Button onClick={goBack} variant="outline" size="sm" className="h-8 gap-1 text-xs"></Button>
<Button onClick={loadConnections} variant="outline" size="sm" className="h-8 gap-1 text-xs">
<RefreshCw className="h-3.5 w-3.5" />
</Button>
<Button onClick={handleSave} className="gap-2">
<Save className="h-4 w-4" />
<Button onClick={handleSave} size="sm" className="h-8 gap-1 text-xs">
<Save className="h-3.5 w-3.5" />
</Button>
</div>

View File

@ -1,76 +1,66 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import DataFlowList from "@/components/dataflow/DataFlowList";
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { Button } from "@/components/ui/button";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ArrowLeft } from "lucide-react";
type Step = "list" | "editor";
export default function DataFlowPage() {
const { user } = useAuth();
const router = useRouter();
const [currentStep, setCurrentStep] = useState<Step>("list");
const [loadingFlowId, setLoadingFlowId] = useState<number | null>(null);
// 플로우 불러오기 핸들러
const handleLoadFlow = async (flowId: number | null) => {
if (flowId === null) {
// 새 플로우 생성
setLoadingFlowId(null);
setCurrentStep("editor");
return;
}
try {
// 기존 플로우 불러오기
setLoadingFlowId(flowId);
setCurrentStep("editor");
toast.success("플로우를 불러왔습니다.");
toast.success("플로우를 불러왔어요");
} catch (error: any) {
console.error("❌ 플로우 불러오기 실패:", error);
showErrorToast("플로우 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
console.error("플로우 불러오기 실패:", error);
showErrorToast("플로우를 불러오는 데 실패했어요", error, {
guidance: "네트워크 연결을 확인해 주세요.",
});
}
};
// 목록으로 돌아가기
const handleBackToList = () => {
setCurrentStep("list");
setLoadingFlowId(null);
};
// 에디터 모드일 때는 전체 화면 사용
const isEditorMode = currentStep === "editor";
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
if (isEditorMode) {
if (currentStep === "editor") {
return (
<div className="bg-background fixed inset-0 z-50">
<div className="flex h-full flex-col">
{/* 에디터 헤더 */}
<div className="bg-background flex items-center gap-4 border-b p-4">
<Button variant="outline" size="sm" onClick={handleBackToList} className="flex items-center gap-2">
<div className="bg-background flex items-center gap-4 border-b px-5 py-3">
<Button
variant="ghost"
size="sm"
onClick={handleBackToList}
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-sm"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground mt-1 text-sm">
</p>
</div>
</div>
{/* 플로우 에디터 */}
<div className="flex-1 overflow-hidden">
<FlowEditor key={loadingFlowId || "new"} initialFlowId={loadingFlowId} />
<FlowEditor
key={loadingFlowId || "new"}
initialFlowId={loadingFlowId}
/>
</div>
</div>
</div>
@ -78,20 +68,10 @@ export default function DataFlowPage() {
}
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-4 sm:p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-sm"> </p>
</div>
{/* 플로우 목록 */}
<div className="h-full overflow-y-auto">
<div className="mx-auto w-full max-w-[1400px] space-y-6 p-4 sm:p-6 pb-20">
<DataFlowList onLoadFlow={handleLoadFlow} />
</div>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}

View File

@ -34,8 +34,7 @@ import { commonCodeApi } from "@/lib/api/commonCode";
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
import { ddlApi } from "@/lib/api/ddl";
import { getSecondLevelMenus, createColumnMapping, deleteColumnMappingsByColumn } from "@/lib/api/tableCategoryValue";
import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule";
import { NumberingRuleConfig } from "@/types/numbering-rule";
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
import { CreateTableModal } from "@/components/admin/CreateTableModal";
import { AddColumnModal } from "@/components/admin/AddColumnModal";
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
@ -102,10 +101,7 @@ export default function TableManagementPage() {
// 🆕 Category 타입용: 2레벨 메뉴 목록
const [secondLevelMenus, setSecondLevelMenus] = useState<SecondLevelMenu[]>([]);
// 🆕 Numbering 타입용: 채번규칙 목록
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [numberingRulesLoading, setNumberingRulesLoading] = useState(false);
const [numberingComboboxOpen, setNumberingComboboxOpen] = useState<Record<string, boolean>>({});
// 채번 타입은 옵션설정 > 채번설정에서 관리 (별도 선택 불필요)
// 로그 뷰어 상태
const [logViewerOpen, setLogViewerOpen] = useState(false);
@ -281,24 +277,6 @@ export default function TableManagementPage() {
};
// 🆕 채번규칙 목록 로드
const loadNumberingRules = async () => {
setNumberingRulesLoading(true);
try {
const response = await getNumberingRules();
if (response.success && response.data) {
setNumberingRules(response.data);
} else {
console.warn("⚠️ 채번규칙 로드 실패:", response);
setNumberingRules([]);
}
} catch (error) {
console.error("❌ 채번규칙 로드 에러:", error);
setNumberingRules([]);
} finally {
setNumberingRulesLoading(false);
}
};
// 테이블 목록 로드
const loadTables = async () => {
setLoading(true);
@ -344,9 +322,7 @@ export default function TableManagementPage() {
// 컬럼 데이터에 기본값 설정
const processedColumns = (data.columns || data).map((col: any) => {
// detailSettings에서 hierarchyRole, numberingRuleId 추출
let hierarchyRole: "large" | "medium" | "small" | undefined = undefined;
let numberingRuleId: string | undefined = undefined;
if (col.detailSettings && typeof col.detailSettings === "string") {
try {
const parsed = JSON.parse(col.detailSettings);
@ -357,9 +333,6 @@ export default function TableManagementPage() {
) {
hierarchyRole = parsed.hierarchyRole;
}
if (parsed.numberingRuleId) {
numberingRuleId = parsed.numberingRuleId;
}
} catch {
// JSON 파싱 실패 시 무시
}
@ -369,7 +342,6 @@ export default function TableManagementPage() {
...col,
inputType: col.inputType || "text",
isUnique: col.isUnique || "NO",
numberingRuleId,
categoryMenus: col.categoryMenus || [],
hierarchyRole,
categoryRef: col.categoryRef || null,
@ -1000,7 +972,6 @@ export default function TableManagementPage() {
loadTables();
loadCommonCodeCategories();
loadSecondLevelMenus();
loadNumberingRules();
}, []);
// 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드
@ -1633,7 +1604,7 @@ export default function TableManagementPage() {
tables={tables}
referenceTableColumns={referenceTableColumns}
secondLevelMenus={secondLevelMenus}
numberingRules={numberingRules}
numberingRules={[]}
onColumnChange={(field, value) => {
if (!selectedColumn) return;
if (field === "inputType") {

View File

@ -306,16 +306,126 @@ select {
}
}
/* ===== Sonner 토스트 애니메이션 완전 제거 ===== */
[data-sonner-toaster] [data-sonner-toast] {
animation: none !important;
transition: none !important;
opacity: 1 !important;
transform: none !important;
/* ===== Sonner Toast - B안 (하단 중앙 스낵바) ===== */
/* 기본 토스트: 다크 배경 스낵바 */
[data-sonner-toaster] [data-sonner-toast].sonner-toast-snackbar {
--normal-bg: hsl(222 30% 16%);
--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"] {
animation: none !important;
}

View File

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

View File

@ -16,6 +16,7 @@ import {
Activity,
Settings
} from "lucide-react";
import { apiClient } from "@/lib/api/client";
import { BatchConfig } from "@/lib/api/batch";
interface BatchCardProps {
@ -78,7 +79,7 @@ export default function BatchCard({
</span>
<span className="font-medium">
{new Date(batch.created_date).toLocaleDateString('ko-KR')}
{batch.created_date ? new Date(batch.created_date).toLocaleDateString('ko-KR') : '-'}
</span>
</div>
@ -118,7 +119,7 @@ export default function BatchCard({
<Button
variant="outline"
size="sm"
onClick={() => onExecute(batch.id)}
onClick={() => batch.id != null && onExecute(batch.id)}
disabled={isExecuting}
className="h-9 flex-1 gap-2 text-sm"
>
@ -134,7 +135,7 @@ export default function BatchCard({
<Button
variant="outline"
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"
>
{isActive ? (
@ -149,7 +150,7 @@ export default function BatchCard({
<Button
variant="outline"
size="sm"
onClick={() => onEdit(batch.id)}
onClick={() => batch.id != null && onEdit(batch.id)}
className="h-9 flex-1 gap-2 text-sm"
>
<Edit className="h-4 w-4" />
@ -160,7 +161,7 @@ export default function BatchCard({
<Button
variant="destructive"
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"
>
<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 { INPUT_TYPE_COLORS } from "./types";
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
import type { NumberingRuleConfig } from "@/types/numbering-rule";
export interface ColumnDetailPanelProps {
column: ColumnTypeInfo | null;
tables: TableInfo[];
referenceTableColumns: Record<string, ReferenceTableColumn[]>;
secondLevelMenus: SecondLevelMenu[];
numberingRules: NumberingRuleConfig[];
numberingRules: any[];
onColumnChange: (field: keyof ColumnTypeInfo, value: unknown) => void;
onClose: () => void;
onLoadReferenceColumns?: (tableName: string) => void;
@ -53,7 +52,6 @@ export function ColumnDetailPanel({
const [advancedOpen, setAdvancedOpen] = React.useState(false);
const [entityTableOpen, setEntityTableOpen] = React.useState(false);
const [entityColumnOpen, setEntityColumnOpen] = React.useState(false);
const [numberingOpen, setNumberingOpen] = React.useState(false);
const typeConf = column ? INPUT_TYPE_COLORS[column.inputType || "text"] : null;
const refColumns = column?.referenceTable
@ -404,53 +402,10 @@ export function ColumnDetailPanel({
<Settings2 className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"> </Label>
</div>
<Popover open={numberingOpen} onOpenChange={setNumberingOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between text-xs">
{column.numberingRuleId
? numberingRules.find((r) => r.ruleId === column.numberingRuleId)?.ruleName ?? column.numberingRuleId
: "규칙 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="규칙 검색..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
<CommandItem
value="none"
onSelect={() => {
onColumnChange("numberingRuleId", undefined);
setNumberingOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", !column.numberingRuleId ? "opacity-100" : "opacity-0")} />
</CommandItem>
{numberingRules.map((r) => (
<CommandItem
key={r.ruleId}
value={`${r.ruleName} ${r.ruleId}`}
onSelect={() => {
onColumnChange("numberingRuleId", r.ruleId);
setNumberingOpen(false);
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", column.numberingRuleId === r.ruleId ? "opacity-100" : "opacity-0")}
/>
{r.ruleName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="rounded-md border border-border bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
&gt; .
.
</p>
</section>
)}

View File

@ -1,14 +1,8 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
@ -17,12 +11,39 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { MoreHorizontal, Trash2, Copy, Plus, Search, Network, Calendar } from "lucide-react";
import {
Plus,
Search,
Network,
RefreshCw,
Pencil,
Copy,
Trash2,
LayoutGrid,
List,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { useAuth } from "@/hooks/useAuth";
import { apiClient } from "@/lib/api/client";
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
import { getNodePaletteItem } from "@/components/dataflow/node-editor/sidebar/nodePaletteConfig";
interface TopologyNode {
id: string;
type: string;
x: number;
y: number;
}
interface FlowSummary {
nodeCount: number;
edgeCount: number;
nodeTypes: Record<string, number>;
topology: {
nodes: TopologyNode[];
edges: [string, string][];
} | null;
}
interface NodeFlow {
flowId: number;
@ -30,18 +51,205 @@ interface NodeFlow {
flowDescription: string;
createdAt: string;
updatedAt: string;
summary: FlowSummary;
}
interface DataFlowListProps {
onLoadFlow: (flowId: number | null) => void;
}
const CATEGORY_COLORS: Record<string, { text: string; bg: string; border: string }> = {
source: { text: "text-teal-400", bg: "bg-teal-500/10", border: "border-teal-500/20" },
transform: { text: "text-violet-400", bg: "bg-violet-500/10", border: "border-violet-500/20" },
action: { text: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
external: { text: "text-pink-400", bg: "bg-pink-500/10", border: "border-pink-500/20" },
utility: { text: "text-zinc-400", bg: "bg-zinc-500/10", border: "border-zinc-500/20" },
};
function getNodeCategoryColor(nodeType: string) {
const item = getNodePaletteItem(nodeType);
const cat = item?.category || "utility";
return CATEGORY_COLORS[cat] || CATEGORY_COLORS.utility;
}
function getNodeLabel(nodeType: string) {
const item = getNodePaletteItem(nodeType);
return item?.label || nodeType;
}
function getNodeColor(nodeType: string): string {
const item = getNodePaletteItem(nodeType);
return item?.color || "#6B7280";
}
function relativeTime(dateStr: string): string {
const now = Date.now();
const d = new Date(dateStr).getTime();
const diff = now - d;
const min = Math.floor(diff / 60000);
if (min < 1) return "방금 전";
if (min < 60) return `${min}분 전`;
const h = Math.floor(min / 60);
if (h < 24) return `${h}시간 전`;
const day = Math.floor(h / 24);
if (day < 30) return `${day}일 전`;
const month = Math.floor(day / 30);
return `${month}개월 전`;
}
function MiniTopology({ topology }: { topology: FlowSummary["topology"] }) {
if (!topology || topology.nodes.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<span className="font-mono text-[10px] text-zinc-600"> </span>
</div>
);
}
const W = 340;
const H = 88;
const padX = 40;
const padY = 18;
const nodeMap = new Map(topology.nodes.map((n) => [n.id, n]));
return (
<svg viewBox={`0 0 ${W} ${H}`} fill="none" className="h-full w-full">
{topology.edges.map(([src, tgt], i) => {
const s = nodeMap.get(src);
const t = nodeMap.get(tgt);
if (!s || !t) return null;
const sx = padX + s.x * (W - padX * 2);
const sy = padY + s.y * (H - padY * 2);
const tx = padX + t.x * (W - padX * 2);
const ty = padY + t.y * (H - padY * 2);
const mx = (sx + tx) / 2;
const my = (sy + ty) / 2 - 8;
return (
<path
key={`e-${i}`}
d={`M${sx} ${sy}Q${mx} ${my} ${tx} ${ty}`}
stroke="rgba(108,92,231,0.25)"
strokeWidth="1.5"
/>
);
})}
{topology.nodes.map((n) => {
const cx = padX + n.x * (W - padX * 2);
const cy = padY + n.y * (H - padY * 2);
const color = getNodeColor(n.type);
return (
<g key={n.id}>
<circle cx={cx} cy={cy} r="5" fill={`${color}20`} stroke={color} strokeWidth="1.5" />
<circle cx={cx} cy={cy} r="2" fill={color} />
</g>
);
})}
</svg>
);
}
function FlowCard({
flow,
onOpen,
onCopy,
onDelete,
}: {
flow: NodeFlow;
onOpen: () => void;
onCopy: () => void;
onDelete: () => void;
}) {
const chips = useMemo(() => {
const entries = Object.entries(flow.summary?.nodeTypes || {});
return entries.slice(0, 4).map(([type, count]) => {
const colors = getNodeCategoryColor(type);
const label = getNodeLabel(type);
return { type, count, label, colors };
});
}, [flow.summary?.nodeTypes]);
return (
<div
className="group relative cursor-pointer overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/80 transition-all duration-200 hover:-translate-y-0.5 hover:border-violet-500/50 hover:shadow-lg hover:shadow-violet-500/5"
onClick={onOpen}
>
{/* 미니 토폴로지 */}
<div className="relative h-[88px] overflow-hidden border-b border-zinc-800/60 bg-gradient-to-b from-violet-500/[0.03] to-transparent">
<MiniTopology topology={flow.summary?.topology} />
</div>
{/* 카드 바디 */}
<div className="px-4 pb-3 pt-3.5">
<h3 className="mb-1 truncate text-sm font-semibold tracking-tight text-zinc-100">
{flow.flowName}
</h3>
<p className="mb-3 line-clamp-2 min-h-[2.5rem] text-[11px] leading-relaxed text-zinc-500">
{flow.flowDescription || "설명이 아직 없어요"}
</p>
{/* 노드 타입 칩 */}
{chips.length > 0 && (
<div className="mb-3 flex flex-wrap gap-1.5">
{chips.map(({ type, count, label, colors }) => (
<span
key={type}
className={`inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${colors.text} ${colors.bg} ${colors.border}`}
>
{label} {count}
</span>
))}
</div>
)}
</div>
{/* 카드 푸터 */}
<div className="flex items-center justify-between border-t border-zinc-800/40 px-4 py-2.5">
<span className="font-mono text-[11px] text-zinc-600">
{relativeTime(flow.updatedAt)}
</span>
<div className="flex gap-0.5">
<button
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-violet-500/10 hover:text-violet-400"
title="편집"
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-violet-500/10 hover:text-violet-400"
title="복사"
onClick={(e) => {
e.stopPropagation();
onCopy();
}}
>
<Copy className="h-3.5 w-3.5" />
</button>
<button
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-pink-500/10 hover:text-pink-400"
title="삭제"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
);
}
export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
const { user } = useAuth();
const [flows, setFlows] = useState<NodeFlow[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedFlow, setSelectedFlow] = useState<NodeFlow | null>(null);
@ -49,7 +257,6 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
try {
setLoading(true);
const response = await apiClient.get("/dataflow/node-flows");
if (response.data.success) {
setFlows(response.data.data);
} else {
@ -57,7 +264,9 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
}
} catch (error) {
console.error("플로우 목록 조회 실패", error);
showErrorToast("플로우 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
showErrorToast("플로우 목록을 불러오는 데 실패했어요", error, {
guidance: "네트워크 연결을 확인해 주세요.",
});
} finally {
setLoading(false);
}
@ -75,30 +284,26 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
const handleCopy = async (flow: NodeFlow) => {
try {
setLoading(true);
const response = await apiClient.get(`/dataflow/node-flows/${flow.flowId}`);
if (!response.data.success) {
throw new Error(response.data.message || "플로우 조회 실패");
}
const originalFlow = response.data.data;
if (!response.data.success) throw new Error(response.data.message || "플로우 조회 실패");
const copyResponse = await apiClient.post("/dataflow/node-flows", {
flowName: `${flow.flowName} (복사본)`,
flowDescription: flow.flowDescription,
flowData: originalFlow.flowData,
flowData: response.data.data.flowData,
});
if (copyResponse.data.success) {
toast.success(`플로우가 성공적으로 복사되었습니다`);
toast.success("플로우를 복사했어요");
await loadFlows();
} else {
throw new Error(copyResponse.data.message || "플로우 복사 실패");
}
} catch (error) {
console.error("플로우 복사 실패:", error);
showErrorToast("플로우 복사에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
showErrorToast("플로우 복사에 실패했어요", error, {
guidance: "잠시 후 다시 시도해 주세요.",
});
} finally {
setLoading(false);
}
@ -106,20 +311,20 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
const handleConfirmDelete = async () => {
if (!selectedFlow) return;
try {
setLoading(true);
const response = await apiClient.delete(`/dataflow/node-flows/${selectedFlow.flowId}`);
if (response.data.success) {
toast.success(`플로우가 삭제되었습니다: ${selectedFlow.flowName}`);
toast.success(`"${selectedFlow.flowName}" 플로우를 삭제했어요`);
await loadFlows();
} else {
throw new Error(response.data.message || "플로우 삭제 실패");
}
} catch (error) {
console.error("플로우 삭제 실패:", error);
showErrorToast("플로우 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
showErrorToast("플로우 삭제에 실패했어요", error, {
guidance: "잠시 후 다시 시도해 주세요.",
});
} finally {
setLoading(false);
setShowDeleteModal(false);
@ -127,170 +332,241 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
}
};
const filteredFlows = flows.filter(
(flow) =>
flow.flowName.toLowerCase().includes(searchTerm.toLowerCase()) ||
flow.flowDescription.toLowerCase().includes(searchTerm.toLowerCase()),
const filteredFlows = useMemo(
() =>
flows.filter(
(f) =>
f.flowName.toLowerCase().includes(searchTerm.toLowerCase()) ||
(f.flowDescription || "").toLowerCase().includes(searchTerm.toLowerCase()),
),
[flows, searchTerm],
);
// DropdownMenu 렌더러 (테이블 + 카드 공통)
const renderDropdownMenu = (flow: NodeFlow) => (
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onLoadFlow(flow.flowId)}>
<Network className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopy(flow)}>
<Copy className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(flow)} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
const stats = useMemo(() => {
let totalNodes = 0;
let totalEdges = 0;
flows.forEach((f) => {
totalNodes += f.summary?.nodeCount || 0;
totalEdges += f.summary?.edgeCount || 0;
});
return { total: flows.length, totalNodes, totalEdges };
}, [flows]);
const columns: RDVColumn<NodeFlow>[] = [
{
key: "flowName",
label: "플로우명",
render: (_val, flow) => (
<div className="flex items-center font-medium">
<Network className="mr-2 h-4 w-4 text-primary" />
{flow.flowName}
</div>
),
},
{
key: "flowDescription",
label: "설명",
render: (_val, flow) => (
<span className="text-muted-foreground">{flow.flowDescription || "설명 없음"}</span>
),
},
{
key: "createdAt",
label: "생성일",
render: (_val, flow) => (
<span className="flex items-center text-muted-foreground">
<Calendar className="mr-1 h-3 w-3" />
{new Date(flow.createdAt).toLocaleDateString()}
</span>
),
},
{
key: "updatedAt",
label: "최근 수정",
hideOnMobile: true,
render: (_val, flow) => (
<span className="flex items-center text-muted-foreground">
<Calendar className="mr-1 h-3 w-3" />
{new Date(flow.updatedAt).toLocaleDateString()}
</span>
),
},
];
const cardFields: RDVCardField<NodeFlow>[] = [
{
label: "생성일",
render: (flow) => new Date(flow.createdAt).toLocaleDateString(),
},
{
label: "최근 수정",
render: (flow) => new Date(flow.updatedAt).toLocaleDateString(),
},
];
if (loading && flows.length === 0) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
return (
<div className="space-y-4">
{/* 검색 및 액션 영역 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="w-full sm:w-[400px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="플로우명, 설명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
</div>
<div className="space-y-6">
{/* 헤더 */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="bg-gradient-to-r from-zinc-100 to-violet-300 bg-clip-text text-2xl font-bold tracking-tight text-transparent sm:text-3xl">
</h1>
<p className="mt-1 text-xs text-zinc-500 sm:text-sm">
</p>
</div>
<div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground">
<span className="font-semibold text-foreground">{filteredFlows.length}</span>
</div>
<Button onClick={() => onLoadFlow(null)} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={loadFlows}
disabled={loading}
className="gap-1.5 border-zinc-700 bg-zinc-900 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
</Button>
<Button
size="sm"
onClick={() => onLoadFlow(null)}
className="gap-1.5 bg-violet-600 font-semibold text-white shadow-lg shadow-violet-600/25 hover:bg-violet-500"
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 빈 상태: 커스텀 Empty UI */}
{!loading && filteredFlows.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Network className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold"> </h3>
<p className="max-w-sm text-sm text-muted-foreground">
.
</p>
<Button onClick={() => onLoadFlow(null)} className="mt-4 h-10 gap-2 text-sm font-medium">
{/* 통계 스트립 */}
<div className="flex flex-wrap items-center gap-5 border-b border-zinc-800/60 pb-4">
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
<div className="h-1.5 w-1.5 rounded-full bg-violet-500" />
{" "}
<strong className="font-mono font-bold text-zinc-200">{stats.total}</strong>
</div>
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
<div className="h-1.5 w-1.5 rounded-full bg-zinc-600" />
{" "}
<strong className="font-mono font-bold text-zinc-300">{stats.totalNodes}</strong>
</div>
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
<div className="h-1.5 w-1.5 rounded-full bg-zinc-600" />
{" "}
<strong className="font-mono font-bold text-zinc-300">{stats.totalEdges}</strong>
</div>
</div>
{/* 툴바 */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1 sm:max-w-[360px]">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-600" />
<Input
placeholder="플로우 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 border-zinc-700 bg-zinc-900 pl-10 text-sm text-zinc-200 placeholder:text-zinc-600 focus-visible:ring-violet-500/40"
/>
</div>
<div className="flex gap-0.5 rounded-lg border border-zinc-700 bg-zinc-900 p-0.5">
<button
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
viewMode === "grid"
? "bg-violet-500/10 text-violet-400"
: "text-zinc-500 hover:text-zinc-300"
}`}
onClick={() => setViewMode("grid")}
>
<LayoutGrid className="h-3.5 w-3.5" />
</button>
<button
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
viewMode === "list"
? "bg-violet-500/10 text-violet-400"
: "text-zinc-500 hover:text-zinc-300"
}`}
onClick={() => setViewMode("list")}
>
<List className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* 컨텐츠 */}
{filteredFlows.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-zinc-700 px-6 py-20 text-center">
<div className="mb-5 flex h-20 w-20 items-center justify-center rounded-2xl border border-violet-500/15 bg-violet-500/[0.08]">
<Network className="h-9 w-9 text-violet-400" />
</div>
<h2 className="mb-2 text-lg font-bold text-zinc-200">
{searchTerm ? "검색 결과가 없어요" : "아직 플로우가 없어요"}
</h2>
<p className="mb-6 max-w-sm text-sm leading-relaxed text-zinc-500">
{searchTerm
? `"${searchTerm}"에 해당하는 플로우를 찾지 못했어요. 다른 키워드로 검색해 보세요.`
: "노드를 연결해서 데이터 처리 파이프라인을 만들어 보세요. 코드 없이 드래그 앤 드롭만으로 설계할 수 있어요."}
</p>
{!searchTerm && (
<Button
onClick={() => onLoadFlow(null)}
className="gap-2 bg-violet-600 px-5 font-semibold text-white shadow-lg shadow-violet-600/25 hover:bg-violet-500"
>
<Plus className="h-4 w-4" />
</Button>
)}
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{filteredFlows.map((flow) => (
<FlowCard
key={flow.flowId}
flow={flow}
onOpen={() => onLoadFlow(flow.flowId)}
onCopy={() => handleCopy(flow)}
onDelete={() => handleDelete(flow)}
/>
))}
{/* 새 플로우 만들기 카드 */}
<div
className="group flex min-h-[260px] cursor-pointer flex-col items-center justify-center rounded-xl border border-dashed border-zinc-700 transition-all duration-200 hover:border-violet-500/50 hover:bg-violet-500/[0.04]"
onClick={() => onLoadFlow(null)}
>
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-violet-500/[0.08]">
<Plus className="h-6 w-6 text-violet-400" />
</div>
<span className="text-sm font-semibold text-zinc-400 group-hover:text-zinc-200">
</span>
<span className="mt-1 text-[11px] text-zinc-600"> </span>
</div>
</div>
) : (
<ResponsiveDataView<NodeFlow>
data={filteredFlows}
columns={columns}
keyExtractor={(flow) => String(flow.flowId)}
isLoading={loading}
skeletonCount={5}
cardTitle={(flow) => (
<span className="flex items-center">
<Network className="mr-2 h-4 w-4 text-primary" />
{flow.flowName}
</span>
)}
cardSubtitle={(flow) => flow.flowDescription || "설명 없음"}
cardHeaderRight={renderDropdownMenu}
cardFields={cardFields}
actionsLabel="작업"
actionsWidth="80px"
renderActions={renderDropdownMenu}
onRowClick={(flow) => onLoadFlow(flow.flowId)}
/>
<div className="space-y-2">
{filteredFlows.map((flow) => (
<div
key={flow.flowId}
className="group flex cursor-pointer items-center gap-4 rounded-lg border border-zinc-800 bg-zinc-900/80 px-4 py-3 transition-all hover:border-violet-500/40 hover:bg-zinc-900"
onClick={() => onLoadFlow(flow.flowId)}
>
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-violet-500/10">
<Network className="h-5 w-5 text-violet-400" />
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-semibold text-zinc-100">
{flow.flowName}
</h3>
<p className="truncate text-xs text-zinc-500">
{flow.flowDescription || "설명이 아직 없어요"}
</p>
</div>
<div className="hidden items-center gap-1.5 lg:flex">
{Object.entries(flow.summary?.nodeTypes || {})
.slice(0, 3)
.map(([type, count]) => {
const colors = getNodeCategoryColor(type);
return (
<span
key={type}
className={`inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${colors.text} ${colors.bg} ${colors.border}`}
>
{getNodeLabel(type)} {count}
</span>
);
})}
</div>
<span className="hidden font-mono text-[11px] text-zinc-600 sm:block">
{relativeTime(flow.updatedAt)}
</span>
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
<button
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-violet-500/10 hover:text-violet-400"
title="복사"
onClick={() => handleCopy(flow)}
>
<Copy className="h-3.5 w-3.5" />
</button>
<button
className="flex h-7 w-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-pink-500/10 hover:text-pink-400"
title="삭제"
onClick={() => handleDelete(flow)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
)}
{/* 삭제 확인 모달 */}
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogTitle className="text-base sm:text-lg"> ?</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
&ldquo;{selectedFlow?.flowName}&rdquo; ?
&ldquo;{selectedFlow?.flowName}&rdquo; .
<br />
<span className="font-medium text-destructive">
, .
<span className="text-destructive font-medium">
, .
</span>
</DialogDescription>
</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 ReactFlow, { Background, Controls, MiniMap, Panel, ReactFlowProvider, useReactFlow } from "reactflow";
import ReactFlow, {
Background,
Controls,
MiniMap,
Panel,
ReactFlowProvider,
useReactFlow,
} from "reactflow";
import "reactflow/dist/style.css";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { apiClient } from "@/lib/api/client";
import { NodePalette } from "./sidebar/NodePalette";
import { LeftV2Toolbar, ToolbarButton } from "@/components/screen/toolbar/LeftV2Toolbar";
import { Boxes, Settings } from "lucide-react";
import { PropertiesPanel } from "./panels/PropertiesPanel";
import { CommandPalette } from "./CommandPalette";
import { SlideOverSheet } from "./SlideOverSheet";
import { FlowBreadcrumb } from "./FlowBreadcrumb";
import { NodeContextMenu } from "./NodeContextMenu";
import { ValidationNotification } from "./ValidationNotification";
import { FlowToolbar } from "./FlowToolbar";
import { getNodePaletteItem } from "./sidebar/nodePaletteConfig";
import { Pencil, Copy, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { TableSourceNode } from "./nodes/TableSourceNode";
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
import { ConditionNode } from "./nodes/ConditionNode";
@ -36,70 +48,116 @@ import { ProcedureCallActionNode } from "./nodes/ProcedureCallActionNode";
import { validateFlow } from "@/lib/utils/flowValidation";
import type { FlowValidation } from "@/lib/utils/flowValidation";
// 노드 타입들
const nodeTypes = {
// 데이터 소스
tableSource: TableSourceNode,
externalDBSource: ExternalDBSourceNode,
restAPISource: RestAPISourceNode,
// 변환/조건
condition: ConditionNode,
dataTransform: DataTransformNode,
aggregate: AggregateNode,
formulaTransform: FormulaTransformNode,
// 데이터 액션
insertAction: InsertActionNode,
updateAction: UpdateActionNode,
deleteAction: DeleteActionNode,
upsertAction: UpsertActionNode,
// 외부 연동 액션
emailAction: EmailActionNode,
scriptAction: ScriptActionNode,
httpRequestAction: HttpRequestActionNode,
procedureCallAction: ProcedureCallActionNode,
// 유틸리티
comment: CommentNode,
log: LogNode,
};
/**
* FlowEditor
*/
interface FlowEditorInnerProps {
initialFlowId?: number | null;
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
onSaveComplete?: (flowId: number, flowName: string) => void;
/** 임베디드 모드 여부 */
embedded?: boolean;
}
// 플로우 에디터 툴바 버튼 설정
const flowToolbarButtons: ToolbarButton[] = [
{
id: "nodes",
label: "노드",
icon: <Boxes className="h-5 w-5" />,
shortcut: "N",
group: "source",
panelWidth: 300,
},
{
id: "properties",
label: "속성",
icon: <Settings className="h-5 w-5" />,
shortcut: "P",
group: "editor",
panelWidth: 350,
},
];
function getDefaultNodeData(type: string): Record<string, any> {
const paletteItem = getNodePaletteItem(type);
const base: Record<string, any> = {
displayName: paletteItem?.label || `${type} 노드`,
};
function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorInnerProps) {
if (type === "restAPISource") {
Object.assign(base, {
method: "GET",
url: "",
headers: {},
timeout: 30000,
responseFields: [],
responseMapping: "",
});
}
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
Object.assign(base, {
targetType: "internal",
fieldMappings: [],
options: {},
});
if (type === "updateAction" || type === "deleteAction") {
base.whereConditions = [];
}
if (type === "upsertAction") {
base.conflictKeys = [];
}
}
if (type === "emailAction") {
Object.assign(base, {
displayName: "메일 발송",
smtpConfig: { host: "", port: 587, secure: false },
from: "",
to: "",
subject: "",
body: "",
bodyType: "text",
});
}
if (type === "scriptAction") {
Object.assign(base, {
displayName: "스크립트 실행",
scriptType: "python",
executionMode: "inline",
inlineScript: "",
inputMethod: "stdin",
inputFormat: "json",
outputHandling: { captureStdout: true, captureStderr: true, parseOutput: "text" },
});
}
if (type === "httpRequestAction") {
Object.assign(base, {
displayName: "HTTP 요청",
url: "",
method: "GET",
bodyType: "none",
authentication: { type: "none" },
options: { timeout: 30000, followRedirects: true },
});
}
return base;
}
function FlowEditorInner({
initialFlowId,
onSaveComplete,
embedded = false,
}: FlowEditorInnerProps) {
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const { screenToFlowPosition, setCenter } = useReactFlow();
const { screenToFlowPosition, setCenter, getViewport } = useReactFlow();
// 패널 표시 상태
const [showNodesPanel, setShowNodesPanel] = useState(true);
const [showPropertiesPanelLocal, setShowPropertiesPanelLocal] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [slideOverOpen, setSlideOverOpen] = useState(false);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
nodeId: string;
} | null>(null);
const {
nodes,
@ -117,12 +175,11 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
loadFlow,
} = useFlowEditorStore();
// 🆕 실시간 플로우 검증
const validations = useMemo<FlowValidation[]>(() => {
return validateFlow(nodes, edges);
}, [nodes, edges]);
const validations = useMemo<FlowValidation[]>(
() => validateFlow(nodes, edges),
[nodes, edges],
);
// 🆕 노드 클릭 핸들러 (검증 패널에서 사용)
const handleValidationNodeClick = useCallback(
(nodeId: string) => {
const node = nodes.find((n) => n.id === nodeId);
@ -137,23 +194,27 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
[nodes, selectNodes, setCenter],
);
// 속성 패널 상태 동기화
// 노드 선택 시 속성 패널 열기
useEffect(() => {
if (selectedNodes.length > 0 && !showPropertiesPanelLocal) {
setShowPropertiesPanelLocal(true);
if (selectedNodes.length > 0) {
setSlideOverOpen(true);
}
}, [selectedNodes, showPropertiesPanelLocal]);
}, [selectedNodes]);
// 초기 플로우 로드
// 플로우 로드
useEffect(() => {
const fetchAndLoadFlow = async () => {
if (initialFlowId) {
try {
const response = await apiClient.get(`/dataflow/node-flows/${initialFlowId}`);
const response = await apiClient.get(
`/dataflow/node-flows/${initialFlowId}`,
);
if (response.data.success && response.data.data) {
const flow = response.data.data;
const flowData = typeof flow.flowData === "string" ? JSON.parse(flow.flowData) : flow.flowData;
const flowData =
typeof flow.flowData === "string"
? JSON.parse(flow.flowData)
: flow.flowData;
loadFlow(
flow.flowId,
@ -162,73 +223,174 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
flowData.nodes || [],
flowData.edges || [],
);
// 🆕 플로우 로드 후 첫 번째 노드 자동 선택
if (flowData.nodes && flowData.nodes.length > 0) {
const firstNode = flowData.nodes[0];
selectNodes([firstNode.id]);
setShowPropertiesPanelLocal(true);
console.log("✅ 첫 번째 노드 자동 선택:", firstNode.id);
}
}
} catch (error) {
console.error("플로우 로드 실패:", error);
}
}
};
fetchAndLoadFlow();
}, [initialFlowId, loadFlow, selectNodes]);
}, [initialFlowId, loadFlow]);
/**
*
*/
const onSelectionChange = useCallback(
({ nodes: selectedNodes }: { nodes: any[] }) => {
const selectedIds = selectedNodes.map((node) => node.id);
({ nodes: selected }: { nodes: any[] }) => {
const selectedIds = selected.map((n) => n.id);
selectNodes(selectedIds);
console.log("🔍 선택된 노드:", selectedIds);
},
[selectNodes],
);
/**
* (Delete/Backspace , Ctrl+Z/Y로 Undo/Redo)
*/
// 더블클릭으로 속성 패널 열기
const onNodeDoubleClick = useCallback(
(_event: React.MouseEvent, node: any) => {
selectNodes([node.id]);
setSlideOverOpen(true);
},
[selectNodes],
);
// 우클릭 컨텍스트 메뉴
const onNodeContextMenu = useCallback(
(event: React.MouseEvent, node: any) => {
event.preventDefault();
selectNodes([node.id]);
setContextMenu({
x: event.clientX,
y: event.clientY,
nodeId: node.id,
});
},
[selectNodes],
);
// 캔버스 우클릭 → 커맨드 팔레트
const onPaneContextMenu = useCallback(
(event: React.MouseEvent | MouseEvent) => {
event.preventDefault();
setCommandPaletteOpen(true);
},
[],
);
// 컨텍스트 메뉴 아이템 생성
const getContextMenuItems = useCallback(
(nodeId: string) => {
const node = nodes.find((n) => n.id === nodeId);
const nodeName = (node?.data as any)?.displayName || "노드";
return [
{
label: "속성 편집",
icon: <Pencil className="h-3.5 w-3.5" />,
onClick: () => {
selectNodes([nodeId]);
setSlideOverOpen(true);
},
},
{
label: "복제",
icon: <Copy className="h-3.5 w-3.5" />,
onClick: () => {
if (!node) return;
const newNode: any = {
id: `node_${Date.now()}`,
type: node.type,
position: {
x: node.position.x + 40,
y: node.position.y + 40,
},
data: { ...(node.data as any) },
};
addNode(newNode);
selectNodes([newNode.id]);
toast.success(`"${nodeName}" 노드를 복제했어요`);
},
},
{
label: "삭제",
icon: <Trash2 className="h-3.5 w-3.5" />,
onClick: () => {
removeNodes([nodeId]);
toast.success(`"${nodeName}" 노드를 삭제했어요`);
},
danger: true,
},
];
},
[nodes, selectNodes, addNode, removeNodes],
);
// "/" 키로 커맨드 팔레트 열기, Esc로 속성 패널 닫기 등
const onKeyDown = useCallback(
(event: React.KeyboardEvent) => {
// Undo: Ctrl+Z (Windows/Linux) or Cmd+Z (Mac)
const target = event.target as HTMLElement;
const isInput =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable;
if (!isInput && event.key === "/" && !event.ctrlKey && !event.metaKey) {
event.preventDefault();
setCommandPaletteOpen(true);
return;
}
if ((event.ctrlKey || event.metaKey) && event.key === "z" && !event.shiftKey) {
event.preventDefault();
console.log("⏪ Undo");
undo();
return;
}
// Redo: Ctrl+Y (Windows/Linux) or Cmd+Shift+Z (Mac) or Ctrl+Shift+Z
if (
((event.ctrlKey || event.metaKey) && event.key === "y") ||
((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "z")
) {
event.preventDefault();
console.log("⏩ Redo");
redo();
return;
}
// Delete: Delete/Backspace 키로 노드 삭제
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodes.length > 0) {
if (
(event.key === "Delete" || event.key === "Backspace") &&
selectedNodes.length > 0 &&
!isInput
) {
event.preventDefault();
console.log("🗑️ 선택된 노드 삭제:", selectedNodes);
removeNodes(selectedNodes);
}
},
[selectedNodes, removeNodes, undo, redo],
);
/**
*
*/
// 커맨드 팔레트에서 노드 선택 시 뷰포트 중앙에 배치
const handleCommandSelect = useCallback(
(nodeType: string) => {
const viewport = getViewport();
const wrapper = reactFlowWrapper.current;
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const position = screenToFlowPosition({
x: rect.left + centerX,
y: rect.top + centerY,
});
const newNode: any = {
id: `node_${Date.now()}`,
type: nodeType,
position,
data: getDefaultNodeData(nodeType),
};
addNode(newNode);
selectNodes([newNode.id]);
},
[screenToFlowPosition, addNode, selectNodes, getViewport],
);
// 기존 드래그 앤 드롭 (하위 호환)
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
@ -237,7 +399,6 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
const onDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
const type = event.dataTransfer.getData("application/reactflow");
if (!type) return;
@ -246,84 +407,11 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
y: event.clientY,
});
// 🔥 노드 타입별 기본 데이터 설정
const defaultData: any = {
displayName: `${type} 노드`,
};
// REST API 소스 노드의 경우
if (type === "restAPISource") {
defaultData.method = "GET";
defaultData.url = "";
defaultData.headers = {};
defaultData.timeout = 30000;
defaultData.responseFields = []; // 빈 배열로 초기화
defaultData.responseMapping = "";
}
// 데이터 액션 노드의 경우 targetType 기본값 설정
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
defaultData.targetType = "internal"; // 기본값: 내부 DB
defaultData.fieldMappings = [];
defaultData.options = {};
if (type === "updateAction" || type === "deleteAction") {
defaultData.whereConditions = [];
}
if (type === "upsertAction") {
defaultData.conflictKeys = [];
}
}
// 메일 발송 노드
if (type === "emailAction") {
defaultData.displayName = "메일 발송";
defaultData.smtpConfig = {
host: "",
port: 587,
secure: false,
};
defaultData.from = "";
defaultData.to = "";
defaultData.subject = "";
defaultData.body = "";
defaultData.bodyType = "text";
}
// 스크립트 실행 노드
if (type === "scriptAction") {
defaultData.displayName = "스크립트 실행";
defaultData.scriptType = "python";
defaultData.executionMode = "inline";
defaultData.inlineScript = "";
defaultData.inputMethod = "stdin";
defaultData.inputFormat = "json";
defaultData.outputHandling = {
captureStdout: true,
captureStderr: true,
parseOutput: "text",
};
}
// HTTP 요청 노드
if (type === "httpRequestAction") {
defaultData.displayName = "HTTP 요청";
defaultData.url = "";
defaultData.method = "GET";
defaultData.bodyType = "none";
defaultData.authentication = { type: "none" };
defaultData.options = {
timeout: 30000,
followRedirects: true,
};
}
const newNode: any = {
id: `node_${Date.now()}`,
type,
position,
data: defaultData,
data: getDefaultNodeData(type),
};
addNode(newNode);
@ -332,32 +420,17 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
);
return (
<div className="flex h-full w-full" style={{ height: "100%", overflow: "hidden" }}>
{/* 좌측 통합 툴바 */}
<LeftV2Toolbar
buttons={flowToolbarButtons}
panelStates={{
nodes: { isOpen: showNodesPanel },
properties: { isOpen: showPropertiesPanelLocal },
}}
onTogglePanel={(panelId) => {
if (panelId === "nodes") {
setShowNodesPanel(!showNodesPanel);
} else if (panelId === "properties") {
setShowPropertiesPanelLocal(!showPropertiesPanelLocal);
}
}}
/>
{/* 노드 라이브러리 패널 */}
{showNodesPanel && (
<div className="h-full w-[300px] border-r bg-white">
<NodePalette />
</div>
)}
{/* 중앙 캔버스 */}
<div className="relative flex-1" ref={reactFlowWrapper} onKeyDown={onKeyDown} tabIndex={0}>
<div
className="relative flex h-full w-full"
style={{ height: "100%", overflow: "hidden" }}
>
{/* 100% 캔버스 */}
<div
className="relative flex-1"
ref={reactFlowWrapper}
onKeyDown={onKeyDown}
tabIndex={0}
>
<ReactFlow
nodes={nodes as any}
edges={edges as any}
@ -366,74 +439,116 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
onConnect={onConnect}
onNodeDragStart={onNodeDragStart}
onSelectionChange={onSelectionChange}
onNodeDoubleClick={onNodeDoubleClick}
onNodeContextMenu={onNodeContextMenu}
onPaneContextMenu={onPaneContextMenu}
onPaneClick={() => setContextMenu(null)}
onDragOver={onDragOver}
onDrop={onDrop}
nodeTypes={nodeTypes}
fitView
className="bg-muted"
className="bg-zinc-950"
deleteKeyCode={["Delete", "Backspace"]}
>
{/* 배경 그리드 */}
<Background gap={16} size={1} color="#E5E7EB" />
<Background gap={20} size={1} color="#27272a" />
{/* 컨트롤 버튼 */}
<Controls className="bg-white shadow-md" />
{/* 미니맵 */}
<MiniMap
className="bg-white shadow-md"
nodeColor={(node) => {
// 노드 타입별 색상 (추후 구현)
return "#3B82F6";
}}
maskColor="rgba(0, 0, 0, 0.1)"
<Controls
className="!rounded-lg !border-zinc-700 !bg-zinc-900 !shadow-lg [&>button]:!border-zinc-700 [&>button]:!bg-zinc-900 [&>button]:!text-zinc-400 [&>button:hover]:!bg-zinc-800 [&>button:hover]:!text-zinc-200"
showInteractive={false}
/>
{/* 상단 툴바 */}
<MiniMap
className="!rounded-lg !border-zinc-700 !bg-zinc-900 !shadow-lg"
nodeColor={(node) => {
const item = getNodePaletteItem(node.type || "");
return item?.color || "#6B7280";
}}
maskColor="rgba(0, 0, 0, 0.6)"
/>
{/* Breadcrumb (좌상단) */}
<Panel position="top-left" className="pointer-events-auto">
<div className="rounded-lg border border-zinc-700/60 bg-zinc-900/90 px-3 py-2 backdrop-blur-sm">
<FlowBreadcrumb />
</div>
</Panel>
{/* 플로팅 툴바 (상단 중앙) */}
<Panel position="top-center" className="pointer-events-auto">
<FlowToolbar validations={validations} onSaveComplete={onSaveComplete} />
<FlowToolbar
validations={validations}
onSaveComplete={onSaveComplete}
onOpenCommandPalette={() => setCommandPaletteOpen(true)}
/>
</Panel>
</ReactFlow>
</div>
{/* 우측 속성 패널 */}
{showPropertiesPanelLocal && selectedNodes.length > 0 && (
<div
style={{
height: "100%",
width: "350px",
display: "flex",
flexDirection: "column",
}}
className="border-l bg-white"
>
<PropertiesPanel />
</div>
{/* Slide-over 속성 패널 */}
<SlideOverSheet
isOpen={slideOverOpen && selectedNodes.length > 0}
onClose={() => setSlideOverOpen(false)}
/>
{/* Command Palette */}
<CommandPalette
isOpen={commandPaletteOpen}
onClose={() => setCommandPaletteOpen(false)}
onSelectNode={handleCommandSelect}
/>
{/* 노드 우클릭 컨텍스트 메뉴 */}
{contextMenu && (
<NodeContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={getContextMenuItems(contextMenu.nodeId)}
onClose={() => setContextMenu(null)}
/>
)}
{/* 검증 알림 (우측 상단 플로팅) */}
<ValidationNotification validations={validations} onNodeClick={handleValidationNodeClick} />
{/* 검증 알림 */}
<ValidationNotification
validations={validations}
onNodeClick={handleValidationNodeClick}
/>
{/* 빈 캔버스 힌트 */}
{nodes.length === 0 && !commandPaletteOpen && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center">
<p className="mb-2 text-sm text-zinc-500">
</p>
<p className="text-xs text-zinc-600">
<kbd className="rounded border border-zinc-700 bg-zinc-800 px-1.5 py-0.5 font-mono text-[11px]">
/
</kbd>{" "}
</p>
</div>
</div>
)}
</div>
);
}
/**
* FlowEditor (Provider로 )
*/
interface FlowEditorProps {
initialFlowId?: number | null;
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
onSaveComplete?: (flowId: number, flowName: string) => void;
/** 임베디드 모드 여부 (헤더 표시 여부 등) */
embedded?: boolean;
}
export function FlowEditor({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorProps = {}) {
export function FlowEditor({
initialFlowId,
onSaveComplete,
embedded = false,
}: FlowEditorProps = {}) {
return (
<div className="h-full w-full">
<ReactFlowProvider>
<FlowEditorInner
initialFlowId={initialFlowId}
<FlowEditorInner
initialFlowId={initialFlowId}
onSaveComplete={onSaveComplete}
embedded={embedded}
/>

View File

@ -1,12 +1,17 @@
"use client";
/**
*
*/
import { useState, useEffect, useRef } from "react";
import { Save, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Save,
Undo2,
Redo2,
ZoomIn,
ZoomOut,
Maximize2,
Download,
Trash2,
Plus,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { useReactFlow } from "reactflow";
@ -17,11 +22,15 @@ import { useToast } from "@/hooks/use-toast";
interface FlowToolbarProps {
validations?: FlowValidation[];
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
onSaveComplete?: (flowId: number, flowName: string) => void;
onOpenCommandPalette?: () => void;
}
export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarProps) {
export function FlowToolbar({
validations = [],
onSaveComplete,
onOpenCommandPalette,
}: FlowToolbarProps) {
const { toast } = useToast();
const { zoomIn, zoomOut, fitView } = useReactFlow();
const {
@ -42,9 +51,7 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
const [showSaveDialog, setShowSaveDialog] = useState(false);
// Ctrl+S 단축키: 플로우 저장
const handleSaveRef = useRef<() => void>();
useEffect(() => {
handleSaveRef.current = handleSave;
});
@ -53,28 +60,20 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (!isSaving) {
handleSaveRef.current?.();
}
if (!isSaving) handleSaveRef.current?.();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isSaving]);
const handleSave = async () => {
// 검증 수행
const currentValidations = validations.length > 0 ? validations : validateFlow(nodes, edges);
const summary = summarizeValidations(currentValidations);
// 오류나 경고가 있으면 다이얼로그 표시
const currentValidations =
validations.length > 0 ? validations : validateFlow(nodes, edges);
if (currentValidations.length > 0) {
setShowSaveDialog(true);
return;
}
// 문제 없으면 바로 저장
await performSave();
};
@ -82,27 +81,22 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
const result = await saveFlow();
if (result.success) {
toast({
title: "저장 완료",
description: `${result.message}\nFlow ID: ${result.flowId}`,
title: "저장했어요",
description: `플로우가 안전하게 저장됐어요`,
variant: "default",
});
// 임베디드 모드에서 저장 완료 콜백 호출
if (onSaveComplete && result.flowId) {
onSaveComplete(result.flowId, flowName);
}
// 부모 창이 있으면 postMessage로 알림 (새 창에서 열린 경우)
if (window.opener && result.flowId) {
window.opener.postMessage({
type: "FLOW_SAVED",
flowId: result.flowId,
flowName: flowName,
}, "*");
window.opener.postMessage(
{ type: "FLOW_SAVED", flowId: result.flowId, flowName },
"*",
);
}
} else {
toast({
title: "저장 실패",
title: "저장 실패했어요",
description: result.message,
variant: "destructive",
});
@ -120,102 +114,128 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
a.click();
URL.revokeObjectURL(url);
toast({
title: "내보내기 완료",
description: "JSON 파일로 저장되었습니다.",
title: "내보내기 완료",
description: "JSON 파일로 저장했어요",
variant: "default",
});
};
const handleDelete = () => {
if (selectedNodes.length === 0) {
toast({
title: "⚠️ 선택된 노드 없음",
description: "삭제할 노드를 선택해주세요.",
variant: "default",
});
return;
}
if (confirm(`선택된 ${selectedNodes.length}개 노드를 삭제하시겠습니까?`)) {
removeNodes(selectedNodes);
toast({
title: "✅ 노드 삭제 완료",
description: `${selectedNodes.length}개 노드가 삭제되었습니다.`,
variant: "default",
});
}
if (selectedNodes.length === 0) return;
removeNodes(selectedNodes);
toast({
title: "노드를 삭제했어요",
description: `${selectedNodes.length}개 노드가 삭제됐어요`,
variant: "default",
});
};
const ToolBtn = ({
onClick,
disabled,
title,
danger,
children,
}: {
onClick: () => void;
disabled?: boolean;
title: string;
danger?: boolean;
children: React.ReactNode;
}) => (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors disabled:opacity-30 ${
danger
? "text-pink-400 hover:bg-pink-500/15"
: "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
}`}
>
{children}
</button>
);
return (
<>
<div className="flex items-center gap-2 rounded-lg border bg-background p-2 shadow-md">
<div className="flex items-center gap-1 rounded-xl border border-zinc-700 bg-zinc-900/95 px-2 py-1.5 shadow-lg shadow-black/30 backdrop-blur-sm">
{/* 노드 추가 */}
{onOpenCommandPalette && (
<>
<button
onClick={onOpenCommandPalette}
title="노드 추가 (/)"
className="flex h-8 items-center gap-1.5 rounded-lg bg-violet-600/20 px-2.5 text-violet-400 transition-colors hover:bg-violet-600/30"
>
<Plus className="h-3.5 w-3.5" />
<span className="text-xs font-medium"></span>
</button>
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
</>
)}
{/* 플로우 이름 */}
<Input
value={flowName}
onChange={(e) => setFlowName(e.target.value)}
onKeyDown={(e) => {
// 입력 필드에서 키 이벤트가 FlowEditor로 전파되지 않도록 방지
// FlowEditor의 Backspace/Delete 키로 노드가 삭제되는 것을 막음
e.stopPropagation();
}}
className="h-8 w-[200px] text-sm"
placeholder="플로우 이름"
onKeyDown={(e) => e.stopPropagation()}
className="h-7 w-[160px] border-none bg-transparent px-2 text-xs font-medium text-zinc-200 placeholder:text-zinc-600 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="플로우 이름을 입력해요"
/>
<div className="h-6 w-px bg-border" />
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
{/* 실행 취소/다시 실행 */}
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
<Undo2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
<Redo2 className="h-4 w-4" />
</Button>
{/* Undo / Redo */}
<ToolBtn onClick={undo} disabled={!canUndo()} title="실행 취소 (Ctrl+Z)">
<Undo2 className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn onClick={redo} disabled={!canRedo()} title="다시 실행 (Ctrl+Y)">
<Redo2 className="h-3.5 w-3.5" />
</ToolBtn>
<div className="h-6 w-px bg-border" />
{/* 삭제 */}
{selectedNodes.length > 0 && (
<>
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
<ToolBtn onClick={handleDelete} title={`${selectedNodes.length}개 삭제`} danger>
<Trash2 className="h-3.5 w-3.5" />
</ToolBtn>
</>
)}
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
disabled={selectedNodes.length === 0}
title={selectedNodes.length > 0 ? `${selectedNodes.length}개 노드 삭제` : "삭제할 노드를 선택하세요"}
className="gap-1 text-destructive hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
{selectedNodes.length > 0 && <span className="text-xs">({selectedNodes.length})</span>}
</Button>
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
<div className="h-6 w-px bg-border" />
{/* 줌 */}
<ToolBtn onClick={() => zoomIn()} title="확대">
<ZoomIn className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn onClick={() => zoomOut()} title="축소">
<ZoomOut className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn onClick={() => fitView()} title="전체 보기">
<Maximize2 className="h-3.5 w-3.5" />
</ToolBtn>
{/* 줌 컨트롤 */}
<Button variant="ghost" size="sm" onClick={() => zoomIn()} title="확대">
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => zoomOut()} title="축소">
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => fitView()} title="전체 보기">
<span className="text-xs"></span>
</Button>
<div className="h-6 w-px bg-border" />
<div className="mx-0.5 h-5 w-px bg-zinc-700" />
{/* 저장 */}
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-1">
<Save className="h-4 w-4" />
<span className="text-xs">{isSaving ? "저장 중..." : "저장"}</span>
</Button>
<button
onClick={handleSave}
disabled={isSaving}
title="저장 (Ctrl+S)"
className="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-zinc-300 transition-colors hover:bg-zinc-700 hover:text-zinc-100 disabled:opacity-40"
>
<Save className="h-3.5 w-3.5" />
<span className="text-xs font-medium">{isSaving ? "저장 중..." : "저장"}</span>
</button>
{/* 내보내기 */}
<Button variant="outline" size="sm" onClick={handleExport} className="gap-1">
<Download className="h-4 w-4" />
<span className="text-xs">JSON</span>
</Button>
{/* JSON 내보내기 */}
<ToolBtn onClick={handleExport} title="JSON 내보내기">
<Download className="h-3.5 w-3.5" />
</ToolBtn>
</div>
{/* 저장 확인 다이얼로그 */}
<SaveConfirmDialog
open={showSaveDialog}
validations={validations.length > 0 ? validations : validateFlow(nodes, edges)}

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";
/**
* (Aggregate Node)
* SUM, COUNT, AVG, MIN, MAX
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Calculator, Layers } from "lucide-react";
import type { AggregateNodeData, AggregateFunction } from "@/types/node-editor";
// 집계 함수별 아이콘/라벨
const AGGREGATE_FUNCTION_LABELS: Record<AggregateFunction, string> = {
SUM: "합계",
COUNT: "개수",
AVG: "평균",
MIN: "최소",
MAX: "최대",
FIRST: "첫번째",
LAST: "마지막",
};
import { NodeProps } from "reactflow";
import { BarChart3 } from "lucide-react";
import { CompactNodeShell } from "./CompactNodeShell";
import type { AggregateNodeData } from "@/types/node-editor";
export const AggregateNode = memo(({ data, selected }: NodeProps<AggregateNodeData>) => {
const groupByCount = data.groupByFields?.length || 0;
const aggregationCount = data.aggregations?.length || 0;
const opCount = data.operations?.length || 0;
const groupCount = data.groupByFields?.length || 0;
const summary = opCount > 0
? `${opCount}개 연산${groupCount > 0 ? `, ${groupCount}개 그룹` : ""}`
: "집계 연산을 설정해 주세요";
return (
<div
className={`min-w-[280px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-purple-500 shadow-lg" : "border-border"
}`}
<CompactNodeShell
color="#A855F7"
label={data.displayName || "집계"}
summary={summary}
icon={<BarChart3 className="h-3.5 w-3.5" />}
selected={selected}
>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-purple-600 px-3 py-2 text-white">
<Calculator className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">{data.displayName || "집계"}</div>
<div className="text-xs opacity-80">
{groupByCount > 0 ? `${groupByCount}개 그룹` : "전체"} / {aggregationCount}
</div>
{opCount > 0 && (
<div className="space-y-0.5">
{data.operations!.slice(0, 3).map((op: any, i: number) => (
<div key={i} className="flex items-center gap-1.5">
<span className="rounded bg-violet-500/20 px-1 py-0.5 font-mono text-[9px] font-semibold text-violet-400">
{op.function || op.operation}
</span>
<span>{op.field || op.sourceField}</span>
</div>
))}
</div>
</div>
{/* 본문 */}
<div className="p-3 space-y-3">
{/* 그룹 기준 */}
{groupByCount > 0 && (
<div className="rounded bg-purple-50 p-2">
<div className="flex items-center gap-1 mb-1">
<Layers className="h-3 w-3 text-purple-600" />
<span className="text-xs font-medium text-purple-700"> </span>
</div>
<div className="flex flex-wrap gap-1">
{data.groupByFields.slice(0, 3).map((field, idx) => (
<span
key={idx}
className="inline-flex items-center rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-700"
>
{field.fieldLabel || field.field}
</span>
))}
{data.groupByFields.length > 3 && (
<span className="text-xs text-purple-500">+{data.groupByFields.length - 3}</span>
)}
</div>
</div>
)}
{/* 집계 연산 */}
{aggregationCount > 0 ? (
<div className="space-y-2">
{data.aggregations.slice(0, 4).map((agg, idx) => (
<div key={agg.id || idx} className="rounded bg-muted p-2">
<div className="flex items-center justify-between">
<span className="rounded bg-purple-600 px-1.5 py-0.5 text-xs font-medium text-white">
{AGGREGATE_FUNCTION_LABELS[agg.function] || agg.function}
</span>
<span className="text-xs text-muted-foreground">
{agg.outputFieldLabel || agg.outputField}
</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{agg.sourceFieldLabel || agg.sourceField}
</div>
</div>
))}
{data.aggregations.length > 4 && (
<div className="text-xs text-muted-foreground/70 text-center">
... {data.aggregations.length - 4}
</div>
)}
</div>
) : (
<div className="py-4 text-center text-xs text-muted-foreground/70"> </div>
)}
</div>
{/* 핸들 */}
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-purple-500" />
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-purple-500" />
</div>
)}
</CompactNodeShell>
);
});
AggregateNode.displayName = "AggregateNode";

View File

@ -1,29 +1,21 @@
"use client";
/**
* -
*/
import { memo } from "react";
import { NodeProps } from "reactflow";
import { MessageSquare } from "lucide-react";
import type { CommentNodeData } from "@/types/node-editor";
import { CompactNodeShell } from "./CompactNodeShell";
export const CommentNode = memo(({ data, selected }: NodeProps<CommentNodeData>) => {
export const CommentNode = memo(({ data, selected }: NodeProps<any>) => {
return (
<div
className={`max-w-[350px] min-w-[200px] rounded-lg border-2 border-dashed bg-amber-50 shadow-sm transition-all ${
selected ? "border-yellow-500 shadow-md" : "border-amber-300"
}`}
>
<div className="p-3">
<div className="mb-2 flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-amber-600" />
<span className="text-xs font-semibold text-yellow-800"></span>
</div>
<div className="text-sm whitespace-pre-wrap text-foreground">{data.content || "메모를 입력하세요..."}</div>
</div>
</div>
<CompactNodeShell
color="#6B7280"
label="메모"
summary={data.comment || data.text || "메모를 작성해 주세요"}
icon={<MessageSquare className="h-3.5 w-3.5" />}
selected={selected}
hasInput={false}
hasOutput={false}
/>
);
});

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";
/**
*
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Zap, Check, X } from "lucide-react";
import { GitBranch } from "lucide-react";
import { CompactNodeShell } from "./CompactNodeShell";
import type { ConditionNodeData } from "@/types/node-editor";
const OPERATOR_LABELS: Record<string, string> = {
EQUALS: "=",
NOT_EQUALS: "≠",
GREATER_THAN: ">",
LESS_THAN: "<",
GREATER_THAN_OR_EQUAL: "≥",
LESS_THAN_OR_EQUAL: "≤",
LIKE: "포함",
NOT_LIKE: "미포함",
IN: "IN",
NOT_IN: "NOT IN",
IS_NULL: "NULL",
IS_NOT_NULL: "NOT NULL",
EXISTS_IN: "EXISTS IN",
NOT_EXISTS_IN: "NOT EXISTS IN",
};
// EXISTS 계열 연산자인지 확인
const isExistsOperator = (operator: string): boolean => {
return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN";
EQUALS: "=", NOT_EQUALS: "!=",
GREATER_THAN: ">", LESS_THAN: "<",
GREATER_THAN_OR_EQUAL: ">=", LESS_THAN_OR_EQUAL: "<=",
LIKE: "포함", NOT_LIKE: "미포함",
IN: "IN", NOT_IN: "NOT IN",
IS_NULL: "NULL", IS_NOT_NULL: "NOT NULL",
EXISTS_IN: "EXISTS", NOT_EXISTS_IN: "NOT EXISTS",
};
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeData>) => {
const condCount = data.conditions?.length || 0;
const summary = condCount > 0
? `${condCount}개 조건 (${data.logic || "AND"})`
: "조건을 설정해 주세요";
return (
<div
className={`min-w-[280px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-yellow-500 shadow-lg" : "border-border"
className={`rounded-lg border bg-zinc-900 shadow-lg transition-all ${
selected ? "border-violet-500 shadow-violet-500/20" : "border-zinc-700"
}`}
style={{ minWidth: "260px", maxWidth: "320px" }}
>
{/* 입력 핸들 */}
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-yellow-500 !bg-white" />
<Handle
type="target"
position={Position.Left}
className="!h-2.5 !w-2.5 !border-2 !border-amber-500 !bg-zinc-900"
/>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-amber-500 px-3 py-2 text-white">
<Zap className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold"> </div>
<div className="text-xs opacity-80">{data.displayName || "조건 분기"}</div>
<div className="flex items-center gap-2.5 px-3 py-2.5">
<div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-amber-500/20">
<GitBranch className="h-3.5 w-3.5 text-amber-400" />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-semibold text-zinc-200">
{data.displayName || "조건 분기"}
</div>
<div className="line-clamp-2 text-[10px] leading-relaxed text-zinc-500">{summary}</div>
</div>
</div>
{/* 본문 */}
<div className="p-3">
{data.conditions && data.conditions.length > 0 ? (
<div className="space-y-2">
<div className="text-xs font-medium text-foreground">: ({data.conditions.length})</div>
<div className="max-h-[150px] space-y-1.5 overflow-y-auto">
{data.conditions.slice(0, 4).map((condition, idx) => (
<div key={idx} className="rounded bg-amber-50 px-2 py-1.5 text-xs">
{idx > 0 && (
<div className="mb-1 text-center text-xs font-semibold text-amber-600">{data.logic}</div>
)}
<div className="flex flex-wrap items-center gap-1">
<span className="font-mono text-foreground">{condition.field}</span>
<span
className={`rounded px-1 py-0.5 ${
isExistsOperator(condition.operator)
? "bg-purple-200 text-purple-800"
: "bg-yellow-200 text-yellow-800"
}`}
>
{OPERATOR_LABELS[condition.operator] || condition.operator}
</span>
{/* EXISTS 연산자인 경우 테이블.필드 표시 */}
{isExistsOperator(condition.operator) ? (
<span className="text-purple-600">
{(condition as any).lookupTableLabel || (condition as any).lookupTable || "..."}
{(condition as any).lookupField && `.${(condition as any).lookupFieldLabel || (condition as any).lookupField}`}
</span>
) : (
// 일반 연산자인 경우 값 표시
condition.value !== null &&
condition.value !== undefined && (
<span className="text-muted-foreground">
{typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
</span>
)
)}
</div>
</div>
))}
{data.conditions.length > 4 && (
<div className="text-xs text-muted-foreground/70">... {data.conditions.length - 4}</div>
{/* 조건 미리보기 */}
{condCount > 0 && (
<div className="space-y-0.5 border-t border-zinc-800 px-3 py-2 text-[10px] text-zinc-400">
{data.conditions!.slice(0, 2).map((c, i) => (
<div key={i} className="flex items-center gap-1 flex-wrap">
{i > 0 && <span className="text-amber-500">{data.logic}</span>}
<span className="font-mono text-zinc-300">{c.field}</span>
<span className="text-amber-400">{OPERATOR_LABELS[c.operator] || c.operator}</span>
{c.value !== undefined && c.value !== null && (
<span className="text-zinc-500">{String(c.value)}</span>
)}
</div>
</div>
) : (
<div className="text-center text-xs text-muted-foreground/70"> </div>
)}
</div>
))}
{condCount > 2 && <span className="text-zinc-600"> {condCount - 2}</span>}
</div>
)}
{/* 분기 출력 핸들 */}
<div className="relative border-t">
{/* TRUE 출력 - 오른쪽 위 */}
<div className="relative border-b p-2">
<div className="flex items-center justify-end gap-1 pr-6 text-xs">
<Check className="h-3 w-3 text-emerald-600" />
<span className="font-medium text-emerald-600">TRUE</span>
</div>
{/* 분기 출력 */}
<div className="border-t border-zinc-800">
<div className="relative flex items-center justify-end px-3 py-1.5">
<span className="text-[10px] font-medium text-emerald-400"></span>
<Handle
type="source"
position={Position.Right}
id="true"
className="!top-1/2 !-right-1.5 !h-3 !w-3 !-translate-y-1/2 !border-2 !border-emerald-500 !bg-white"
className="!h-2.5 !w-2.5 !border-2 !border-emerald-500 !bg-zinc-900"
/>
</div>
{/* FALSE 출력 - 오른쪽 아래 */}
<div className="relative p-2">
<div className="flex items-center justify-end gap-1 pr-6 text-xs">
<X className="h-3 w-3 text-destructive" />
<span className="font-medium text-destructive">FALSE</span>
</div>
<div className="relative flex items-center justify-end border-t border-zinc-800/50 px-3 py-1.5">
<span className="text-[10px] font-medium text-pink-400"></span>
<Handle
type="source"
position={Position.Right}
id="false"
className="!top-1/2 !-right-1.5 !h-3 !w-3 !-translate-y-1/2 !border-2 !border-destructive !bg-white"
className="!h-2.5 !w-2.5 !border-2 !border-pink-500 !bg-zinc-900"
/>
</div>
</div>

View File

@ -1,87 +1,38 @@
"use client";
/**
*
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Wand2, ArrowRight } from "lucide-react";
import { NodeProps } from "reactflow";
import { Repeat } from "lucide-react";
import { CompactNodeShell } from "./CompactNodeShell";
import type { DataTransformNodeData } from "@/types/node-editor";
export const DataTransformNode = memo(({ data, selected }: NodeProps<DataTransformNodeData>) => {
const ruleCount = data.transformRules?.length || 0;
const summary = ruleCount > 0
? `${ruleCount}개 변환 규칙`
: "변환 규칙을 설정해 주세요";
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-orange-500 shadow-lg" : "border-border"
}`}
<CompactNodeShell
color="#06B6D4"
label={data.displayName || "데이터 변환"}
summary={summary}
icon={<Repeat className="h-3.5 w-3.5" />}
selected={selected}
>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-primary px-3 py-2 text-white">
<Wand2 className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">{data.displayName || "데이터 변환"}</div>
<div className="text-xs opacity-80">{data.transformations?.length || 0} </div>
{ruleCount > 0 && (
<div className="space-y-0.5">
{data.transformRules!.slice(0, 3).map((r: any, i: number) => (
<div key={i} className="flex items-center gap-1.5">
<div className="h-1 w-1 rounded-full bg-cyan-400" />
<span>{r.sourceField || r.field || `규칙 ${i + 1}`}</span>
{r.targetField && <span className="text-zinc-600"> {r.targetField}</span>}
</div>
))}
{ruleCount > 3 && <span className="text-zinc-600"> {ruleCount - 3}</span>}
</div>
</div>
{/* 본문 */}
<div className="p-3">
{data.transformations && data.transformations.length > 0 ? (
<div className="space-y-2">
{data.transformations.slice(0, 3).map((transform, idx) => {
const sourceLabel = transform.sourceFieldLabel || transform.sourceField || "소스";
const targetField = transform.targetField || transform.sourceField;
const targetLabel = transform.targetFieldLabel || targetField;
const isInPlace = !transform.targetField || transform.targetField === transform.sourceField;
return (
<div key={idx} className="rounded bg-indigo-50 p-2">
<div className="mb-1 flex items-center gap-2 text-xs">
<span className="font-medium text-indigo-700">{transform.type}</span>
</div>
<div className="text-xs text-muted-foreground">
{sourceLabel}
<span className="mx-1 text-muted-foreground/70"></span>
{isInPlace ? (
<span className="font-medium text-primary">()</span>
) : (
<span>{targetLabel}</span>
)}
</div>
{/* 타입별 추가 정보 */}
{transform.type === "EXPLODE" && transform.delimiter && (
<div className="mt-1 text-xs text-muted-foreground">: {transform.delimiter}</div>
)}
{transform.type === "CONCAT" && transform.separator && (
<div className="mt-1 text-xs text-muted-foreground">: {transform.separator}</div>
)}
{transform.type === "REPLACE" && (
<div className="mt-1 text-xs text-muted-foreground">
"{transform.searchValue}" "{transform.replaceValue}"
</div>
)}
{transform.expression && (
<div className="mt-1 text-xs text-muted-foreground">
<code className="rounded bg-white px-1 py-0.5">{transform.expression}</code>
</div>
)}
</div>
);
})}
{data.transformations.length > 3 && (
<div className="text-xs text-muted-foreground/70">... {data.transformations.length - 3}</div>
)}
</div>
) : (
<div className="py-4 text-center text-xs text-muted-foreground/70"> </div>
)}
</div>
{/* 핸들 */}
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-primary" />
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-primary" />
</div>
)}
</CompactNodeShell>
);
});

View File

@ -1,75 +1,25 @@
"use client";
/**
* DELETE
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Trash2, AlertTriangle } from "lucide-react";
import { NodeProps } from "reactflow";
import { Trash2 } from "lucide-react";
import { CompactNodeShell } from "./CompactNodeShell";
import type { DeleteActionNodeData } from "@/types/node-editor";
export const DeleteActionNode = memo(({ data, selected }: NodeProps<DeleteActionNodeData>) => {
const whereCount = data.whereConditions?.length || 0;
const summary = data.targetTable
? `${data.targetTable} (${whereCount}개 조건)`
: "대상 테이블을 선택해 주세요";
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-destructive shadow-lg" : "border-border"
}`}
>
{/* 입력 핸들 */}
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-destructive !bg-white" />
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-destructive px-3 py-2 text-white">
<Trash2 className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">DELETE</div>
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
</div>
</div>
{/* 본문 */}
<div className="p-3">
<div className="mb-2 text-xs font-medium text-muted-foreground">: {data.targetTable}</div>
{/* WHERE 조건 */}
{data.whereConditions && data.whereConditions.length > 0 ? (
<div className="space-y-1">
<div className="text-xs font-medium text-foreground">WHERE :</div>
<div className="max-h-[120px] space-y-1 overflow-y-auto">
{data.whereConditions.map((condition, idx) => (
<div key={idx} className="rounded bg-destructive/10 px-2 py-1 text-xs">
<span className="font-mono text-foreground">{condition.field}</span>
<span className="mx-1 text-destructive">{condition.operator}</span>
<span className="text-muted-foreground">{condition.sourceField || condition.staticValue || "?"}</span>
</div>
))}
</div>
</div>
) : (
<div className="rounded bg-amber-50 p-2 text-xs text-yellow-700"> - !</div>
)}
{/* 경고 메시지 */}
<div className="mt-3 flex items-start gap-2 rounded border border-destructive/20 bg-destructive/10 p-2">
<AlertTriangle className="h-3 w-3 flex-shrink-0 text-destructive" />
<div className="text-xs text-destructive">
<div className="font-medium"></div>
<div className="mt-0.5"> </div>
</div>
</div>
{/* 옵션 */}
{data.options?.requireConfirmation && (
<div className="mt-2">
<span className="rounded bg-destructive/10 px-1.5 py-0.5 text-xs text-destructive"> </span>
</div>
)}
</div>
{/* 출력 핸들 */}
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-destructive !bg-white" />
</div>
<CompactNodeShell
color="#EF4444"
label={data.displayName || "DELETE"}
summary={summary}
icon={<Trash2 className="h-3.5 w-3.5" />}
selected={selected}
/>
);
});

View File

@ -1,104 +1,30 @@
"use client";
/**
*
*
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Mail, User, CheckCircle } from "lucide-react";
import type { EmailActionNodeData } from "@/types/node-editor";
import { NodeProps } from "reactflow";
import { Mail } from "lucide-react";
import { CompactNodeShell } from "./CompactNodeShell";
export const EmailActionNode = memo(({ data, selected }: NodeProps<EmailActionNodeData>) => {
const hasAccount = !!data.accountId;
const hasRecipient = data.to && data.to.trim().length > 0;
const hasSubject = data.subject && data.subject.trim().length > 0;
export const EmailActionNode = memo(({ data, selected }: NodeProps<any>) => {
const summary = data.to
? `To: ${data.to}`
: "수신자를 설정해 주세요";
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-pink-500 shadow-lg" : "border-border"
}`}
<CompactNodeShell
color="#EC4899"
label={data.displayName || "메일 발송"}
summary={summary}
icon={<Mail className="h-3.5 w-3.5" />}
selected={selected}
>
{/* 입력 핸들 */}
<Handle
type="target"
position={Position.Left}
className="!h-3 !w-3 !border-2 !border-white !bg-pink-500"
/>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-pink-500 px-3 py-2 text-white">
<Mail className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">{data.displayName || "메일 발송"}</div>
{data.subject && (
<div className="line-clamp-2">
: {data.subject}
</div>
</div>
{/* 본문 */}
<div className="space-y-2 p-3">
{/* 발송 계정 상태 */}
<div className="flex items-center gap-2 text-xs">
<User className="h-3 w-3 text-muted-foreground/70" />
<span className="text-muted-foreground">
{hasAccount ? (
<span className="flex items-center gap-1 text-emerald-600">
<CheckCircle className="h-3 w-3" />
</span>
) : (
<span className="text-amber-500"> </span>
)}
</span>
</div>
{/* 수신자 */}
<div className="text-xs">
<span className="text-muted-foreground">: </span>
{hasRecipient ? (
<span className="text-foreground">{data.to}</span>
) : (
<span className="text-amber-500"></span>
)}
</div>
{/* 제목 */}
<div className="text-xs">
<span className="text-muted-foreground">: </span>
{hasSubject ? (
<span className="truncate text-foreground">{data.subject}</span>
) : (
<span className="text-amber-500"></span>
)}
</div>
{/* 본문 형식 */}
<div className="flex items-center gap-2">
<span
className={`rounded px-1.5 py-0.5 text-xs ${
data.bodyType === "html" ? "bg-primary/10 text-primary" : "bg-muted text-foreground"
}`}
>
{data.bodyType === "html" ? "HTML" : "TEXT"}
</span>
{data.attachments && data.attachments.length > 0 && (
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-700">
{data.attachments.length}
</span>
)}
</div>
</div>
{/* 출력 핸들 */}
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-white !bg-pink-500"
/>
</div>
)}
</CompactNodeShell>
);
});
EmailActionNode.displayName = "EmailActionNode";

View File

@ -1,87 +1,25 @@
"use client";
/**
* DB
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Plug } from "lucide-react";
import { NodeProps } from "reactflow";
import { HardDrive } from "lucide-react";
import { CompactNodeShell } from "./CompactNodeShell";
import type { ExternalDBSourceNodeData } from "@/types/node-editor";
const DB_TYPE_COLORS: Record<string, string> = {
PostgreSQL: "#336791",
MySQL: "#4479A1",
Oracle: "#F80000",
MSSQL: "#CC2927",
MariaDB: "#003545",
};
const DB_TYPE_ICONS: Record<string, string> = {
PostgreSQL: "🐘",
MySQL: "🐬",
Oracle: "🔴",
MSSQL: "🟦",
MariaDB: "🦭",
};
export const ExternalDBSourceNode = memo(({ data, selected }: NodeProps<ExternalDBSourceNodeData>) => {
const dbColor = (data.dbType && DB_TYPE_COLORS[data.dbType]) || "#F59E0B";
const dbIcon = (data.dbType && DB_TYPE_ICONS[data.dbType]) || "🔌";
const summary = data.connectionName
? `${data.connectionName}${data.tableName || "..."}`
: "외부 DB 연결을 설정해 주세요";
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-orange-500 shadow-lg" : "border-border"
}`}
>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg px-3 py-2 text-white" style={{ backgroundColor: dbColor }}>
<Plug className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">{data.displayName || data.connectionName}</div>
<div className="text-xs opacity-80">{data.tableName}</div>
</div>
<span className="text-lg">{dbIcon}</span>
</div>
{/* 본문 */}
<div className="p-3">
<div className="mb-2 flex items-center gap-1 text-xs">
<div className="rounded bg-amber-100 px-2 py-0.5 font-medium text-orange-700">{data.dbType || "DB"}</div>
<div className="flex-1 text-muted-foreground"> DB</div>
</div>
{/* 필드 목록 */}
<div className="space-y-1">
<div className="text-xs font-medium text-foreground"> :</div>
<div className="max-h-[150px] overflow-y-auto">
{data.fields && data.fields.length > 0 ? (
data.fields.slice(0, 5).map((field) => (
<div key={field.name} className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: dbColor }} />
<span className="font-mono">{field.name}</span>
<span className="text-muted-foreground/70">({field.type})</span>
</div>
))
) : (
<div className="text-xs text-muted-foreground/70"> </div>
)}
{data.fields && data.fields.length > 5 && (
<div className="text-xs text-muted-foreground/70">... {data.fields.length - 5}</div>
)}
</div>
</div>
</div>
{/* 출력 핸들 */}
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !bg-white"
style={{ borderColor: dbColor }}
/>
</div>
<CompactNodeShell
color="#F59E0B"
label={data.displayName || "외부 DB"}
summary={summary}
icon={<HardDrive className="h-3.5 w-3.5" />}
selected={selected}
hasInput={false}
/>
);
});

View File

@ -1,164 +1,23 @@
"use client";
/**
* (Formula Transform Node)
* , , .
* UPSERT .
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Calculator, Database, ArrowRight } from "lucide-react";
import type { FormulaTransformNodeData, FormulaType } from "@/types/node-editor";
import { NodeProps } from "reactflow";
import { Calculator } from "lucide-react";
import { CompactNodeShell } from "./CompactNodeShell";
// 수식 타입별 라벨
const FORMULA_TYPE_LABELS: Record<FormulaType, { label: string; color: string }> = {
arithmetic: { label: "산술", color: "bg-amber-500" },
function: { label: "함수", color: "bg-primary" },
condition: { label: "조건", color: "bg-amber-500" },
static: { label: "정적", color: "bg-muted0" },
};
// 연산자 표시
const OPERATOR_LABELS: Record<string, string> = {
"+": "+",
"-": "-",
"*": "x",
"/": "/",
"%": "%",
};
// 피연산자를 문자열로 변환
function getOperandStr(operand: any): string {
if (!operand) return "?";
if (operand.type === "static") return String(operand.value || "?");
if (operand.fieldLabel) return operand.fieldLabel;
return operand.field || operand.resultField || "?";
}
// 수식 요약 생성
function getFormulaSummary(transformation: FormulaTransformNodeData["transformations"][0]): string {
const { formulaType, arithmetic, function: func, condition, staticValue } = transformation;
switch (formulaType) {
case "arithmetic": {
if (!arithmetic) return "미설정";
const leftStr = getOperandStr(arithmetic.leftOperand);
const rightStr = getOperandStr(arithmetic.rightOperand);
let formula = `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`;
// 추가 연산 표시
if (arithmetic.additionalOperations && arithmetic.additionalOperations.length > 0) {
for (const addOp of arithmetic.additionalOperations) {
const opStr = getOperandStr(addOp.operand);
formula += ` ${OPERATOR_LABELS[addOp.operator] || addOp.operator} ${opStr}`;
}
}
return formula;
}
case "function": {
if (!func) return "미설정";
const args = func.arguments
.map((arg) => (arg.type === "static" ? arg.value : `${arg.type}.${arg.field || arg.resultField}`))
.join(", ");
return `${func.name}(${args})`;
}
case "condition": {
if (!condition) return "미설정";
return "CASE WHEN ... THEN ... ELSE ...";
}
case "static": {
return staticValue !== undefined ? String(staticValue) : "미설정";
}
default:
return "미설정";
}
}
export const FormulaTransformNode = memo(({ data, selected }: NodeProps<FormulaTransformNodeData>) => {
const transformationCount = data.transformations?.length || 0;
const hasTargetLookup = !!data.targetLookup?.tableName;
export const FormulaTransformNode = memo(({ data, selected }: NodeProps<any>) => {
const summary = data.formula
? `${data.formula.substring(0, 30)}${data.formula.length > 30 ? "..." : ""}`
: "수식을 입력해 주세요";
return (
<div
className={`min-w-[300px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-orange-500 shadow-lg" : "border-border"
}`}
>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-amber-500 px-3 py-2 text-white">
<Calculator className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">{data.displayName || "수식 변환"}</div>
<div className="text-xs opacity-80">
{transformationCount} {hasTargetLookup && "| 타겟 조회"}
</div>
</div>
</div>
{/* 본문 */}
<div className="space-y-3 p-3">
{/* 타겟 테이블 조회 설정 */}
{hasTargetLookup && (
<div className="rounded bg-primary/10 p-2">
<div className="mb-1 flex items-center gap-1">
<Database className="h-3 w-3 text-primary" />
<span className="text-xs font-medium text-primary"> </span>
</div>
<div className="text-xs text-primary">{data.targetLookup?.tableLabel || data.targetLookup?.tableName}</div>
{data.targetLookup?.lookupKeys && data.targetLookup.lookupKeys.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{data.targetLookup.lookupKeys.slice(0, 2).map((key, idx) => (
<span
key={idx}
className="inline-flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary"
>
{key.sourceFieldLabel || key.sourceField}
<ArrowRight className="h-2 w-2" />
{key.targetFieldLabel || key.targetField}
</span>
))}
{data.targetLookup.lookupKeys.length > 2 && (
<span className="text-xs text-primary">+{data.targetLookup.lookupKeys.length - 2}</span>
)}
</div>
)}
</div>
)}
{/* 변환 규칙들 */}
{transformationCount > 0 ? (
<div className="space-y-2">
{data.transformations.slice(0, 4).map((trans, idx) => {
const typeInfo = FORMULA_TYPE_LABELS[trans.formulaType];
return (
<div key={trans.id || idx} className="rounded bg-muted p-2">
<div className="flex items-center justify-between">
<span className={`rounded px-1.5 py-0.5 text-xs font-medium text-white ${typeInfo.color}`}>
{typeInfo.label}
</span>
<span className="text-xs font-medium text-foreground">
{trans.outputFieldLabel || trans.outputField}
</span>
</div>
<div className="mt-1 truncate font-mono text-xs text-muted-foreground">{getFormulaSummary(trans)}</div>
</div>
);
})}
{data.transformations.length > 4 && (
<div className="text-center text-xs text-muted-foreground/70">... {data.transformations.length - 4}</div>
)}
</div>
) : (
<div className="py-4 text-center text-xs text-muted-foreground/70"> </div>
)}
</div>
{/* 핸들 */}
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-amber-500" />
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-amber-500" />
</div>
<CompactNodeShell
color="#F97316"
label={data.displayName || "수식 변환"}
summary={summary}
icon={<Calculator className="h-3.5 w-3.5" />}
selected={selected}
/>
);
});

View File

@ -1,124 +1,34 @@
"use client";
/**
* HTTP
* REST API를
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Globe, Lock, Unlock } from "lucide-react";
import type { HttpRequestActionNodeData } from "@/types/node-editor";
import { NodeProps } from "reactflow";
import { Send } from "lucide-react";
import { CompactNodeShell } from "./CompactNodeShell";
// HTTP 메서드별 색상
const METHOD_COLORS: Record<string, { bg: string; text: string }> = {
GET: { bg: "bg-emerald-100", text: "text-emerald-700" },
POST: { bg: "bg-primary/10", text: "text-primary" },
PUT: { bg: "bg-amber-100", text: "text-orange-700" },
PATCH: { bg: "bg-amber-100", text: "text-yellow-700" },
DELETE: { bg: "bg-destructive/10", text: "text-destructive" },
HEAD: { bg: "bg-muted", text: "text-foreground" },
OPTIONS: { bg: "bg-purple-100", text: "text-purple-700" },
};
export const HttpRequestActionNode = memo(({ data, selected }: NodeProps<HttpRequestActionNodeData>) => {
const methodColor = METHOD_COLORS[data.method] || METHOD_COLORS.GET;
const hasUrl = data.url && data.url.trim().length > 0;
const hasAuth = data.authentication?.type && data.authentication.type !== "none";
// URL에서 도메인 추출
const getDomain = (url: string) => {
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
return url;
}
};
export const HttpRequestActionNode = memo(({ data, selected }: NodeProps<any>) => {
const method = data.method || "GET";
const summary = data.url
? `${method} ${data.url}`
: "요청 URL을 입력해 주세요";
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-cyan-500 shadow-lg" : "border-border"
}`}
<CompactNodeShell
color="#06B6D4"
label={data.displayName || "HTTP 요청"}
summary={summary}
icon={<Send className="h-3.5 w-3.5" />}
selected={selected}
>
{/* 입력 핸들 */}
<Handle
type="target"
position={Position.Left}
className="!h-3 !w-3 !border-2 !border-white !bg-cyan-500"
/>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-cyan-500 px-3 py-2 text-white">
<Globe className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">{data.displayName || "HTTP 요청"}</div>
</div>
</div>
{/* 본문 */}
<div className="space-y-2 p-3">
{/* 메서드 & 인증 */}
<div className="flex items-center gap-2">
<span className={`rounded px-2 py-0.5 text-xs font-bold ${methodColor.bg} ${methodColor.text}`}>
{data.method}
{data.url && (
<div className="flex items-center gap-1.5">
<span className="rounded bg-cyan-500/20 px-1 py-0.5 font-mono text-[9px] font-semibold text-cyan-400">
{method}
</span>
{hasAuth ? (
<span className="flex items-center gap-1 rounded bg-emerald-100 px-1.5 py-0.5 text-xs text-emerald-700">
<Lock className="h-3 w-3" />
{data.authentication?.type}
</span>
) : (
<span className="flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
<Unlock className="h-3 w-3" />
</span>
)}
<span className="break-all font-mono">{data.url}</span>
</div>
{/* URL */}
<div className="text-xs">
<span className="text-muted-foreground">URL: </span>
{hasUrl ? (
<span className="truncate text-foreground" title={data.url}>
{getDomain(data.url)}
</span>
) : (
<span className="text-amber-500">URL </span>
)}
</div>
{/* 바디 타입 */}
{data.bodyType && data.bodyType !== "none" && (
<div className="text-xs">
<span className="text-muted-foreground">Body: </span>
<span className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground">
{data.bodyType.toUpperCase()}
</span>
</div>
)}
{/* 타임아웃 & 재시도 */}
<div className="flex gap-2 text-xs text-muted-foreground">
{data.options?.timeout && (
<span>: {Math.round(data.options.timeout / 1000)}</span>
)}
{data.options?.retryCount && data.options.retryCount > 0 && (
<span>: {data.options.retryCount}</span>
)}
</div>
</div>
{/* 출력 핸들 */}
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-white !bg-cyan-500"
/>
</div>
)}
</CompactNodeShell>
);
});
HttpRequestActionNode.displayName = "HttpRequestActionNode";

View File

@ -1,81 +1,38 @@
"use client";
/**
* INSERT
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { NodeProps } from "reactflow";
import { Plus } from "lucide-react";
import { CompactNodeShell } from "./CompactNodeShell";
import type { InsertActionNodeData } from "@/types/node-editor";
export const InsertActionNode = memo(({ data, selected }: NodeProps<InsertActionNodeData>) => {
const mappingCount = data.fieldMappings?.length || 0;
const summary = data.targetTable
? `${data.targetTable} (${mappingCount}개 필드)`
: "대상 테이블을 선택해 주세요";
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-emerald-500 shadow-lg" : "border-border"
}`}
<CompactNodeShell
color="#22C55E"
label={data.displayName || "INSERT"}
summary={summary}
icon={<Plus className="h-3.5 w-3.5" />}
selected={selected}
>
{/* 입력 핸들 */}
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-emerald-500 !bg-white" />
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-emerald-500 px-3 py-2 text-white">
<Plus className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">INSERT</div>
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
</div>
</div>
{/* 본문 */}
<div className="p-3">
<div className="mb-2 text-xs font-medium text-muted-foreground">
: {data.displayName || data.targetTable}
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
<span className="ml-1 font-mono text-muted-foreground/70">({data.targetTable})</span>
)}
</div>
{/* 필드 매핑 */}
{data.fieldMappings && data.fieldMappings.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-medium text-foreground"> :</div>
<div className="max-h-[120px] space-y-1 overflow-y-auto">
{data.fieldMappings.slice(0, 4).map((mapping, idx) => (
<div key={idx} className="rounded bg-muted px-2 py-1 text-xs">
<span className="text-muted-foreground">
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
</span>
<span className="mx-1 text-muted-foreground/70"></span>
<span className="font-mono text-foreground">{mapping.targetFieldLabel || mapping.targetField}</span>
</div>
))}
{data.fieldMappings.length > 4 && (
<div className="text-xs text-muted-foreground/70">... {data.fieldMappings.length - 4}</div>
)}
{mappingCount > 0 && (
<div className="space-y-0.5">
{data.fieldMappings!.slice(0, 3).map((m, i) => (
<div key={i} className="flex items-center gap-1">
<span>{m.sourceFieldLabel || m.sourceField || "?"}</span>
<span className="text-zinc-600"></span>
<span className="font-mono text-zinc-300">{m.targetFieldLabel || m.targetField}</span>
</div>
</div>
)}
{/* 옵션 */}
{data.options && (
<div className="mt-2 flex flex-wrap gap-1">
{data.options.ignoreDuplicates && (
<span className="rounded bg-emerald-100 px-1.5 py-0.5 text-xs text-emerald-700"> </span>
)}
{data.options.batchSize && (
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
{data.options.batchSize}
</span>
)}
</div>
)}
</div>
{/* 출력 핸들 */}
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-emerald-500 !bg-white" />
</div>
))}
{mappingCount > 3 && <span className="text-zinc-600"> {mappingCount - 3}</span>}
</div>
)}
</CompactNodeShell>
);
});

View File

@ -1,58 +1,24 @@
"use client";
/**
* -
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { FileText, AlertCircle, Info, AlertTriangle } from "lucide-react";
import type { LogNodeData } from "@/types/node-editor";
import { NodeProps } from "reactflow";
import { FileText } from "lucide-react";
import { CompactNodeShell } from "./CompactNodeShell";
const LOG_LEVEL_CONFIG = {
debug: { icon: Info, color: "text-primary", bg: "bg-primary/10", border: "border-primary/20" },
info: { icon: Info, color: "text-emerald-600", bg: "bg-emerald-50", border: "border-emerald-200" },
warn: { icon: AlertTriangle, color: "text-amber-600", bg: "bg-amber-50", border: "border-amber-200" },
error: { icon: AlertCircle, color: "text-destructive", bg: "bg-destructive/10", border: "border-destructive/20" },
};
export const LogNode = memo(({ data, selected }: NodeProps<LogNodeData>) => {
const config = LOG_LEVEL_CONFIG[data.level] || LOG_LEVEL_CONFIG.info;
const Icon = config.icon;
export const LogNode = memo(({ data, selected }: NodeProps<any>) => {
const summary = data.logLevel
? `${data.logLevel} 레벨 로깅`
: "로그를 기록해요";
return (
<div
className={`min-w-[200px] rounded-lg border-2 bg-white shadow-sm transition-all ${
selected ? `${config.border} shadow-md` : "border-border"
}`}
>
{/* 헤더 */}
<div className={`flex items-center gap-2 rounded-t-lg ${config.bg} px-3 py-2`}>
<FileText className={`h-4 w-4 ${config.color}`} />
<div className="flex-1">
<div className={`text-sm font-semibold ${config.color}`}></div>
<div className="text-xs text-muted-foreground">{data.level.toUpperCase()}</div>
</div>
<Icon className={`h-4 w-4 ${config.color}`} />
</div>
{/* 본문 */}
<div className="p-3">
{data.message ? (
<div className="text-sm text-foreground">{data.message}</div>
) : (
<div className="text-sm text-muted-foreground/70"> </div>
)}
{data.includeData && (
<div className="mt-2 rounded bg-muted px-2 py-1 text-xs text-muted-foreground"> </div>
)}
</div>
{/* 핸들 */}
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-muted-foreground" />
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-muted-foreground" />
</div>
<CompactNodeShell
color="#6B7280"
label={data.displayName || "로그"}
summary={summary}
icon={<FileText className="h-3.5 w-3.5" />}
selected={selected}
hasOutput={false}
/>
);
});

View File

@ -1,121 +1,24 @@
"use client";
/**
* /
* DB의 /
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Database, Workflow } from "lucide-react";
import type { ProcedureCallActionNodeData } from "@/types/node-editor";
import { NodeProps } from "reactflow";
import { Database } from "lucide-react";
import { CompactNodeShell } from "./CompactNodeShell";
export const ProcedureCallActionNode = memo(
({ data, selected }: NodeProps<ProcedureCallActionNodeData>) => {
const hasProcedure = !!data.procedureName;
const inParams = data.parameters?.filter((p) => p.mode === "IN" || p.mode === "INOUT") ?? [];
const outParams = data.parameters?.filter((p) => p.mode === "OUT" || p.mode === "INOUT") ?? [];
export const ProcedureCallActionNode = memo(({ data, selected }: NodeProps<any>) => {
const summary = data.procedureName
? `${data.procedureName}()`
: "프로시저를 선택해 주세요";
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-violet-500 shadow-lg" : "border-border"
}`}
>
{/* 입력 핸들 */}
<Handle
type="target"
position={Position.Left}
className="!h-3 !w-3 !border-2 !border-white !bg-violet-500"
/>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-violet-500 px-3 py-2 text-white">
<Workflow className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">
{data.displayName || "프로시저 호출"}
</div>
</div>
</div>
{/* 본문 */}
<div className="space-y-2 p-3">
{/* DB 소스 */}
<div className="flex items-center gap-2">
<Database className="h-3 w-3 text-muted-foreground/70" />
<span className="text-xs text-muted-foreground">
{data.dbSource === "external" ? (
<span className="rounded bg-amber-100 px-2 py-0.5 text-amber-700">
{data.connectionName || "외부 DB"}
</span>
) : (
<span className="rounded bg-primary/10 px-2 py-0.5 text-primary">
DB
</span>
)}
</span>
<span
className={`ml-auto rounded px-2 py-0.5 text-xs font-medium ${
data.callType === "function"
? "bg-cyan-100 text-cyan-700"
: "bg-violet-100 text-violet-700"
}`}
>
{data.callType === "function" ? "FUNCTION" : "PROCEDURE"}
</span>
</div>
{/* 프로시저명 */}
<div className="flex items-center gap-2 text-xs">
<Workflow className="h-3 w-3 text-muted-foreground/70" />
{hasProcedure ? (
<span className="font-mono text-emerald-600 truncate">
{data.procedureSchema && data.procedureSchema !== "public"
? `${data.procedureSchema}.`
: ""}
{data.procedureName}()
</span>
) : (
<span className="text-amber-500"> </span>
)}
</div>
{/* 파라미터 수 */}
{hasProcedure && inParams.length > 0 && (
<div className="text-xs text-muted-foreground">
: {inParams.length}
</div>
)}
{/* 반환 필드 */}
{hasProcedure && outParams.length > 0 && (
<div className="mt-1 space-y-1 border-t border-border pt-1">
<div className="text-[10px] font-medium text-emerald-600">
:
</div>
{outParams.map((p) => (
<div
key={p.name}
className="flex items-center justify-between rounded bg-emerald-50 px-2 py-0.5 text-[10px]"
>
<span className="font-mono text-emerald-700">{p.name}</span>
<span className="text-muted-foreground/70">{p.dataType}</span>
</div>
))}
</div>
)}
</div>
{/* 출력 핸들 */}
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-white !bg-violet-500"
/>
</div>
);
}
);
return (
<CompactNodeShell
color="#8B5CF6"
label={data.displayName || "프로시저 호출"}
summary={summary}
icon={<Database className="h-3.5 w-3.5" />}
selected={selected}
/>
);
});
ProcedureCallActionNode.displayName = "ProcedureCallActionNode";

View File

@ -1,80 +1,35 @@
"use client";
/**
* REST API
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Globe, Lock } from "lucide-react";
import { NodeProps } from "reactflow";
import { Globe } from "lucide-react";
import { CompactNodeShell } from "./CompactNodeShell";
import type { RestAPISourceNodeData } from "@/types/node-editor";
const METHOD_COLORS: Record<string, string> = {
GET: "bg-emerald-100 text-emerald-700",
POST: "bg-primary/10 text-primary",
PUT: "bg-amber-100 text-yellow-700",
DELETE: "bg-destructive/10 text-destructive",
PATCH: "bg-purple-100 text-purple-700",
};
export const RestAPISourceNode = memo(({ data, selected }: NodeProps<RestAPISourceNodeData>) => {
const methodColor = METHOD_COLORS[data.method] || "bg-muted text-foreground";
const method = data.method || "GET";
const summary = data.url
? `${method} ${data.url}`
: "API URL을 입력해 주세요";
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-orange-500 shadow-lg" : "border-border"
}`}
<CompactNodeShell
color="#10B981"
label={data.displayName || "REST API"}
summary={summary}
icon={<Globe className="h-3.5 w-3.5" />}
selected={selected}
hasInput={false}
>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-teal-600 px-3 py-2 text-white">
<Globe className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">{data.displayName || "REST API"}</div>
<div className="text-xs opacity-80">{data.url || "URL 미설정"}</div>
{data.url && (
<div className="flex items-center gap-1.5">
<span className="rounded bg-emerald-500/20 px-1 py-0.5 font-mono text-[9px] font-semibold text-emerald-400">
{method}
</span>
<span className="break-all font-mono">{data.url}</span>
</div>
{data.authentication && <Lock className="h-4 w-4 opacity-70" />}
</div>
{/* 본문 */}
<div className="p-3">
{/* HTTP 메서드 */}
<div className="mb-2 flex items-center gap-2">
<span className={`rounded px-2 py-1 text-xs font-semibold ${methodColor}`}>{data.method}</span>
{data.timeout && <span className="text-xs text-muted-foreground">{data.timeout}ms</span>}
</div>
{/* 헤더 */}
{data.headers && Object.keys(data.headers).length > 0 && (
<div className="mb-2">
<div className="text-xs font-medium text-foreground">:</div>
<div className="mt-1 space-y-1">
{Object.entries(data.headers)
.slice(0, 2)
.map(([key, value]) => (
<div key={key} className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{key}:</span>
<span className="truncate text-muted-foreground">{value}</span>
</div>
))}
{Object.keys(data.headers).length > 2 && (
<div className="text-xs text-muted-foreground/70">... {Object.keys(data.headers).length - 2}</div>
)}
</div>
</div>
)}
{/* 응답 매핑 */}
{data.responseMapping && (
<div className="rounded bg-teal-50 px-2 py-1 text-xs text-teal-700">
: <code className="font-mono">{data.responseMapping}</code>
</div>
)}
</div>
{/* 핸들 */}
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-teal-500" />
</div>
)}
</CompactNodeShell>
);
});

View File

@ -1,118 +1,31 @@
"use client";
/**
*
* Python, Shell, PowerShell
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Terminal, FileCode, Play } from "lucide-react";
import type { ScriptActionNodeData } from "@/types/node-editor";
import { NodeProps } from "reactflow";
import { Terminal } from "lucide-react";
import { CompactNodeShell } from "./CompactNodeShell";
// 스크립트 타입별 아이콘 색상
const SCRIPT_TYPE_COLORS: Record<string, { bg: string; text: string; label: string }> = {
python: { bg: "bg-amber-100", text: "text-yellow-700", label: "Python" },
shell: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Shell" },
powershell: { bg: "bg-primary/10", text: "text-primary", label: "PowerShell" },
node: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Node.js" },
executable: { bg: "bg-muted", text: "text-foreground", label: "실행파일" },
};
export const ScriptActionNode = memo(({ data, selected }: NodeProps<ScriptActionNodeData>) => {
const scriptTypeInfo = SCRIPT_TYPE_COLORS[data.scriptType] || SCRIPT_TYPE_COLORS.executable;
const hasScript = data.executionMode === "inline" ? !!data.inlineScript : !!data.scriptPath;
export const ScriptActionNode = memo(({ data, selected }: NodeProps<any>) => {
const scriptType = data.scriptType || "python";
const summary = data.inlineScript
? `${scriptType} 스크립트 (${data.inlineScript.split("\n").length}줄)`
: "스크립트를 작성해 주세요";
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-emerald-500 shadow-lg" : "border-border"
}`}
<CompactNodeShell
color="#10B981"
label={data.displayName || "스크립트 실행"}
summary={summary}
icon={<Terminal className="h-3.5 w-3.5" />}
selected={selected}
>
{/* 입력 핸들 */}
<Handle
type="target"
position={Position.Left}
className="!h-3 !w-3 !border-2 !border-white !bg-emerald-500"
/>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-emerald-500 px-3 py-2 text-white">
<Terminal className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">{data.displayName || "스크립트 실행"}</div>
</div>
</div>
{/* 본문 */}
<div className="space-y-2 p-3">
{/* 스크립트 타입 */}
<div className="flex items-center gap-2">
<span className={`rounded px-2 py-0.5 text-xs font-medium ${scriptTypeInfo.bg} ${scriptTypeInfo.text}`}>
{scriptTypeInfo.label}
</span>
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{data.executionMode === "inline" ? "인라인" : "파일"}
</span>
</div>
{/* 스크립트 정보 */}
<div className="flex items-center gap-2 text-xs">
{data.executionMode === "inline" ? (
<>
<FileCode className="h-3 w-3 text-muted-foreground/70" />
<span className="text-muted-foreground">
{hasScript ? (
<span className="text-emerald-600">
{data.inlineScript!.split("\n").length}
</span>
) : (
<span className="text-amber-500"> </span>
)}
</span>
</>
) : (
<>
<Play className="h-3 w-3 text-muted-foreground/70" />
<span className="text-muted-foreground">
{hasScript ? (
<span className="truncate text-emerald-600">{data.scriptPath}</span>
) : (
<span className="text-amber-500"> </span>
)}
</span>
</>
)}
</div>
{/* 입력 방식 */}
<div className="text-xs">
<span className="text-muted-foreground">: </span>
<span className="text-foreground">
{data.inputMethod === "stdin" && "표준입력 (stdin)"}
{data.inputMethod === "args" && "명령줄 인자"}
{data.inputMethod === "env" && "환경변수"}
{data.inputMethod === "file" && "파일"}
</span>
</div>
{/* 타임아웃 */}
{data.options?.timeout && (
<div className="text-xs text-muted-foreground">
: {Math.round(data.options.timeout / 1000)}
</div>
)}
</div>
{/* 출력 핸들 */}
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-white !bg-emerald-500"
/>
</div>
{data.scriptType && (
<span className="rounded bg-emerald-500/20 px-1 py-0.5 font-mono text-[9px] font-semibold text-emerald-400">
{scriptType}
</span>
)}
</CompactNodeShell>
);
});
ScriptActionNode.displayName = "ScriptActionNode";

View File

@ -1,69 +1,40 @@
"use client";
/**
*
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { NodeProps } from "reactflow";
import { Database } from "lucide-react";
import { CompactNodeShell } from "./CompactNodeShell";
import type { TableSourceNodeData } from "@/types/node-editor";
export const TableSourceNode = memo(({ data, selected }: NodeProps<TableSourceNodeData>) => {
// 디버깅: 필드 데이터 확인
if (data.fields && data.fields.length > 0) {
console.log("🔍 TableSource 필드 데이터:", data.fields);
}
const fieldCount = data.fields?.length || 0;
const summary = data.tableName
? `${data.tableName} (${fieldCount}개 필드)`
: "테이블을 선택해 주세요";
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-primary shadow-lg" : "border-border"
}`}
<CompactNodeShell
color="#3B82F6"
label={data.displayName || data.tableName || "테이블 소스"}
summary={summary}
icon={<Database className="h-3.5 w-3.5" />}
selected={selected}
hasInput={false}
>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-primary px-3 py-2 text-white">
<Database className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">{data.displayName || data.tableName || "테이블 소스"}</div>
{data.tableName && data.displayName !== data.tableName && (
<div className="text-xs opacity-80">{data.tableName}</div>
{fieldCount > 0 && (
<div className="space-y-0.5">
{data.fields!.slice(0, 4).map((f) => (
<div key={f.name} className="flex items-center gap-1.5">
<div className="h-1 w-1 flex-shrink-0 rounded-full bg-blue-400" />
<span>{f.label || f.displayName || f.name}</span>
</div>
))}
{fieldCount > 4 && (
<span className="text-zinc-600"> {fieldCount - 4}</span>
)}
</div>
</div>
{/* 본문 */}
<div className="p-3">
<div className="mb-2 text-xs font-medium text-muted-foreground">📍 </div>
{/* 필드 목록 */}
<div className="space-y-1">
<div className="text-xs font-medium text-foreground"> :</div>
<div className="max-h-[150px] overflow-y-auto">
{data.fields && data.fields.length > 0 ? (
data.fields.slice(0, 5).map((field) => (
<div key={field.name} className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="h-1.5 w-1.5 rounded-full bg-primary/70" />
<span className="font-medium">{field.label || field.displayName || field.name}</span>
{(field.label || field.displayName) && field.label !== field.name && (
<span className="font-mono text-muted-foreground/70">({field.name})</span>
)}
<span className="text-muted-foreground/70">{field.type}</span>
</div>
))
) : (
<div className="text-xs text-muted-foreground/70"> </div>
)}
{data.fields && data.fields.length > 5 && (
<div className="text-xs text-muted-foreground/70">... {data.fields.length - 5}</div>
)}
</div>
</div>
</div>
{/* 출력 핸들 */}
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-primary !bg-white" />
</div>
)}
</CompactNodeShell>
);
});

View File

@ -1,97 +1,26 @@
"use client";
/**
* UPDATE
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Edit } from "lucide-react";
import { NodeProps } from "reactflow";
import { Pencil } from "lucide-react";
import { CompactNodeShell } from "./CompactNodeShell";
import type { UpdateActionNodeData } from "@/types/node-editor";
export const UpdateActionNode = memo(({ data, selected }: NodeProps<UpdateActionNodeData>) => {
const mappingCount = data.fieldMappings?.length || 0;
const whereCount = data.whereConditions?.length || 0;
const summary = data.targetTable
? `${data.targetTable} (${mappingCount}개 필드, ${whereCount}개 조건)`
: "대상 테이블을 선택해 주세요";
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-primary shadow-lg" : "border-border"
}`}
>
{/* 입력 핸들 */}
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-primary !bg-white" />
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-primary px-3 py-2 text-white">
<Edit className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">UPDATE</div>
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
</div>
</div>
{/* 본문 */}
<div className="p-3">
<div className="mb-2 text-xs font-medium text-muted-foreground">
: {data.displayName || data.targetTable}
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
<span className="ml-1 font-mono text-muted-foreground/70">({data.targetTable})</span>
)}
</div>
{/* WHERE 조건 */}
{data.whereConditions && data.whereConditions.length > 0 && (
<div className="mb-3 space-y-1">
<div className="text-xs font-medium text-foreground">WHERE :</div>
<div className="max-h-[80px] space-y-1 overflow-y-auto">
{data.whereConditions.slice(0, 2).map((condition, idx) => (
<div key={idx} className="rounded bg-primary/10 px-2 py-1 text-xs">
<span className="font-mono text-foreground">{condition.fieldLabel || condition.field}</span>
<span className="mx-1 text-primary">{condition.operator}</span>
<span className="text-muted-foreground">
{condition.sourceFieldLabel || condition.sourceField || condition.staticValue || "?"}
</span>
</div>
))}
{data.whereConditions.length > 2 && (
<div className="text-xs text-muted-foreground/70">... {data.whereConditions.length - 2}</div>
)}
</div>
</div>
)}
{/* 필드 매핑 */}
{data.fieldMappings && data.fieldMappings.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-medium text-foreground"> :</div>
<div className="max-h-[100px] space-y-1 overflow-y-auto">
{data.fieldMappings.slice(0, 3).map((mapping, idx) => (
<div key={idx} className="rounded bg-muted px-2 py-1 text-xs">
<span className="text-muted-foreground">
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
</span>
<span className="mx-1 text-muted-foreground/70"></span>
<span className="font-mono text-foreground">{mapping.targetFieldLabel || mapping.targetField}</span>
</div>
))}
{data.fieldMappings.length > 3 && (
<div className="text-xs text-muted-foreground/70">... {data.fieldMappings.length - 3}</div>
)}
</div>
</div>
)}
{/* 옵션 */}
{data.options && data.options.batchSize && (
<div className="mt-2">
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
{data.options.batchSize}
</span>
</div>
)}
</div>
{/* 출력 핸들 */}
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-primary !bg-white" />
</div>
<CompactNodeShell
color="#3B82F6"
label={data.displayName || "UPDATE"}
summary={summary}
icon={<Pencil className="h-3.5 w-3.5" />}
selected={selected}
/>
);
});

View File

@ -1,93 +1,26 @@
"use client";
/**
* UPSERT
* INSERT와 UPDATE를
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Database, RefreshCw } from "lucide-react";
import { NodeProps } from "reactflow";
import { RefreshCw } from "lucide-react";
import { CompactNodeShell } from "./CompactNodeShell";
import type { UpsertActionNodeData } from "@/types/node-editor";
export const UpsertActionNode = memo(({ data, selected }: NodeProps<UpsertActionNodeData>) => {
const mappingCount = data.fieldMappings?.length || 0;
const conflictCount = data.conflictKeys?.length || 0;
const summary = data.targetTable
? `${data.targetTable} (${mappingCount}개 필드, ${conflictCount}개 키)`
: "대상 테이블을 선택해 주세요";
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-orange-500 shadow-lg" : "border-border"
}`}
>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-purple-600 px-3 py-2 text-white">
<RefreshCw className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">{data.displayName || "UPSERT 액션"}</div>
<div className="text-xs opacity-80">{data.targetTable}</div>
</div>
<Database className="h-4 w-4 opacity-70" />
</div>
{/* 본문 */}
<div className="p-3">
<div className="mb-2 text-xs font-medium text-muted-foreground">
: {data.displayName || data.targetTable}
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
<span className="ml-1 font-mono text-muted-foreground/70">({data.targetTable})</span>
)}
</div>
{/* 충돌 키 */}
{data.conflictKeys && data.conflictKeys.length > 0 && (
<div className="mb-2">
<div className="text-xs font-medium text-foreground"> :</div>
<div className="mt-1 flex flex-wrap gap-1">
{data.conflictKeys.map((key, idx) => (
<span key={idx} className="rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-700">
{data.conflictKeyLabels?.[idx] || key}
</span>
))}
</div>
</div>
)}
{/* 필드 매핑 */}
{data.fieldMappings && data.fieldMappings.length > 0 && (
<div className="mb-2">
<div className="text-xs font-medium text-foreground"> :</div>
<div className="mt-1 space-y-1">
{data.fieldMappings.slice(0, 3).map((mapping, idx) => (
<div key={idx} className="rounded bg-muted px-2 py-1 text-xs">
<span className="text-muted-foreground">
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
</span>
<span className="mx-1 text-muted-foreground/70"></span>
<span className="font-mono text-foreground">{mapping.targetFieldLabel || mapping.targetField}</span>
</div>
))}
{data.fieldMappings.length > 3 && (
<div className="text-xs text-muted-foreground/70">... {data.fieldMappings.length - 3}</div>
)}
</div>
</div>
)}
{/* 옵션 */}
<div className="flex flex-wrap gap-1">
{data.options?.updateOnConflict && (
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs text-primary"> </span>
)}
{data.options?.batchSize && (
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
: {data.options.batchSize}
</span>
)}
</div>
</div>
{/* 핸들 */}
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-purple-500" />
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-purple-500" />
</div>
<CompactNodeShell
color="#8B5CF6"
label={data.displayName || "UPSERT"}
summary={summary}
icon={<RefreshCw className="h-3.5 w-3.5" />}
selected={selected}
/>
);
});

View File

@ -4,8 +4,6 @@
*
*/
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { TableSourceProperties } from "./properties/TableSourceProperties";
import { InsertActionProperties } from "./properties/InsertActionProperties";
@ -29,70 +27,32 @@ import type { NodeType } from "@/types/node-editor";
export function PropertiesPanel() {
const { nodes, selectedNodes, setShowPropertiesPanel } = useFlowEditorStore();
// 선택된 노드가 하나일 경우 해당 노드 데이터 가져오기
const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null;
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
width: "100%",
overflow: "hidden",
}}
>
{/* 헤더 */}
<div
style={{
flexShrink: 0,
height: "64px",
}}
className="flex items-center justify-between border-b bg-white p-4"
>
<div>
<h3 className="text-sm font-semibold text-foreground"></h3>
{selectedNode && (
<p className="mt-0.5 text-xs text-muted-foreground">{getNodeTypeLabel(selectedNode.type as NodeType)}</p>
)}
if (selectedNodes.length === 0) {
return (
<div className="flex h-full items-center justify-center p-4">
<div className="text-center text-sm text-muted-foreground">
<p> </p>
</div>
<Button variant="ghost" size="sm" onClick={() => setShowPropertiesPanel(false)} className="h-6 w-6 p-0">
<X className="h-4 w-4" />
</Button>
</div>
);
}
{/* 내용 - 스크롤 가능 영역 */}
<div
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
overflowX: "hidden",
}}
>
{selectedNodes.length === 0 ? (
<div className="flex h-full items-center justify-center p-4">
<div className="text-center text-sm text-muted-foreground">
<div className="mb-2 text-2xl">📝</div>
<p> </p>
<p> </p>
</div>
</div>
) : selectedNodes.length === 1 && selectedNode ? (
<NodePropertiesRenderer node={selectedNode} />
) : (
<div className="flex h-full items-center justify-center p-4">
<div className="text-center text-sm text-muted-foreground">
<div className="mb-2 text-2xl">📋</div>
<p>{selectedNodes.length} </p>
<p></p>
<p className="mt-2 text-xs"> </p>
</div>
</div>
)}
if (selectedNodes.length > 1) {
return (
<div className="flex h-full items-center justify-center p-4">
<div className="text-center text-sm text-muted-foreground">
<p>{selectedNodes.length} </p>
<p className="mt-1 text-xs text-muted-foreground"> </p>
</div>
</div>
</div>
);
);
}
if (!selectedNode) return null;
return <NodePropertiesRenderer node={selectedNode} />;
}
/**
@ -155,14 +115,10 @@ function NodePropertiesRenderer({ node }: { node: any }) {
return (
<div className="p-4">
<div className="rounded border border-amber-200 bg-amber-50 p-4 text-sm">
<p className="font-medium text-yellow-800">🚧 </p>
<p className="mt-2 text-xs text-yellow-700">
{getNodeTypeLabel(node.type as NodeType)} UI는 .
<p className="font-medium text-amber-800"> </p>
<p className="mt-2 text-xs text-amber-700">
{getNodeTypeLabel(node.type as NodeType)} .
</p>
<div className="mt-3 rounded bg-white p-2 text-xs">
<p className="font-medium text-foreground"> ID:</p>
<p className="font-mono text-muted-foreground">{node.id}</p>
</div>
</div>
</div>
);

View File

@ -5,16 +5,29 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Save, ListOrdered } from "lucide-react";
import { Plus, Save, Search, Hash, Table2 } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview, computePartDisplayItems, getPartTypeColorClass } from "./NumberingRulePreview";
import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule";
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
import { apiClient } from "@/lib/api/client";
import { cn } from "@/lib/utils";
interface NumberingColumn {
tableName: string;
tableLabel: string;
columnName: string;
columnLabel: string;
}
interface GroupedColumns {
tableLabel: string;
columns: NumberingColumn[];
}
interface NumberingRuleDesignerProps {
initialConfig?: NumberingRuleConfig;
onSave?: (config: NumberingRuleConfig) => void;
@ -36,64 +49,95 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
currentTableName,
menuObjid,
}) => {
const [rulesList, setRulesList] = useState<NumberingRuleConfig[]>([]);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
const [selectedPartOrder, setSelectedPartOrder] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [columnSearch, setColumnSearch] = useState("");
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
const selectedRule = rulesList.find((r) => r.ruleId === selectedRuleId) ?? currentRule;
// 좌측: 규칙 목록 로드
useEffect(() => {
loadRules();
loadNumberingColumns();
}, []);
const loadRules = async () => {
const loadNumberingColumns = async () => {
setLoading(true);
try {
const response = await getNumberingRules();
if (response.success && response.data) {
setRulesList(response.data);
if (response.data.length > 0 && !selectedRuleId) {
const first = response.data[0];
setSelectedRuleId(first.ruleId);
setCurrentRule(JSON.parse(JSON.stringify(first)));
}
const response = await apiClient.get("/table-management/numbering-columns");
if (response.data.success && response.data.data) {
setNumberingColumns(response.data.data);
}
} catch (e) {
console.error("채번 규칙 목록 로드 실패:", e);
} catch (error: any) {
console.error("채번 컬럼 목록 로드 실패:", error);
} finally {
setLoading(false);
}
};
const handleSelectRule = (rule: NumberingRuleConfig) => {
setSelectedRuleId(rule.ruleId);
setCurrentRule(JSON.parse(JSON.stringify(rule)));
const handleSelectColumn = async (tableName: string, columnName: string) => {
setSelectedColumn({ tableName, columnName });
setSelectedPartOrder(null);
setLoading(true);
try {
const response = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
if (response.data.success && response.data.data) {
const rule = response.data.data as NumberingRuleConfig;
setCurrentRule(JSON.parse(JSON.stringify(rule)));
} else {
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: `${columnName} 채번`,
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table",
tableName,
columnName,
};
setCurrentRule(newRule);
}
} catch {
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: `${columnName} 채번`,
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table",
tableName,
columnName,
};
setCurrentRule(newRule);
} finally {
setLoading(false);
}
};
const handleAddNewRule = () => {
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: "새 규칙",
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "global",
tableName: currentTableName ?? "",
columnName: "",
};
setRulesList((prev) => [...prev, newRule]);
setSelectedRuleId(newRule.ruleId);
setCurrentRule(JSON.parse(JSON.stringify(newRule)));
setSelectedPartOrder(null);
toast.success("새 규칙이 추가되었습니다");
};
// 테이블별 그룹화
const groupedColumns = numberingColumns.reduce<Record<string, GroupedColumns>>((acc, col) => {
if (!acc[col.tableName]) {
acc[col.tableName] = { tableLabel: col.tableLabel, columns: [] };
}
acc[col.tableName].columns.push(col);
return acc;
}, {});
// 검색 필터
const filteredGroups = Object.entries(groupedColumns).filter(([tableName, group]) => {
if (!columnSearch) return true;
const search = columnSearch.toLowerCase();
return (
tableName.toLowerCase().includes(search) ||
group.tableLabel.toLowerCase().includes(search) ||
group.columns.some(
(c) => c.columnName.toLowerCase().includes(search) || c.columnLabel.toLowerCase().includes(search)
)
);
});
useEffect(() => {
if (currentRule) onChange?.(currentRule);
@ -225,24 +269,14 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
const ruleToSave = {
...currentRule,
parts: partsWithDefaults,
scopeType: "global" as const,
tableName: currentRule.tableName || currentTableName || "",
columnName: currentRule.columnName || "",
scopeType: "table" as const,
tableName: selectedColumn?.tableName || currentRule.tableName || "",
columnName: selectedColumn?.columnName || currentRule.columnName || "",
};
const response = await saveNumberingRuleToTest(ruleToSave);
if (response.success && response.data) {
const saved: NumberingRuleConfig = JSON.parse(JSON.stringify(response.data));
setCurrentRule(saved);
setRulesList((prev) => {
const idx = prev.findIndex((r) => r.ruleId === currentRule.ruleId);
if (idx >= 0) {
const next = [...prev];
next[idx] = saved;
return next;
}
return [...prev, saved];
});
setSelectedRuleId(saved.ruleId);
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
setCurrentRule(currentData);
await onSave?.(response.data);
toast.success("채번 규칙이 저장되었습니다");
} else {
@ -257,7 +291,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
} finally {
setLoading(false);
}
}, [currentRule, onSave, currentTableName]);
}, [currentRule, onSave, selectedColumn]);
const selectedPart = currentRule?.parts.find((p) => p.order === selectedPartOrder) ?? null;
const globalSep = currentRule?.separator ?? "-";
@ -265,92 +299,118 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
return (
<div className={cn("flex h-full", className)}>
{/* 좌측: 규칙 리스트 (code-nav, 220px) */}
<div className="code-nav flex w-[220px] flex-shrink-0 flex-col border-r border-border">
<div className="code-nav-head flex items-center justify-between gap-2 border-b border-border px-3 py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2">
<ListOrdered className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-xs font-bold"> ({rulesList.length})</span>
{/* 좌측: 채번 컬럼 목록 (테이블별 그룹화) */}
<div className="code-nav flex w-[240px] flex-shrink-0 flex-col border-r border-border">
<div className="code-nav-head flex flex-col gap-2 border-b border-border px-3 py-2.5">
<div className="flex items-center gap-2">
<Hash className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="text-xs font-bold"> ({numberingColumns.length})</span>
</div>
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<Input
value={columnSearch}
onChange={(e) => setColumnSearch(e.target.value)}
placeholder="검색..."
className="h-7 pl-7 text-xs"
/>
</div>
<Button
size="sm"
variant="default"
className="h-8 shrink-0 gap-1 text-xs font-medium"
onClick={handleAddNewRule}
disabled={isPreview || loading}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
<div className="code-nav-list flex-1 overflow-y-auto">
{loading && rulesList.length === 0 ? (
{loading && numberingColumns.length === 0 ? (
<div className="flex h-24 items-center justify-center text-xs text-muted-foreground">
...
</div>
) : rulesList.length === 0 ? (
<div className="flex h-24 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50 text-xs text-muted-foreground">
) : filteredGroups.length === 0 ? (
<div className="flex h-24 flex-col items-center justify-center gap-1 px-3 text-center text-xs text-muted-foreground">
<Hash className="h-6 w-6" />
{numberingColumns.length === 0
? "채번 타입 컬럼이 없습니다"
: "검색 결과 없음"}
</div>
) : (
rulesList.map((rule) => {
const isSelected = selectedRuleId === rule.ruleId;
return (
<button
key={rule.ruleId}
type="button"
className={cn(
"code-nav-item flex w-full items-center gap-2 border-b border-border/50 px-3 py-2 text-left transition-colors",
isSelected
? "border-l-[3px] border-primary bg-primary/5 pl-2.5 font-bold"
: "hover:bg-accent"
)}
onClick={() => handleSelectRule(rule)}
>
<span className="rule-name min-w-0 flex-1 truncate text-xs font-semibold">
{rule.ruleName}
filteredGroups.map(([tableName, group]) => (
<div key={tableName}>
<div className="flex items-center gap-1.5 bg-muted/50 px-3 py-1.5">
<Table2 className="h-3 w-3 text-muted-foreground" />
<span className="truncate text-[10px] font-bold text-muted-foreground">
{group.tableLabel || tableName}
</span>
<span className="rule-table max-w-[70px] shrink-0 truncate text-[9px] text-muted-foreground">
{rule.tableName || "-"}
</span>
<span className="rule-parts shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[8px] font-bold text-muted-foreground">
{rule.parts?.length ?? 0}
</span>
</button>
);
})
</div>
{group.columns.map((col) => {
const isSelected =
selectedColumn?.tableName === col.tableName &&
selectedColumn?.columnName === col.columnName;
return (
<button
key={`${col.tableName}.${col.columnName}`}
type="button"
className={cn(
"flex w-full items-center gap-2 border-b border-border/30 px-3 py-2 text-left transition-colors",
isSelected
? "border-l-[3px] border-l-primary bg-primary/5 pl-2.5 font-bold"
: "pl-5 hover:bg-accent"
)}
onClick={() => handleSelectColumn(col.tableName, col.columnName)}
>
<Hash className="h-3 w-3 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold">
{col.columnLabel || col.columnName}
</div>
<div className="truncate text-[9px] text-muted-foreground">
{col.columnName}
</div>
</div>
</button>
);
})}
</div>
))
)}
</div>
</div>
{/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 (code-main) */}
{/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 */}
<div className="code-main flex min-w-0 flex-1 flex-col overflow-hidden">
{!currentRule ? (
<div className="flex flex-1 flex-col items-center justify-center text-center">
<ListOrdered className="mb-3 h-10 w-10 text-muted-foreground" />
<p className="mb-2 text-lg font-medium text-muted-foreground"> </p>
<Hash className="mb-3 h-10 w-10 text-muted-foreground" />
<p className="mb-2 text-lg font-medium text-muted-foreground"> </p>
<p className="text-sm text-muted-foreground">
&quot;&quot;
</p>
</div>
) : (
<>
{/* 헤더: 규칙명 + 적용 대상 표시 */}
<div className="flex flex-col gap-2 px-6 pt-4">
<Label className="text-xs font-medium"></Label>
<Input
value={currentRule.ruleName}
onChange={(e) => setCurrentRule((prev) => (prev ? { ...prev, ruleName: e.target.value } : null))}
placeholder="예: 프로젝트 코드"
className="h-9 text-sm"
/>
<div className="flex items-center gap-3">
<div className="flex-1">
<Label className="text-xs font-medium"></Label>
<Input
value={currentRule.ruleName}
onChange={(e) => setCurrentRule((prev) => (prev ? { ...prev, ruleName: e.target.value } : null))}
placeholder="예: 프로젝트 코드"
className="h-9 text-sm"
/>
</div>
{selectedColumn && (
<div className="flex-shrink-0 pt-4">
<span className="rounded bg-muted px-2 py-1 text-[10px] font-medium text-muted-foreground">
{selectedColumn.tableName}.{selectedColumn.columnName}
</span>
</div>
)}
</div>
</div>
{/* 큰 미리보기 스트립 (code-preview-strip) */}
{/* 미리보기 스트립 */}
<div className="code-preview-strip flex-shrink-0 border-b border-border px-6 py-5">
<NumberingRulePreview config={currentRule} variant="strip" />
</div>
{/* 파이프라인 영역 (code-pipeline-area) */}
{/* 파이프라인 영역 */}
<div className="code-pipeline-area flex flex-col gap-3 border-b border-border px-6 py-5">
<div className="area-label flex items-center gap-1.5">
<span className="text-xs font-bold"> </span>
@ -360,15 +420,21 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
</div>
<div className="code-pipeline flex flex-1 flex-wrap items-center gap-0 overflow-x-auto overflow-y-hidden pb-2">
{currentRule.parts.length === 0 ? (
<div className="flex h-24 min-w-[200px] items-center justify-center rounded-xl border-2 border-dashed border-border bg-muted/30 text-xs text-muted-foreground">
</div>
<button
type="button"
className="flex h-24 min-w-[200px] flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border bg-muted/30 text-xs text-muted-foreground transition-colors hover:border-primary hover:bg-primary/5 hover:text-primary"
onClick={handleAddPart}
disabled={isPreview || loading}
>
<Plus className="h-6 w-6" />
</button>
) : (
<>
{currentRule.parts.map((part, index) => {
const item = partItems.find((i) => i.order === part.order);
const sep = part.separatorAfter ?? globalSep;
const isSelected = selectedPartOrder === part.order;
const isPartSelected = selectedPartOrder === part.order;
const typeLabel = CODE_PART_TYPE_OPTIONS.find((o) => o.value === part.partType)?.label ?? part.partType;
return (
<React.Fragment key={`part-${part.order}-${index}`}>
@ -380,7 +446,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
part.partType === "text" && "border-primary",
part.partType === "sequence" && "border-primary",
(part.partType === "number" || part.partType === "category" || part.partType === "reference") && "border-border",
isSelected && "border-primary bg-primary/5 shadow-md ring-2 ring-primary/30"
isPartSelected && "border-primary bg-primary/5 shadow-md ring-2 ring-primary/30"
)}
onClick={() => setSelectedPartOrder(part.order)}
>
@ -416,7 +482,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
</div>
</div>
{/* 설정 패널 (선택된 세그먼트 상세, code-config-panel) */}
{/* 설정 패널 */}
{selectedPart && (
<div className="code-config-panel min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="code-config-grid grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
@ -460,7 +526,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
</div>
)}
{/* 저장 바 (code-save-bar) */}
{/* 저장 바 */}
<div className="code-save-bar flex flex-shrink-0 items-center justify-between gap-4 border-t border-border bg-muted/30 px-6 py-4">
<div className="min-w-0 flex-1 text-xs text-muted-foreground">
{currentRule.tableName && (

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";
export type BatchExecutionType = "mapping" | "node_flow";
export interface BatchConfig {
id?: number;
batch_name: string;
@ -10,14 +12,55 @@ export interface BatchConfig {
cron_schedule: string;
is_active?: string;
company_code?: string;
save_mode?: 'INSERT' | 'UPSERT'; // 저장 모드 (기본: INSERT)
conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명
auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명
save_mode?: 'INSERT' | 'UPSERT';
conflict_key?: string;
auth_service_name?: string;
execution_type?: BatchExecutionType;
node_flow_id?: number;
node_flow_context?: Record<string, any>;
created_date?: Date;
created_by?: string;
updated_date?: Date;
updated_by?: string;
batch_mappings?: BatchMapping[];
last_status?: string;
last_executed_at?: string;
last_total_records?: number;
}
export interface NodeFlowInfo {
flow_id: number;
flow_name: string;
description?: string;
company_code?: string;
node_count: number;
}
export interface BatchStats {
totalBatches: number;
activeBatches: number;
todayExecutions: number;
todayFailures: number;
prevDayExecutions: number;
prevDayFailures: number;
}
export interface SparklineData {
hour: string;
success: number;
failed: number;
}
export interface RecentLog {
id: number;
started_at: string;
finished_at: string | null;
status: string;
total_records: number;
success_records: number;
failed_records: number;
error_message: string | null;
duration_ms: number | null;
}
export interface BatchMapping {
@ -48,6 +91,8 @@ export interface BatchConfigFilter {
is_active?: string;
company_code?: string;
search?: string;
page?: number;
limit?: number;
}
export interface BatchJob {
@ -95,6 +140,9 @@ export interface BatchMappingRequest {
cronSchedule: string;
mappings: BatchMapping[];
isActive?: boolean;
executionType?: BatchExecutionType;
nodeFlowId?: number;
nodeFlowContext?: Record<string, any>;
}
export interface ApiResponse<T> {
@ -190,7 +238,7 @@ export class BatchAPI {
static async createBatchConfig(data: BatchMappingRequest): Promise<BatchConfig> {
try {
const response = await apiClient.post<ApiResponse<BatchConfig>>(
`/batch-configs`,
`/batch-management/batch-configs`,
data,
);
@ -460,7 +508,76 @@ export class BatchAPI {
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",
index % 2 === 0 ? "bg-background" : "bg-muted/20",
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",
isDragging && "bg-muted opacity-50",
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]",
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)]",
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",
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",
column.editable === false && "bg-muted/10 dark:bg-muted/10",
// 코드 컬럼: mono 폰트 + primary 색상