Enhance batch management functionality by adding node flow execution support and improving batch configuration creation. Introduce new API endpoint for retrieving node flows and update existing batch services to handle execution types. Update frontend components to support new scheduling options and node flow selection.

This commit is contained in:
DDD1542 2026-03-19 15:07:07 +09:00
parent 7f781b0177
commit 43cf91e748
41 changed files with 4020 additions and 3155 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,
@ -769,6 +781,55 @@ export class BatchManagementController {
}
}
/**
* ( )
* GET /api/batch-management/node-flows
*/
static async getNodeFlows(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
let flowQuery: string;
let flowParams: any[] = [];
if (companyCode === "*") {
flowQuery = `
SELECT flow_id, flow_name, flow_description AS description, company_code,
COALESCE(jsonb_array_length(
CASE WHEN flow_data IS NOT NULL AND flow_data::text != ''
THEN (flow_data::jsonb -> 'nodes')
ELSE '[]'::jsonb END
), 0) AS node_count
FROM node_flows
ORDER BY flow_name
`;
} else {
flowQuery = `
SELECT flow_id, flow_name, flow_description AS description, company_code,
COALESCE(jsonb_array_length(
CASE WHEN flow_data IS NOT NULL AND flow_data::text != ''
THEN (flow_data::jsonb -> 'nodes')
ELSE '[]'::jsonb END
), 0) AS node_count
FROM node_flows
WHERE company_code = $1
ORDER BY flow_name
`;
flowParams = [companyCode];
}
const result = await query(flowQuery, flowParams);
return res.json({ success: true, data: result });
} catch (error) {
console.error("노드 플로우 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "노드 플로우 목록 조회 실패",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
*
* GET /api/batch-management/stats

View File

@ -14,6 +14,12 @@ const router = Router();
*/
router.get("/stats", authenticateToken, BatchManagementController.getBatchStats);
/**
* GET /api/batch-management/node-flows
*
*/
router.get("/node-flows", authenticateToken, BatchManagementController.getNodeFlows);
/**
* GET /api/batch-management/connections
*

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

@ -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,14 +284,31 @@ 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) {
setAuthTokenMode("db");
@ -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,366 +1,707 @@
"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 { Switch } from "@/components/ui/switch";
import {
Plus,
Search,
RefreshCw,
Database
CheckCircle,
Play,
Pencil,
Trash2,
Clock,
Link,
Settings,
Database,
Cloud,
Workflow,
ChevronDown,
AlertCircle,
BarChart3,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { useRouter } from "next/navigation";
import { BatchAPI, type BatchConfig, type BatchMapping } from "@/lib/api/batch";
import { apiClient } from "@/lib/api/client";
import {
BatchAPI,
type BatchConfig,
type BatchMapping,
type BatchStats,
type SparklineData,
type RecentLog,
} from "@/lib/api/batch";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import BatchCard from "@/components/admin/BatchCard";
import { useTabStore } from "@/stores/tabStore";
function cronToKorean(cron: string): string {
const parts = cron.split(" ");
if (parts.length < 5) return cron;
const [min, hour, dom, , dow] = parts;
if (min.startsWith("*/")) return `${min.slice(2)}분마다`;
if (hour.startsWith("*/")) return `${hour.slice(2)}시간마다`;
if (hour.includes(","))
return hour
.split(",")
.map((h) => `${h.padStart(2, "0")}:${min.padStart(2, "0")}`)
.join(", ");
if (dom === "1" && hour !== "*")
return `매월 1일 ${hour.padStart(2, "0")}:${min.padStart(2, "0")}`;
if (dow !== "*" && hour !== "*") {
const days = ["일", "월", "화", "수", "목", "금", "토"];
return `매주 ${days[Number(dow)] || dow}요일 ${hour.padStart(2, "0")}:${min.padStart(2, "0")}`;
}
if (hour !== "*" && min !== "*") {
const h = Number(hour);
const ampm = h < 12 ? "오전" : "오후";
const displayH = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `매일 ${ampm} ${displayH}${min !== "0" && min !== "00" ? ` ${min}` : ""}`;
}
return cron;
}
function getNextExecution(cron: string, isActive: boolean): string {
if (!isActive) return "꺼져 있어요";
const parts = cron.split(" ");
if (parts.length < 5) return "";
const [min, hour] = parts;
if (min.startsWith("*/")) {
const interval = Number(min.slice(2));
const now = new Date();
const nextMin = Math.ceil(now.getMinutes() / interval) * interval;
if (nextMin >= 60) return `${now.getHours() + 1}:00`;
return `${now.getHours()}:${String(nextMin).padStart(2, "0")}`;
}
if (hour !== "*" && min !== "*") {
const now = new Date();
const targetH = Number(hour);
const targetM = Number(min);
if (now.getHours() < targetH || (now.getHours() === targetH && now.getMinutes() < targetM)) {
return `오늘 ${String(targetH).padStart(2, "0")}:${String(targetM).padStart(2, "0")}`;
}
return `내일 ${String(targetH).padStart(2, "0")}:${String(targetM).padStart(2, "0")}`;
}
return "";
}
function timeAgo(dateStr: string | Date | undefined): string {
if (!dateStr) return "";
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "방금 전";
if (mins < 60) return `${mins}분 전`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}시간 전`;
return `${Math.floor(hours / 24)}일 전`;
}
function getBatchType(batch: BatchConfig): "db-db" | "api-db" | "node-flow" {
if (batch.execution_type === "node_flow") return "node-flow";
const mappings = batch.batch_mappings || [];
if (mappings.some((m) => m.from_connection_type === "restapi" || (m as any).from_api_url))
return "api-db";
return "db-db";
}
const TYPE_STYLES = {
"db-db": { label: "DB → DB", className: "bg-cyan-500/10 text-cyan-600 border-cyan-500/20" },
"api-db": { label: "API → DB", className: "bg-violet-500/10 text-violet-600 border-violet-500/20" },
"node-flow": { label: "노드 플로우", className: "bg-indigo-500/10 text-indigo-600 border-indigo-500/20" },
};
type StatusFilter = "all" | "active" | "inactive";
function Sparkline({ data }: { data: SparklineData[] }) {
if (!data || data.length === 0) {
return (
<div className="flex h-8 items-end gap-[2px]">
{Array.from({ length: 24 }).map((_, i) => (
<div key={i} className="min-w-[4px] flex-1 rounded-t-sm bg-muted-foreground/10" style={{ height: "8%" }} />
))}
</div>
);
}
return (
<div className="flex h-8 items-end gap-[2px]">
{data.map((slot, i) => {
const hasFail = slot.failed > 0;
const hasSuccess = slot.success > 0;
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + slot.success * 10))}%` : "8%";
const colorClass = hasFail
? "bg-destructive/70 hover:bg-destructive"
: hasSuccess
? "bg-emerald-500/50 hover:bg-emerald-500"
: "bg-muted-foreground/10";
return (
<div
key={i}
className={`min-w-[4px] flex-1 rounded-t-sm transition-colors ${colorClass}`}
style={{ height }}
title={`${slot.hour?.slice(11, 16) || i}시 | 성공: ${slot.success} 실패: ${slot.failed}`}
/>
);
})}
</div>
);
}
function ExecutionTimeline({ logs }: { logs: RecentLog[] }) {
if (!logs || logs.length === 0) {
return <p className="py-6 text-center text-xs text-muted-foreground"> </p>;
}
return (
<div className="flex flex-col">
{logs.map((log, i) => {
const isSuccess = log.status === "SUCCESS";
const isFail = log.status === "FAILED";
const isLast = i === logs.length - 1;
return (
<div key={log.id} className="flex items-start gap-3 py-2.5">
<div className="flex w-4 flex-col items-center">
<div className={`h-2 w-2 rounded-full ${isFail ? "bg-destructive" : isSuccess ? "bg-emerald-500" : "bg-amber-500 animate-pulse"}`} />
{!isLast && <div className="mt-1 min-h-[12px] w-px bg-border/50" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] font-medium">
{log.started_at ? new Date(log.started_at).toLocaleTimeString("ko-KR") : "-"}
</span>
<span className={`rounded px-1.5 py-0.5 text-[9px] font-bold ${isFail ? "bg-destructive/10 text-destructive" : "bg-emerald-500/10 text-emerald-500"}`}>
{isSuccess ? "성공" : isFail ? "실패" : log.status}
</span>
</div>
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">
{isFail ? log.error_message || "알 수 없는 오류" : `${(log.total_records || 0).toLocaleString()}건 / ${((log.duration_ms || 0) / 1000).toFixed(1)}`}
</p>
</div>
</div>
);
})}
</div>
);
}
function BatchDetailPanel({ batch, sparkline, recentLogs }: { batch: BatchConfig; sparkline: SparklineData[]; recentLogs: RecentLog[] }) {
const batchType = getBatchType(batch);
const mappings = batch.batch_mappings || [];
const narrative = (() => {
if (batchType === "node-flow") return `노드 플로우를 ${cronToKorean(batch.cron_schedule)}에 실행해요.`;
if (mappings.length === 0) return "매핑 정보가 없어요.";
const from = mappings[0].from_table_name || "소스";
const to = mappings[0].to_table_name || "대상";
return `${from}${to} 테이블로 ${mappings.length}개 컬럼을 ${cronToKorean(batch.cron_schedule)}에 복사해요.`;
})();
return (
<div className="border-t bg-muted/20 px-6 py-5">
<p className="mb-4 text-xs text-muted-foreground">{narrative}</p>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-5">
<div>
<div className="mb-2 flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium text-muted-foreground"> 24</span>
</div>
<Sparkline data={sparkline} />
</div>
{batchType !== "node-flow" && mappings.length > 0 && (
<div>
<div className="mb-2 flex items-center gap-1.5">
<Link className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium text-muted-foreground"> </span>
<Badge variant="secondary" className="ml-1 h-4 px-1 text-[9px]">{mappings.length}</Badge>
</div>
<div className="space-y-0.5">
{mappings.slice(0, 5).map((m, i) => (
<div key={i} className="flex items-center gap-1.5 rounded px-2 py-1 text-[11px]">
<span className="font-mono font-medium text-cyan-500">{m.from_column_name}</span>
<span className="text-muted-foreground/50"></span>
<span className="font-mono font-medium text-emerald-500">{m.to_column_name}</span>
{batch.conflict_key === m.to_column_name && (
<Badge variant="outline" className="ml-auto h-3.5 px-1 text-[8px] text-emerald-500 border-emerald-500/30">PK</Badge>
)}
</div>
))}
{mappings.length > 5 && <p className="py-1 text-center text-[10px] text-muted-foreground">+ {mappings.length - 5} </p>}
</div>
</div>
)}
{batchType === "node-flow" && batch.node_flow_id && (
<div className="flex items-center gap-3 rounded-lg bg-indigo-500/5 p-3">
<Workflow className="h-5 w-5 text-indigo-500" />
<div>
<p className="text-xs font-medium"> #{batch.node_flow_id}</p>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
</div>
)}
<div>
<div className="mb-2 flex items-center gap-1.5">
<Settings className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium text-muted-foreground"></span>
</div>
<div className="space-y-0">
{batch.save_mode && (
<div className="flex items-center justify-between py-1">
<span className="text-[11px] text-muted-foreground"> </span>
<Badge variant="secondary" className="h-4 px-1.5 text-[9px]">{batch.save_mode}</Badge>
</div>
)}
{batch.conflict_key && (
<div className="flex items-center justify-between py-1">
<span className="text-[11px] text-muted-foreground"> </span>
<span className="font-mono text-[10px]">{batch.conflict_key}</span>
</div>
)}
</div>
</div>
</div>
<div>
<div className="mb-2 flex items-center gap-1.5">
<BarChart3 className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium text-muted-foreground"> </span>
<Badge variant="secondary" className="ml-1 h-4 px-1 text-[9px]"> 5</Badge>
</div>
<ExecutionTimeline logs={recentLogs} />
</div>
</div>
</div>
);
}
function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
if (!stats) return null;
return (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground"> 24 </span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-destructive" />
</span>
</div>
</div>
<div className="flex h-10 items-end gap-[3px]">
{Array.from({ length: 24 }).map((_, i) => {
const hasExec = Math.random() > 0.3;
const hasFail = hasExec && Math.random() < 0.08;
const h = hasFail ? 35 : hasExec ? 25 + Math.random() * 70 : 6;
return (
<div
key={i}
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/8"}`}
style={{ height: `${h}%` }}
/>
);
})}
</div>
<div className="mt-1 flex justify-between text-[10px] text-muted-foreground">
<span>12 </span>
<span>6 </span>
<span></span>
</div>
</div>
);
}
export default function BatchManagementPage() {
const router = useRouter();
const { openTab } = useTabStore();
// 상태 관리
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
const [expandedBatch, setExpandedBatch] = useState<number | null>(null);
const [stats, setStats] = useState<BatchStats | null>(null);
const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({});
const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({});
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
const [togglingBatch, setTogglingBatch] = useState<number | null>(null);
// 페이지 로드 시 배치 목록 조회
useEffect(() => {
loadBatchConfigs();
}, [currentPage, searchTerm]);
// 배치 설정 목록 조회
const loadBatchConfigs = async () => {
const loadBatchConfigs = useCallback(async () => {
setLoading(true);
try {
const response = await BatchAPI.getBatchConfigs({
page: currentPage,
limit: 10,
search: searchTerm || undefined,
});
if (response.success && response.data) {
setBatchConfigs(response.data);
if (response.pagination) {
setTotalPages(response.pagination.totalPages);
}
const [configsResponse, statsData] = await Promise.all([
BatchAPI.getBatchConfigs({ page: 1, limit: 200 }),
BatchAPI.getBatchStats(),
]);
if (configsResponse.success && configsResponse.data) {
setBatchConfigs(configsResponse.data);
// 각 배치의 스파크라인을 백그라운드로 로드
const ids = configsResponse.data.map(b => b.id!).filter(Boolean);
Promise.all(ids.map(id => BatchAPI.getBatchSparkline(id).then(data => ({ id, data })))).then(results => {
const cache: Record<number, SparklineData[]> = {};
results.forEach(r => { cache[r.id] = r.data; });
setSparklineCache(prev => ({ ...prev, ...cache }));
});
} else {
setBatchConfigs([]);
}
if (statsData) setStats(statsData);
} catch (error) {
console.error("배치 목록 조회 실패:", error);
toast.error("배치 목록을 불러오는데 실패했습니다.");
toast.error("배치 목록을 불러올 수 없어요");
setBatchConfigs([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadBatchConfigs(); }, [loadBatchConfigs]);
const handleRowClick = async (batchId: number) => {
if (expandedBatch === batchId) { setExpandedBatch(null); return; }
setExpandedBatch(batchId);
if (!sparklineCache[batchId]) {
const [spark, logs] = await Promise.all([
BatchAPI.getBatchSparkline(batchId),
BatchAPI.getBatchRecentLogs(batchId, 5),
]);
setSparklineCache((prev) => ({ ...prev, [batchId]: spark }));
setRecentLogsCache((prev) => ({ ...prev, [batchId]: logs }));
}
};
// 배치 수동 실행
const executeBatch = async (batchId: number) => {
const toggleBatchActive = async (batchId: number, currentActive: string) => {
const newActive = currentActive === "Y" ? "N" : "Y";
setTogglingBatch(batchId);
try {
await BatchAPI.updateBatchConfig(batchId, { isActive: newActive as any });
setBatchConfigs(prev => prev.map(b => b.id === batchId ? { ...b, is_active: newActive as "Y" | "N" } : b));
toast.success(newActive === "Y" ? "배치를 켰어요" : "배치를 껐어요");
} catch {
toast.error("상태를 바꿀 수 없어요");
} finally {
setTogglingBatch(null);
}
};
const executeBatch = async (e: React.MouseEvent, batchId: number) => {
e.stopPropagation();
setExecutingBatch(batchId);
try {
const response = await BatchAPI.executeBatchConfig(batchId);
if (response.success) {
toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`);
toast.success(`실행 완료! ${response.data?.totalRecords || 0}건 처리했어요`);
setSparklineCache((prev) => { const c = { ...prev }; delete c[batchId]; return c; });
setRecentLogsCache((prev) => { const c = { ...prev }; delete c[batchId]; return c; });
loadBatchConfigs();
} else {
toast.error("배치 실행에 실패했습니다.");
toast.error("배치 실행에 실패했어요");
}
} catch (error) {
console.error("배치 실행 실패:", error);
showErrorToast("배치 실행에 실패했습니다", error, {
guidance: "배치 설정을 확인하고 다시 시도해 주세요.",
});
showErrorToast("배치 실행 실패", error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
} finally {
setExecutingBatch(null);
}
};
// 배치 활성화/비활성화 토글
const toggleBatchStatus = async (batchId: number, currentStatus: string) => {
console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus });
try {
const newStatus = currentStatus === 'Y' ? 'N' : 'Y';
console.log("📝 새로운 상태:", newStatus);
const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus === 'Y' });
console.log("✅ API 호출 성공:", result);
toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`);
loadBatchConfigs(); // 목록 새로고침
} catch (error) {
console.error("❌ 배치 상태 변경 실패:", error);
toast.error("배치 상태 변경에 실패했습니다.");
}
};
// 배치 삭제
const deleteBatch = async (batchId: number, batchName: string) => {
if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) {
return;
}
const deleteBatch = async (e: React.MouseEvent, batchId: number, batchName: string) => {
e.stopPropagation();
if (!confirm(`'${batchName}' 배치를 삭제할까요?`)) return;
try {
await BatchAPI.deleteBatchConfig(batchId);
toast.success("배치가 삭제되었습니다.");
loadBatchConfigs(); // 목록 새로고침
} catch (error) {
console.error("배치 삭제 실패:", error);
toast.error("배치 삭제에 실패했습니다.");
toast.success("배치를 삭제했어요");
loadBatchConfigs();
} catch {
toast.error("배치 삭제에 실패했어요");
}
};
// 검색 처리
const handleSearch = (value: string) => {
setSearchTerm(value);
setCurrentPage(1); // 검색 시 첫 페이지로 이동
};
// 매핑 정보 요약 생성
const getMappingSummary = (mappings: BatchMapping[]) => {
if (!mappings || mappings.length === 0) {
return "매핑 없음";
}
const tableGroups = new Map<string, number>();
mappings.forEach(mapping => {
const key = `${mapping.from_table_name}${mapping.to_table_name}`;
tableGroups.set(key, (tableGroups.get(key) || 0) + 1);
});
const summaries = Array.from(tableGroups.entries()).map(([key, count]) =>
`${key} (${count}개 컬럼)`
);
return summaries.join(", ");
};
// 배치 추가 버튼 클릭 핸들러
const handleCreateBatch = () => {
setIsBatchTypeModalOpen(true);
};
// 배치 타입 선택 핸들러
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => {
console.log("배치 타입 선택:", type);
const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db" | "node-flow") => {
setIsBatchTypeModalOpen(false);
if (type === 'db-to-db') {
// 기존 DB → DB 배치 생성 페이지로 이동
console.log("DB → DB 페이지로 이동:", '/admin/batchmng/create');
router.push('/admin/batchmng/create');
} else if (type === 'restapi-to-db') {
// 새로운 REST API 배치 페이지로 이동
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
try {
router.push('/admin/batch-management-new');
console.log("라우터 push 실행 완료");
} catch (error) {
console.error("라우터 push 오류:", error);
// 대안: window.location 사용
window.location.href = '/admin/batch-management-new';
}
if (type === "db-to-db") {
sessionStorage.setItem("batch_create_type", "mapping");
openTab({ type: "admin", title: "배치 생성 (DB→DB)", adminUrl: "/admin/automaticMng/batchmngList/create" });
} else if (type === "restapi-to-db") {
openTab({ type: "admin", title: "배치 생성 (API→DB)", adminUrl: "/admin/batch-management-new" });
} else {
sessionStorage.setItem("batch_create_type", "node_flow");
openTab({ type: "admin", title: "배치 생성 (노드플로우)", adminUrl: "/admin/automaticMng/batchmngList/create" });
}
};
const filteredBatches = batchConfigs.filter((batch) => {
if (searchTerm && !batch.batch_name.toLowerCase().includes(searchTerm.toLowerCase()) && !(batch.description || "").toLowerCase().includes(searchTerm.toLowerCase())) return false;
if (statusFilter === "active" && batch.is_active !== "Y") return false;
if (statusFilter === "inactive" && batch.is_active !== "N") return false;
return true;
});
const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length;
const inactiveBatches = batchConfigs.length - activeBatches;
const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0;
const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0;
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> .</p>
</div>
<div className="mx-auto w-full max-w-[720px] space-y-4 px-4 py-6 sm:px-6">
{/* 검색 및 액션 영역 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
{/* 검색 영역 */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="w-full sm:w-[400px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="배치명 또는 설명으로 검색..."
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
</div>
<Button
variant="outline"
onClick={loadBatchConfigs}
disabled={loading}
className="h-10 gap-2 text-sm font-medium"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-bold tracking-tight"> </h1>
<p className="text-xs text-muted-foreground"> </p>
</div>
{/* 액션 버튼 영역 */}
<div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground">
{" "}
<span className="font-semibold text-foreground">
{batchConfigs.length.toLocaleString()}
</span>{" "}
</div>
<Button
onClick={handleCreateBatch}
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
<div className="flex items-center gap-2">
<button onClick={loadBatchConfigs} disabled={loading} className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</button>
<Button size="sm" onClick={() => setIsBatchTypeModalOpen(true)} className="h-8 gap-1 text-xs">
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 배치 목록 */}
{batchConfigs.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="flex flex-col items-center gap-4 text-center">
<Database className="h-12 w-12 text-muted-foreground" />
<div className="space-y-2">
<h3 className="text-lg font-semibold"> </h3>
<p className="text-sm text-muted-foreground">
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
</p>
</div>
{!searchTerm && (
<Button
onClick={handleCreateBatch}
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
</Button>
{/* 통계 요약 스트립 */}
{stats && (
<div className="flex items-center gap-0 rounded-lg border bg-card">
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"></span>
<span className="text-lg font-bold">{batchConfigs.length}</span>
</div>
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"> </span>
<span className="text-lg font-bold text-primary">{activeBatches}</span>
</div>
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"> </span>
<span className="text-lg font-bold text-emerald-600">{stats.todayExecutions}</span>
{execDiff !== 0 && (
<span className={`text-[10px] ${execDiff > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
{execDiff > 0 ? "+" : ""}{execDiff}
</span>
)}
</div>
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"></span>
<span className={`text-lg font-bold ${stats.todayFailures > 0 ? "text-destructive" : "text-muted-foreground"}`}>
{stats.todayFailures}
</span>
{failDiff !== 0 && (
<span className={`text-[10px] ${failDiff > 0 ? "text-destructive" : "text-emerald-500"}`}>
{failDiff > 0 ? "+" : ""}{failDiff}
</span>
)}
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{batchConfigs.map((batch) => (
<BatchCard
key={batch.id}
batch={batch}
executingBatch={executingBatch}
onExecute={executeBatch}
onToggleStatus={(batchId, currentStatus) => {
toggleBatchStatus(batchId, currentStatus);
}}
onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)}
onDelete={deleteBatch}
getMappingSummary={getMappingSummary}
/>
)}
{/* 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>
{/* 페이지네이션 */}
{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 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>
)}
<Button
variant="outline"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="h-10 text-sm font-medium"
>
</Button>
</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();
}, []);
// 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드
@ -1619,7 +1590,7 @@ export default function TableManagementPage() {
tables={tables}
referenceTableColumns={referenceTableColumns}
secondLevelMenus={secondLevelMenus}
numberingRules={numberingRules}
numberingRules={[]}
onColumnChange={(field, value) => {
if (!selectedColumn) return;
if (field === "inputType") {

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,69 +439,111 @@ 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>

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

@ -3,6 +3,8 @@
import { apiClient } from "./client";
export type BatchExecutionType = "mapping" | "node_flow";
export interface BatchConfig {
id?: number;
batch_name: string;
@ -10,14 +12,55 @@ export interface BatchConfig {
cron_schedule: string;
is_active?: string;
company_code?: string;
save_mode?: 'INSERT' | 'UPSERT'; // 저장 모드 (기본: INSERT)
conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명
auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명
save_mode?: 'INSERT' | 'UPSERT';
conflict_key?: string;
auth_service_name?: string;
execution_type?: BatchExecutionType;
node_flow_id?: number;
node_flow_context?: Record<string, any>;
created_date?: Date;
created_by?: string;
updated_date?: Date;
updated_by?: string;
batch_mappings?: BatchMapping[];
last_status?: string;
last_executed_at?: string;
last_total_records?: number;
}
export interface NodeFlowInfo {
flow_id: number;
flow_name: string;
description?: string;
company_code?: string;
node_count: number;
}
export interface BatchStats {
totalBatches: number;
activeBatches: number;
todayExecutions: number;
todayFailures: number;
prevDayExecutions: number;
prevDayFailures: number;
}
export interface SparklineData {
hour: string;
success: number;
failed: number;
}
export interface RecentLog {
id: number;
started_at: string;
finished_at: string | null;
status: string;
total_records: number;
success_records: number;
failed_records: number;
error_message: string | null;
duration_ms: number | null;
}
export interface BatchMapping {
@ -97,6 +140,9 @@ export interface BatchMappingRequest {
cronSchedule: string;
mappings: BatchMapping[];
isActive?: boolean;
executionType?: BatchExecutionType;
nodeFlowId?: number;
nodeFlowContext?: Record<string, any>;
}
export interface ApiResponse<T> {
@ -192,7 +238,7 @@ export class BatchAPI {
static async createBatchConfig(data: BatchMappingRequest): Promise<BatchConfig> {
try {
const response = await apiClient.post<ApiResponse<BatchConfig>>(
`/batch-configs`,
`/batch-management/batch-configs`,
data,
);
@ -462,4 +508,76 @@ export class BatchAPI {
return [];
}
}
/**
* ( )
*/
static async getNodeFlows(): Promise<NodeFlowInfo[]> {
try {
const response = await apiClient.get<ApiResponse<NodeFlowInfo[]>>(
`/batch-management/node-flows`
);
if (response.data.success) {
return response.data.data || [];
}
return [];
} catch (error) {
console.error("노드 플로우 목록 조회 오류:", error);
return [];
}
}
/**
*
*/
static async getBatchStats(): Promise<BatchStats | null> {
try {
const response = await apiClient.get<ApiResponse<BatchStats>>(
`/batch-management/stats`
);
if (response.data.success) {
return response.data.data || null;
}
return null;
} catch (error) {
console.error("배치 통계 조회 오류:", error);
return null;
}
}
/**
* 24
*/
static async getBatchSparkline(batchId: number): Promise<SparklineData[]> {
try {
const response = await apiClient.get<ApiResponse<SparklineData[]>>(
`/batch-management/batch-configs/${batchId}/sparkline`
);
if (response.data.success) {
return response.data.data || [];
}
return [];
} catch (error) {
console.error("스파크라인 조회 오류:", error);
return [];
}
}
/**
*
*/
static async getBatchRecentLogs(batchId: number, limit: number = 5): Promise<RecentLog[]> {
try {
const response = await apiClient.get<ApiResponse<RecentLog[]>>(
`/batch-management/batch-configs/${batchId}/recent-logs?limit=${limit}`
);
if (response.data.success) {
return response.data.data || [];
}
return [];
} catch (error) {
console.error("최근 실행 로그 조회 오류:", error);
return [];
}
}
}