1532 lines
61 KiB
TypeScript
1532 lines
61 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useMemo, memo } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Trash2, Plus, ArrowRight, Save, RefreshCw, Globe, Database, Eye } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { BatchManagementAPI } from "@/lib/api/batchManagement";
|
|
|
|
// 타입 정의
|
|
type BatchType = 'db-to-restapi' | 'restapi-to-db' | 'restapi-to-restapi';
|
|
|
|
interface BatchTypeOption {
|
|
value: BatchType;
|
|
label: string;
|
|
description: string;
|
|
}
|
|
|
|
interface BatchConnectionInfo {
|
|
id: number;
|
|
name: string;
|
|
type: string;
|
|
}
|
|
|
|
interface BatchColumnInfo {
|
|
column_name: string;
|
|
data_type: string;
|
|
is_nullable: string;
|
|
}
|
|
|
|
interface RestApiToDbMappingCardProps {
|
|
fromApiFields: string[];
|
|
toColumns: BatchColumnInfo[];
|
|
fromApiData: any[];
|
|
apiFieldMappings: Record<string, string>;
|
|
setApiFieldMappings: React.Dispatch<
|
|
React.SetStateAction<Record<string, string>>
|
|
>;
|
|
apiFieldPathOverrides: Record<string, string>;
|
|
setApiFieldPathOverrides: React.Dispatch<
|
|
React.SetStateAction<Record<string, string>>
|
|
>;
|
|
}
|
|
|
|
interface DbToRestApiMappingCardProps {
|
|
fromColumns: BatchColumnInfo[];
|
|
selectedColumns: string[];
|
|
toApiFields: string[];
|
|
dbToApiFieldMapping: Record<string, string>;
|
|
setDbToApiFieldMapping: React.Dispatch<
|
|
React.SetStateAction<Record<string, string>>
|
|
>;
|
|
setToApiBody: (body: string) => void;
|
|
}
|
|
|
|
export default function BatchManagementNewPage() {
|
|
const router = useRouter();
|
|
|
|
// 기본 상태
|
|
const [batchName, setBatchName] = useState("");
|
|
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
|
|
const [description, setDescription] = useState("");
|
|
|
|
// 연결 정보
|
|
const [connections, setConnections] = useState<BatchConnectionInfo[]>([]);
|
|
const [toConnection, setToConnection] = useState<BatchConnectionInfo | null>(null);
|
|
const [toTables, setToTables] = useState<string[]>([]);
|
|
const [toTable, setToTable] = useState("");
|
|
const [toColumns, setToColumns] = useState<BatchColumnInfo[]>([]);
|
|
|
|
// REST API 설정 (REST API → DB용)
|
|
const [fromApiUrl, setFromApiUrl] = useState("");
|
|
const [fromApiKey, setFromApiKey] = useState("");
|
|
const [fromEndpoint, setFromEndpoint] = useState("");
|
|
const [fromApiMethod, setFromApiMethod] = useState<'GET' | 'POST' | 'PUT' | 'DELETE'>('GET');
|
|
const [fromApiBody, setFromApiBody] = useState(""); // Request Body (JSON)
|
|
|
|
// REST API 파라미터 설정
|
|
const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none');
|
|
const [apiParamName, setApiParamName] = useState(""); // 파라미터명 (예: userId, id)
|
|
const [apiParamValue, setApiParamValue] = useState(""); // 파라미터 값 또는 템플릿
|
|
const [apiParamSource, setApiParamSource] = useState<'static' | 'dynamic'>('static'); // 정적 값 또는 동적 값
|
|
|
|
// DB → REST API용 상태
|
|
const [fromConnection, setFromConnection] = useState<BatchConnectionInfo | null>(null);
|
|
const [fromTables, setFromTables] = useState<string[]>([]);
|
|
const [fromTable, setFromTable] = useState("");
|
|
const [fromColumns, setFromColumns] = useState<BatchColumnInfo[]>([]);
|
|
const [selectedColumns, setSelectedColumns] = useState<string[]>([]); // 선택된 컬럼들
|
|
const [dbToApiFieldMapping, setDbToApiFieldMapping] = useState<Record<string, string>>({}); // DB 컬럼 → API 필드 매핑
|
|
|
|
// REST API 대상 설정 (DB → REST API용)
|
|
const [toApiUrl, setToApiUrl] = useState("");
|
|
const [toApiKey, setToApiKey] = useState("");
|
|
const [toEndpoint, setToEndpoint] = useState("");
|
|
const [toApiMethod, setToApiMethod] = useState<'POST' | 'PUT' | 'DELETE'>('POST');
|
|
const [toApiBody, setToApiBody] = useState<string>(''); // Request Body 템플릿
|
|
const [toApiFields, setToApiFields] = useState<string[]>([]); // TO API 필드 목록
|
|
const [urlPathColumn, setUrlPathColumn] = useState(""); // URL 경로에 사용할 컬럼 (PUT/DELETE용)
|
|
|
|
// API 데이터 미리보기
|
|
const [fromApiData, setFromApiData] = useState<any[]>([]);
|
|
const [fromApiFields, setFromApiFields] = useState<string[]>([]);
|
|
|
|
// API 필드 → DB 컬럼 매핑
|
|
const [apiFieldMappings, setApiFieldMappings] = useState<Record<string, string>>({});
|
|
// API 필드별 JSON 경로 오버라이드 (예: "response.access_token")
|
|
const [apiFieldPathOverrides, setApiFieldPathOverrides] = useState<Record<string, string>>({});
|
|
|
|
// 배치 타입 상태
|
|
const [batchType, setBatchType] = useState<BatchType>('restapi-to-db');
|
|
|
|
// 배치 타입 옵션
|
|
const batchTypeOptions: BatchTypeOption[] = [
|
|
{
|
|
value: 'restapi-to-db',
|
|
label: 'REST API → DB',
|
|
description: 'REST API에서 데이터베이스로 데이터 수집'
|
|
},
|
|
{
|
|
value: 'db-to-restapi',
|
|
label: 'DB → REST API',
|
|
description: '데이터베이스에서 REST API로 데이터 전송'
|
|
}
|
|
];
|
|
|
|
// 초기 데이터 로드
|
|
useEffect(() => {
|
|
loadConnections();
|
|
}, []);
|
|
|
|
// 배치 타입 변경 시 상태 초기화
|
|
useEffect(() => {
|
|
// 공통 초기화
|
|
setApiFieldMappings({});
|
|
|
|
// REST API → DB 관련 초기화
|
|
setToConnection(null);
|
|
setToTables([]);
|
|
setToTable("");
|
|
setToColumns([]);
|
|
setFromApiUrl("");
|
|
setFromApiKey("");
|
|
setFromEndpoint("");
|
|
setFromApiData([]);
|
|
setFromApiFields([]);
|
|
|
|
// DB → REST API 관련 초기화
|
|
setFromConnection(null);
|
|
setFromTables([]);
|
|
setFromTable("");
|
|
setFromColumns([]);
|
|
setSelectedColumns([]);
|
|
setDbToApiFieldMapping({});
|
|
setToApiUrl("");
|
|
setToApiKey("");
|
|
setToEndpoint("");
|
|
setToApiBody("");
|
|
setToApiFields([]);
|
|
}, [batchType]);
|
|
|
|
|
|
// 연결 목록 로드
|
|
const loadConnections = async () => {
|
|
try {
|
|
const result = await BatchManagementAPI.getAvailableConnections();
|
|
setConnections(result || []);
|
|
} catch (error) {
|
|
console.error("연결 목록 로드 오류:", error);
|
|
toast.error("연결 목록을 불러오는데 실패했습니다.");
|
|
}
|
|
};
|
|
|
|
// TO 연결 변경 핸들러
|
|
const handleToConnectionChange = async (connectionValue: string) => {
|
|
let connection: BatchConnectionInfo | null = null;
|
|
|
|
if (connectionValue === 'internal') {
|
|
// 내부 데이터베이스 선택
|
|
connection = connections.find(conn => conn.type === 'internal') || null;
|
|
} else {
|
|
// 외부 데이터베이스 선택
|
|
const connectionId = parseInt(connectionValue);
|
|
connection = connections.find(conn => conn.id === connectionId) || null;
|
|
}
|
|
|
|
setToConnection(connection);
|
|
setToTable("");
|
|
setToColumns([]);
|
|
|
|
if (connection) {
|
|
try {
|
|
const connectionType = connection.type === 'internal' ? 'internal' : 'external';
|
|
const result = await BatchManagementAPI.getTablesFromConnection(connectionType, connection.id);
|
|
const tableNames = Array.isArray(result)
|
|
? result.map((table: any) => typeof table === 'string' ? table : table.table_name || String(table))
|
|
: [];
|
|
setToTables(tableNames);
|
|
} catch (error) {
|
|
console.error("테이블 목록 로드 오류:", error);
|
|
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
|
}
|
|
}
|
|
};
|
|
|
|
// TO 테이블 변경 핸들러
|
|
const handleToTableChange = async (tableName: string) => {
|
|
setToTable(tableName);
|
|
setToColumns([]);
|
|
|
|
if (toConnection && tableName) {
|
|
try {
|
|
const connectionType = toConnection.type === 'internal' ? 'internal' : 'external';
|
|
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, toConnection.id);
|
|
if (result && result.length > 0) {
|
|
setToColumns(result);
|
|
} else {
|
|
setToColumns([]);
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ 컬럼 목록 로드 오류:", error);
|
|
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
|
setToColumns([]);
|
|
}
|
|
}
|
|
};
|
|
|
|
// FROM 연결 변경 핸들러 (DB → REST API용)
|
|
const handleFromConnectionChange = async (connectionValue: string) => {
|
|
let connection: BatchConnectionInfo | null = null;
|
|
if (connectionValue === 'internal') {
|
|
connection = connections.find(conn => conn.type === 'internal') || null;
|
|
} else {
|
|
const connectionId = parseInt(connectionValue);
|
|
connection = connections.find(conn => conn.id === connectionId) || null;
|
|
}
|
|
setFromConnection(connection);
|
|
setFromTable("");
|
|
setFromColumns([]);
|
|
|
|
if (connection) {
|
|
try {
|
|
const connectionType = connection.type === 'internal' ? 'internal' : 'external';
|
|
const result = await BatchManagementAPI.getTablesFromConnection(connectionType, connection.id);
|
|
const tableNames = Array.isArray(result)
|
|
? result.map((table: any) => typeof table === 'string' ? table : table.table_name || String(table))
|
|
: [];
|
|
setFromTables(tableNames);
|
|
} catch (error) {
|
|
console.error("테이블 목록 로드 오류:", error);
|
|
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
|
}
|
|
}
|
|
};
|
|
|
|
// FROM 테이블 변경 핸들러 (DB → REST API용)
|
|
const handleFromTableChange = async (tableName: string) => {
|
|
setFromTable(tableName);
|
|
setFromColumns([]);
|
|
setSelectedColumns([]); // 선택된 컬럼도 초기화
|
|
setDbToApiFieldMapping({}); // 매핑도 초기화
|
|
|
|
if (fromConnection && tableName) {
|
|
try {
|
|
const connectionType = fromConnection.type === 'internal' ? 'internal' : 'external';
|
|
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, fromConnection.id);
|
|
if (result && result.length > 0) {
|
|
setFromColumns(result);
|
|
} else {
|
|
setFromColumns([]);
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ FROM 컬럼 목록 로드 오류:", error);
|
|
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
|
setFromColumns([]);
|
|
}
|
|
}
|
|
};
|
|
|
|
// TO API 미리보기 (DB → REST API용)
|
|
const previewToApiData = async () => {
|
|
if (!toApiUrl || !toApiKey || !toEndpoint) {
|
|
toast.error("API URL, API Key, 엔드포인트를 모두 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await BatchManagementAPI.previewRestApiData(
|
|
toApiUrl,
|
|
toApiKey,
|
|
toEndpoint,
|
|
'GET' // 미리보기는 항상 GET으로
|
|
);
|
|
|
|
if (result.fields && result.fields.length > 0) {
|
|
setToApiFields(result.fields);
|
|
toast.success(`TO API 필드 ${result.fields.length}개를 조회했습니다.`);
|
|
} else {
|
|
setToApiFields([]);
|
|
toast.warning("TO API에서 필드를 찾을 수 없습니다.");
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ TO API 미리보기 오류:", error);
|
|
toast.error("TO API 미리보기에 실패했습니다.");
|
|
setToApiFields([]);
|
|
}
|
|
};
|
|
|
|
// REST API 데이터 미리보기
|
|
const previewRestApiData = async () => {
|
|
// API URL, 엔드포인트는 항상 필수
|
|
if (!fromApiUrl || !fromEndpoint) {
|
|
toast.error("API URL과 엔드포인트를 모두 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
// GET 메서드일 때만 API 키 필수
|
|
if (fromApiMethod === "GET" && !fromApiKey) {
|
|
toast.error("GET 메서드에서는 API 키를 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await BatchManagementAPI.previewRestApiData(
|
|
fromApiUrl,
|
|
fromApiKey || "",
|
|
fromEndpoint,
|
|
fromApiMethod,
|
|
// 파라미터 정보 추가
|
|
apiParamType !== 'none' ? {
|
|
paramType: apiParamType,
|
|
paramName: apiParamName,
|
|
paramValue: apiParamValue,
|
|
paramSource: apiParamSource
|
|
} : undefined,
|
|
// Request Body 추가 (POST/PUT/DELETE)
|
|
(fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') ? fromApiBody : undefined
|
|
);
|
|
|
|
if (result.fields && result.fields.length > 0) {
|
|
setFromApiFields(result.fields);
|
|
setFromApiData(result.samples);
|
|
toast.success(`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.totalCount}개 레코드`);
|
|
} else if (result.samples && result.samples.length > 0) {
|
|
// 백엔드에서 fields를 제대로 보내지 않은 경우, 프론트엔드에서 직접 추출
|
|
const extractedFields = Object.keys(result.samples[0]);
|
|
setFromApiFields(extractedFields);
|
|
setFromApiData(result.samples);
|
|
|
|
toast.success(`API 데이터 미리보기 완료! ${extractedFields.length}개 필드, ${result.samples.length}개 레코드`);
|
|
} else {
|
|
setFromApiFields([]);
|
|
setFromApiData([]);
|
|
toast.warning("API에서 데이터를 가져올 수 없습니다.");
|
|
}
|
|
} catch (error) {
|
|
console.error("REST API 미리보기 오류:", error);
|
|
toast.error("API 데이터 미리보기에 실패했습니다.");
|
|
setFromApiFields([]);
|
|
setFromApiData([]);
|
|
}
|
|
};
|
|
|
|
// 배치 설정 저장
|
|
const handleSave = async () => {
|
|
if (!batchName.trim()) {
|
|
toast.error("배치명을 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
// 배치 타입별 검증 및 저장
|
|
if (batchType === 'restapi-to-db') {
|
|
const mappedFields = Object.keys(apiFieldMappings).filter(
|
|
(field) => apiFieldMappings[field]
|
|
);
|
|
if (mappedFields.length === 0) {
|
|
toast.error("최소 하나의 API 필드를 DB 컬럼에 매핑해주세요.");
|
|
return;
|
|
}
|
|
|
|
// API 필드 매핑을 배치 매핑 형태로 변환
|
|
const apiMappings = mappedFields.map((apiField) => {
|
|
const toColumnName = apiFieldMappings[apiField]; // 매핑된 DB 컬럼 (예: access_token)
|
|
|
|
// 기본은 상위 필드 그대로 사용하되,
|
|
// 사용자가 JSON 경로를 직접 입력한 경우 해당 경로를 우선 사용
|
|
let fromColumnName = apiField;
|
|
const overridePath = apiFieldPathOverrides[apiField];
|
|
if (overridePath && overridePath.trim().length > 0) {
|
|
fromColumnName = overridePath.trim();
|
|
}
|
|
|
|
return {
|
|
from_connection_type: "restapi" as const,
|
|
from_table_name: fromEndpoint, // API 엔드포인트
|
|
from_column_name: fromColumnName, // API 필드명 또는 중첩 경로
|
|
from_api_url: fromApiUrl,
|
|
from_api_key: fromApiKey,
|
|
from_api_method: fromApiMethod,
|
|
from_api_body:
|
|
fromApiMethod === "POST" ||
|
|
fromApiMethod === "PUT" ||
|
|
fromApiMethod === "DELETE"
|
|
? fromApiBody
|
|
: undefined,
|
|
// API 파라미터 정보 추가
|
|
from_api_param_type: apiParamType !== "none" ? apiParamType : undefined,
|
|
from_api_param_name: apiParamType !== "none" ? apiParamName : undefined,
|
|
from_api_param_value: apiParamType !== "none" ? apiParamValue : undefined,
|
|
from_api_param_source:
|
|
apiParamType !== "none" ? apiParamSource : undefined,
|
|
to_connection_type:
|
|
toConnection?.type === "internal" ? "internal" : "external",
|
|
to_connection_id:
|
|
toConnection?.type === "internal" ? undefined : toConnection?.id,
|
|
to_table_name: toTable,
|
|
to_column_name: toColumnName, // 매핑된 DB 컬럼
|
|
mapping_type: "direct" as const,
|
|
};
|
|
});
|
|
|
|
// 실제 API 호출
|
|
try {
|
|
const result = await BatchManagementAPI.saveRestApiBatch({
|
|
batchName,
|
|
batchType,
|
|
cronSchedule,
|
|
description,
|
|
apiMappings
|
|
});
|
|
|
|
if (result.success) {
|
|
toast.success(result.message || "REST API 배치 설정이 저장되었습니다.");
|
|
setTimeout(() => {
|
|
router.push('/admin/batchmng');
|
|
}, 1000);
|
|
} else {
|
|
toast.error(result.message || "배치 저장에 실패했습니다.");
|
|
}
|
|
} catch (error) {
|
|
console.error("배치 저장 오류:", error);
|
|
toast.error("배치 저장 중 오류가 발생했습니다.");
|
|
}
|
|
return;
|
|
} else if (batchType === 'db-to-restapi') {
|
|
// DB → REST API 배치 검증
|
|
if (!fromConnection || !fromTable || selectedColumns.length === 0) {
|
|
toast.error("소스 데이터베이스, 테이블, 컬럼을 선택해주세요.");
|
|
return;
|
|
}
|
|
|
|
if (!toApiUrl || !toApiKey || !toEndpoint) {
|
|
toast.error("대상 API URL, API Key, 엔드포인트를 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
if ((toApiMethod === 'POST' || toApiMethod === 'PUT') && !toApiBody) {
|
|
toast.error("POST/PUT 메서드의 경우 Request Body 템플릿을 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
// DELETE의 경우 빈 Request Body라도 템플릿 로직을 위해 "{}" 설정
|
|
let finalToApiBody = toApiBody;
|
|
if (toApiMethod === 'DELETE' && !finalToApiBody.trim()) {
|
|
finalToApiBody = '{}';
|
|
}
|
|
|
|
// DB → REST API 매핑 생성 (선택된 컬럼만)
|
|
const selectedColumnObjects = fromColumns.filter(column => selectedColumns.includes(column.column_name));
|
|
const dbMappings = selectedColumnObjects.map((column, index) => ({
|
|
from_connection_type: fromConnection.type === 'internal' ? 'internal' : 'external',
|
|
from_connection_id: fromConnection.type === 'internal' ? undefined : fromConnection.id,
|
|
from_table_name: fromTable,
|
|
from_column_name: column.column_name,
|
|
from_column_type: column.data_type,
|
|
to_connection_type: 'restapi' as const,
|
|
to_table_name: toEndpoint, // API 엔드포인트
|
|
to_column_name: dbToApiFieldMapping[column.column_name] || column.column_name, // 매핑된 API 필드명
|
|
to_api_url: toApiUrl,
|
|
to_api_key: toApiKey,
|
|
to_api_method: toApiMethod,
|
|
to_api_body: finalToApiBody, // Request Body 템플릿
|
|
mapping_type: 'template' as const,
|
|
mapping_order: index + 1
|
|
}));
|
|
|
|
// URL 경로 파라미터 매핑 추가 (PUT/DELETE용)
|
|
if ((toApiMethod === 'PUT' || toApiMethod === 'DELETE') && urlPathColumn) {
|
|
const urlPathColumnObject = fromColumns.find(col => col.column_name === urlPathColumn);
|
|
if (urlPathColumnObject) {
|
|
dbMappings.push({
|
|
from_connection_type: fromConnection.type === 'internal' ? 'internal' : 'external',
|
|
from_connection_id: fromConnection.type === 'internal' ? undefined : fromConnection.id,
|
|
from_table_name: fromTable,
|
|
from_column_name: urlPathColumn,
|
|
from_column_type: urlPathColumnObject.data_type,
|
|
to_connection_type: 'restapi' as const,
|
|
to_table_name: toEndpoint,
|
|
to_column_name: 'URL_PATH_PARAM', // 특별한 식별자
|
|
to_api_url: toApiUrl,
|
|
to_api_key: toApiKey,
|
|
to_api_method: toApiMethod,
|
|
to_api_body: finalToApiBody,
|
|
mapping_type: 'url_path' as const,
|
|
mapping_order: 999 // 마지막 순서
|
|
});
|
|
}
|
|
}
|
|
|
|
// 실제 API 호출 (기존 saveRestApiBatch 재사용)
|
|
try {
|
|
const result = await BatchManagementAPI.saveRestApiBatch({
|
|
batchName,
|
|
batchType,
|
|
cronSchedule,
|
|
description,
|
|
apiMappings: dbMappings
|
|
});
|
|
|
|
if (result.success) {
|
|
toast.success(result.message || "DB → REST API 배치 설정이 저장되었습니다.");
|
|
setTimeout(() => {
|
|
router.push('/admin/batchmng');
|
|
}, 1000);
|
|
} else {
|
|
toast.error(result.message || "배치 저장에 실패했습니다.");
|
|
}
|
|
} catch (error) {
|
|
console.error("배치 저장 오류:", error);
|
|
toast.error("배치 저장 중 오류가 발생했습니다.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
toast.error("지원하지 않는 배치 타입입니다.");
|
|
};
|
|
|
|
return (
|
|
<div className="container mx-auto p-6 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-3xl font-bold">고급 배치 생성</h1>
|
|
<div className="flex space-x-2">
|
|
<Button onClick={loadConnections} variant="outline">
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
새로고침
|
|
</Button>
|
|
<Button onClick={handleSave}>
|
|
<Save className="w-4 h-4 mr-2" />
|
|
저장
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 기본 정보 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>기본 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* 배치 타입 선택 */}
|
|
<div>
|
|
<Label>배치 타입 *</Label>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-2">
|
|
{batchTypeOptions.map((option) => (
|
|
<div
|
|
key={option.value}
|
|
className={`p-3 border rounded-lg cursor-pointer transition-all ${
|
|
batchType === option.value
|
|
? 'border-blue-500 bg-blue-50'
|
|
: 'border-gray-200 hover:border-gray-300'
|
|
}`}
|
|
onClick={() => setBatchType(option.value)}
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
{option.value === 'restapi-to-db' ? (
|
|
<Globe className="w-4 h-4 text-blue-600" />
|
|
) : (
|
|
<Database className="w-4 h-4 text-green-600" />
|
|
)}
|
|
<div>
|
|
<div className="font-medium text-sm">{option.label}</div>
|
|
<div className="text-xs text-gray-500 mt-1">{option.description}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="batchName">배치명 *</Label>
|
|
<Input
|
|
id="batchName"
|
|
value={batchName}
|
|
onChange={(e) => setBatchName(e.target.value)}
|
|
placeholder="배치명을 입력하세요"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="cronSchedule">실행 스케줄 *</Label>
|
|
<Input
|
|
id="cronSchedule"
|
|
value={cronSchedule}
|
|
onChange={(e) => setCronSchedule(e.target.value)}
|
|
placeholder="0 12 * * *"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="description">설명</Label>
|
|
<Textarea
|
|
id="description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="배치에 대한 설명을 입력하세요"
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* FROM 설정 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center">
|
|
{batchType === 'restapi-to-db' ? (
|
|
<>
|
|
<Globe className="w-5 h-5 mr-2" />
|
|
FROM: REST API (소스)
|
|
</>
|
|
) : (
|
|
<>
|
|
<Database className="w-5 h-5 mr-2" />
|
|
FROM: 데이터베이스 (소스)
|
|
</>
|
|
)}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* REST API 설정 (REST API → DB) */}
|
|
{batchType === 'restapi-to-db' && (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="fromApiUrl">API 서버 URL *</Label>
|
|
<Input
|
|
id="fromApiUrl"
|
|
value={fromApiUrl}
|
|
onChange={(e) => setFromApiUrl(e.target.value)}
|
|
placeholder="https://api.example.com"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="fromApiKey">
|
|
API 키
|
|
{fromApiMethod === "GET" && <span className="text-red-500 ml-0.5">*</span>}
|
|
</Label>
|
|
<Input
|
|
id="fromApiKey"
|
|
value={fromApiKey}
|
|
onChange={(e) => setFromApiKey(e.target.value)}
|
|
placeholder="ak_your_api_key_here"
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
GET 메서드에서만 필수이며, POST/PUT/DELETE일 때는 선택 사항입니다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="fromEndpoint">엔드포인트 *</Label>
|
|
<Input
|
|
id="fromEndpoint"
|
|
value={fromEndpoint}
|
|
onChange={(e) => setFromEndpoint(e.target.value)}
|
|
placeholder="/api/users"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>HTTP 메서드</Label>
|
|
<Select value={fromApiMethod} onValueChange={(value: any) => setFromApiMethod(value)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="GET">GET (데이터 조회)</SelectItem>
|
|
<SelectItem value="POST">POST (데이터 조회/전송)</SelectItem>
|
|
<SelectItem value="PUT">PUT</SelectItem>
|
|
<SelectItem value="DELETE">DELETE</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* Request Body (POST/PUT/DELETE용) */}
|
|
{(fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') && (
|
|
<div>
|
|
<Label htmlFor="fromApiBody">Request Body (JSON)</Label>
|
|
<Textarea
|
|
id="fromApiBody"
|
|
value={fromApiBody}
|
|
onChange={(e) => setFromApiBody(e.target.value)}
|
|
placeholder='{"username": "myuser", "token": "abc"}'
|
|
className="min-h-[100px]"
|
|
rows={5}
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
API 호출 시 함께 전송할 JSON 데이터를 입력하세요.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* API 파라미터 설정 */}
|
|
<div className="space-y-4">
|
|
<div className="border-t pt-4">
|
|
<Label className="text-base font-medium">API 파라미터 설정</Label>
|
|
<p className="text-sm text-gray-600 mt-1">특정 사용자나 조건으로 데이터를 조회할 때 사용합니다.</p>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>파라미터 타입</Label>
|
|
<Select value={apiParamType} onValueChange={(value: any) => setApiParamType(value)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">파라미터 없음</SelectItem>
|
|
<SelectItem value="url">URL 파라미터 (/api/users/{`{userId}`})</SelectItem>
|
|
<SelectItem value="query">쿼리 파라미터 (/api/users?userId=123)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{apiParamType !== 'none' && (
|
|
<>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="apiParamName">파라미터명 *</Label>
|
|
<Input
|
|
id="apiParamName"
|
|
value={apiParamName}
|
|
onChange={(e) => setApiParamName(e.target.value)}
|
|
placeholder="userId, id, email 등"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>파라미터 소스</Label>
|
|
<Select value={apiParamSource} onValueChange={(value: any) => setApiParamSource(value)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="static">고정값</SelectItem>
|
|
<SelectItem value="dynamic">동적값 (실행 시 결정)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="apiParamValue">
|
|
{apiParamSource === 'static' ? '파라미터 값' : '파라미터 템플릿'} *
|
|
</Label>
|
|
<Input
|
|
id="apiParamValue"
|
|
value={apiParamValue}
|
|
onChange={(e) => setApiParamValue(e.target.value)}
|
|
placeholder={
|
|
apiParamSource === 'static'
|
|
? "123, john@example.com 등"
|
|
: "{{user_id}}, {{email}} 등 (실행 시 치환됨)"
|
|
}
|
|
/>
|
|
{apiParamSource === 'dynamic' && (
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
동적값은 배치 실행 시 설정된 값으로 치환됩니다. 예: {`{{user_id}}`} → 실제 사용자 ID
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{apiParamType === 'url' && (
|
|
<div className="p-3 bg-blue-50 rounded-lg">
|
|
<div className="text-sm font-medium text-blue-800">URL 파라미터 예시</div>
|
|
<div className="text-sm text-blue-700 mt-1">
|
|
엔드포인트: /api/users/{`{${apiParamName || 'userId'}}`}
|
|
</div>
|
|
<div className="text-sm text-blue-700">
|
|
실제 호출: /api/users/{apiParamValue || '123'}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{apiParamType === 'query' && (
|
|
<div className="p-3 bg-green-50 rounded-lg">
|
|
<div className="text-sm font-medium text-green-800">쿼리 파라미터 예시</div>
|
|
<div className="text-sm text-green-700 mt-1">
|
|
실제 호출: {fromEndpoint || '/api/users'}?{apiParamName || 'userId'}={apiParamValue || '123'}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* API URL + 엔드포인트는 필수, GET일 때만 API 키 필수 */}
|
|
{fromApiUrl &&
|
|
fromEndpoint &&
|
|
(fromApiMethod !== "GET" || fromApiKey) && (
|
|
<div className="space-y-3">
|
|
<div className="p-3 bg-gray-50 rounded-lg">
|
|
<div className="text-sm font-medium text-gray-700">API 호출 미리보기</div>
|
|
<div className="text-sm text-gray-600 mt-1">
|
|
{fromApiMethod} {fromApiUrl}
|
|
{apiParamType === 'url' && apiParamName && apiParamValue
|
|
? fromEndpoint.replace(`{${apiParamName}}`, apiParamValue) || fromEndpoint + `/${apiParamValue}`
|
|
: fromEndpoint
|
|
}
|
|
{apiParamType === 'query' && apiParamName && apiParamValue
|
|
? `?${apiParamName}=${apiParamValue}`
|
|
: ''
|
|
}
|
|
</div>
|
|
{fromApiKey && (
|
|
<div className="text-xs text-gray-500 mt-1">
|
|
Headers: X-API-Key: {fromApiKey.substring(0, 10)}...
|
|
</div>
|
|
)}
|
|
{apiParamType !== 'none' && apiParamName && apiParamValue && (
|
|
<div className="text-xs text-blue-600 mt-1">
|
|
파라미터: {apiParamName} = {apiParamValue} ({apiParamSource === 'static' ? '고정값' : '동적값'})
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Button onClick={previewRestApiData} variant="outline" className="w-full">
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
API 데이터 미리보기
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* DB 설정 (DB → REST API) */}
|
|
{batchType === 'db-to-restapi' && (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label>소스 연결 *</Label>
|
|
<Select value={fromConnection?.id?.toString() || fromConnection?.type || ""} onValueChange={handleFromConnectionChange}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="연결을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{connections.map((connection) => (
|
|
<SelectItem
|
|
key={connection.id || 'internal'}
|
|
value={connection.id ? connection.id.toString() : 'internal'}
|
|
>
|
|
{connection.name} ({connection.type === 'internal' ? '내부 DB' : connection.db_type})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>소스 테이블 *</Label>
|
|
<Select value={fromTable} onValueChange={handleFromTableChange}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="테이블을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Array.isArray(fromTables) && fromTables.map((table: string) => (
|
|
<SelectItem key={table} value={table}>
|
|
{table.toUpperCase()}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 컬럼 선택 UI */}
|
|
{fromColumns.length > 0 && (
|
|
<div>
|
|
<Label>컬럼 선택 (선택된 컬럼이 API로 전송됩니다)</Label>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 mt-2 max-h-60 overflow-y-auto border rounded-lg p-3">
|
|
{fromColumns.map((column) => (
|
|
<div key={column.column_name} className="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
id={`col-${column.column_name}`}
|
|
checked={selectedColumns.includes(column.column_name)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedColumns([...selectedColumns, column.column_name]);
|
|
} else {
|
|
setSelectedColumns(selectedColumns.filter(col => col !== column.column_name));
|
|
}
|
|
}}
|
|
className="rounded border-gray-300"
|
|
/>
|
|
<label
|
|
htmlFor={`col-${column.column_name}`}
|
|
className="text-sm cursor-pointer flex-1"
|
|
title={`타입: ${column.data_type} | NULL: ${column.is_nullable ? 'Y' : 'N'}`}
|
|
>
|
|
{column.column_name}
|
|
</label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 선택된 컬럼 개수 표시 */}
|
|
<div className="text-xs text-gray-500 mt-2">
|
|
선택된 컬럼: {selectedColumns.length}개 / 전체: {fromColumns.length}개
|
|
</div>
|
|
|
|
{/* 빠른 매핑 버튼들 */}
|
|
{selectedColumns.length > 0 && toApiFields.length > 0 && (
|
|
<div className="mt-3 p-3 bg-green-50 border border-green-200 rounded-lg">
|
|
<div className="text-sm font-medium text-green-800 mb-2">빠른 매핑</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const mapping: Record<string, string> = {};
|
|
selectedColumns.forEach(col => {
|
|
// 스마트 매핑 로직
|
|
const matchingApiField = toApiFields.find(apiField => {
|
|
const colLower = col.toLowerCase();
|
|
const apiLower = apiField.toLowerCase();
|
|
|
|
// 정확한 매치
|
|
if (colLower === apiLower) return true;
|
|
|
|
// 언더스코어 무시 매치
|
|
if (colLower.replace(/_/g, '') === apiLower.replace(/_/g, '')) return true;
|
|
|
|
// 의미적 매핑
|
|
if ((colLower.includes('created') || colLower.includes('reg')) &&
|
|
(apiLower.includes('date') || apiLower.includes('time'))) return true;
|
|
if ((colLower.includes('updated') || colLower.includes('mod')) &&
|
|
(apiLower.includes('date') || apiLower.includes('time'))) return true;
|
|
if (colLower.includes('id') && apiLower.includes('id')) return true;
|
|
if (colLower.includes('name') && apiLower.includes('name')) return true;
|
|
if (colLower.includes('code') && apiLower.includes('code')) return true;
|
|
|
|
return false;
|
|
});
|
|
|
|
if (matchingApiField) {
|
|
mapping[col] = matchingApiField;
|
|
}
|
|
});
|
|
setDbToApiFieldMapping(mapping);
|
|
toast.success(`${Object.keys(mapping).length}개 컬럼이 자동 매핑되었습니다.`);
|
|
}}
|
|
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
|
|
>
|
|
스마트 자동 매핑
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDbToApiFieldMapping({});
|
|
toast.success("매핑이 초기화되었습니다.");
|
|
}}
|
|
className="px-3 py-1 bg-gray-600 text-white text-xs rounded hover:bg-gray-700"
|
|
>
|
|
매핑 초기화
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 자동 생성된 JSON 미리보기 */}
|
|
{selectedColumns.length > 0 && (
|
|
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
|
<div className="text-sm font-medium text-blue-800 mb-2">자동 생성된 JSON 구조</div>
|
|
<pre className="text-xs text-blue-600 font-mono overflow-x-auto">
|
|
{JSON.stringify(
|
|
selectedColumns.reduce((obj, col) => {
|
|
const apiField = dbToApiFieldMapping[col] || col; // 매핑된 API 필드명 또는 원본 컬럼명
|
|
obj[apiField] = `{{${col}}}`;
|
|
return obj;
|
|
}, {} as Record<string, string>),
|
|
null,
|
|
2
|
|
)}
|
|
</pre>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const autoJson = JSON.stringify(
|
|
selectedColumns.reduce((obj, col) => {
|
|
const apiField = dbToApiFieldMapping[col] || col; // 매핑된 API 필드명 또는 원본 컬럼명
|
|
obj[apiField] = `{{${col}}}`;
|
|
return obj;
|
|
}, {} as Record<string, string>),
|
|
null,
|
|
2
|
|
);
|
|
setToApiBody(autoJson);
|
|
toast.success("Request Body에 자동 생성된 JSON이 적용되었습니다.");
|
|
}}
|
|
className="mt-2 px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
|
|
>
|
|
Request Body에 적용
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 매핑 UI - 배치 타입별 동적 렌더링 */}
|
|
{/* REST API → DB 매핑 */}
|
|
{batchType === "restapi-to-db" &&
|
|
fromApiFields.length > 0 &&
|
|
toColumns.length > 0 && (
|
|
<RestApiToDbMappingCard
|
|
fromApiFields={fromApiFields}
|
|
toColumns={toColumns}
|
|
fromApiData={fromApiData}
|
|
apiFieldMappings={apiFieldMappings}
|
|
setApiFieldMappings={setApiFieldMappings}
|
|
apiFieldPathOverrides={apiFieldPathOverrides}
|
|
setApiFieldPathOverrides={setApiFieldPathOverrides}
|
|
/>
|
|
)}
|
|
|
|
{/* DB → REST API 매핑 */}
|
|
{batchType === "db-to-restapi" &&
|
|
selectedColumns.length > 0 &&
|
|
toApiFields.length > 0 && (
|
|
<DbToRestApiMappingCard
|
|
fromColumns={fromColumns}
|
|
selectedColumns={selectedColumns}
|
|
toApiFields={toApiFields}
|
|
dbToApiFieldMapping={dbToApiFieldMapping}
|
|
setDbToApiFieldMapping={setDbToApiFieldMapping}
|
|
setToApiBody={setToApiBody}
|
|
/>
|
|
)}
|
|
|
|
{/* TO 설정 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center">
|
|
{batchType === 'restapi-to-db' ? (
|
|
<>
|
|
<Database className="w-5 h-5 mr-2" />
|
|
TO: 데이터베이스 (대상)
|
|
</>
|
|
) : (
|
|
<>
|
|
<Globe className="w-5 h-5 mr-2" />
|
|
TO: REST API (대상)
|
|
</>
|
|
)}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* DB 설정 (REST API → DB) */}
|
|
{batchType === 'restapi-to-db' && (
|
|
<>
|
|
<div>
|
|
<Label>데이터베이스 커넥션 선택</Label>
|
|
<Select onValueChange={handleToConnectionChange}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="커넥션을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{connections.map((connection, index) => (
|
|
<SelectItem
|
|
key={connection.id || `internal-${index}`}
|
|
value={connection.id ? connection.id.toString() : 'internal'}
|
|
>
|
|
{connection.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* REST API 설정 (DB → REST API) */}
|
|
{batchType === 'db-to-restapi' && (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="toApiUrl">API 서버 URL *</Label>
|
|
<Input
|
|
id="toApiUrl"
|
|
value={toApiUrl}
|
|
onChange={(e) => setToApiUrl(e.target.value)}
|
|
placeholder="https://api.example.com"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="toApiKey">API 키 *</Label>
|
|
<Input
|
|
id="toApiKey"
|
|
value={toApiKey}
|
|
onChange={(e) => setToApiKey(e.target.value)}
|
|
placeholder="ak_your_api_key_here"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="toEndpoint">엔드포인트 *</Label>
|
|
<Input
|
|
id="toEndpoint"
|
|
value={toEndpoint}
|
|
onChange={(e) => setToEndpoint(e.target.value)}
|
|
placeholder="/api/users"
|
|
/>
|
|
{(toApiMethod === 'PUT' || toApiMethod === 'DELETE') && (
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
💡 실제 URL: {toEndpoint}/{urlPathColumn ? `{${urlPathColumn}}` : '{ID}'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<Label>HTTP 메서드</Label>
|
|
<Select value={toApiMethod} onValueChange={(value: any) => setToApiMethod(value)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="POST">POST (데이터 생성)</SelectItem>
|
|
<SelectItem value="PUT">PUT (데이터 업데이트)</SelectItem>
|
|
<SelectItem value="DELETE">DELETE (데이터 삭제)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* URL 경로 파라미터 설정 (PUT/DELETE용) */}
|
|
{(toApiMethod === 'PUT' || toApiMethod === 'DELETE') && (
|
|
<div>
|
|
<Label>URL 경로 파라미터 컬럼 *</Label>
|
|
<Select value={urlPathColumn} onValueChange={setUrlPathColumn}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="URL 경로에 사용할 컬럼을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{selectedColumns.map((column) => (
|
|
<SelectItem key={column} value={column}>
|
|
{column} (예: /api/users/{`{${column}}`})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
PUT/DELETE 요청 시 URL 경로에 포함될 컬럼을 선택하세요. (예: USER_ID → /api/users/user123)
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* TO API 미리보기 버튼 */}
|
|
<div className="flex justify-center">
|
|
<button
|
|
type="button"
|
|
onClick={previewToApiData}
|
|
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 flex items-center space-x-2"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
<span>API 필드 미리보기</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* TO API 필드 표시 */}
|
|
{toApiFields.length > 0 && (
|
|
<div className="p-3 bg-green-50 border border-green-200 rounded-lg">
|
|
<div className="text-sm font-medium text-green-800 mb-2">
|
|
API 필드 목록 ({toApiFields.length}개)
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{toApiFields.map((field) => (
|
|
<span key={field} className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded">
|
|
{field}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Request Body 템플릿 */}
|
|
{(toApiMethod === 'POST' || toApiMethod === 'PUT') && (
|
|
<div>
|
|
<Label htmlFor="toApiBody">Request Body 템플릿 (JSON)</Label>
|
|
<textarea
|
|
id="toApiBody"
|
|
value={toApiBody}
|
|
onChange={(e) => setToApiBody(e.target.value)}
|
|
placeholder='{"id": "{{id}}", "name": "{{name}}", "email": "{{email}}"}'
|
|
className="w-full p-2 border rounded-md h-24 font-mono text-sm"
|
|
/>
|
|
<div className="text-xs text-gray-500 mt-1">
|
|
DB 컬럼 값을 {`{{컬럼명}}`} 형태로 매핑하세요. 예: {`{{user_id}}, {{user_name}}`}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{toApiUrl && toApiKey && toEndpoint && (
|
|
<div className="p-3 bg-gray-50 rounded-lg">
|
|
<div className="text-sm font-medium text-gray-700">API 호출 미리보기</div>
|
|
<div className="text-sm text-gray-600 mt-1">
|
|
{toApiMethod} {toApiUrl}{toEndpoint}
|
|
</div>
|
|
<div className="text-xs text-gray-500 mt-1">Headers: X-API-Key: {toApiKey.substring(0, 10)}...</div>
|
|
{toApiBody && (
|
|
<div className="text-xs text-blue-600 mt-1">Body: {toApiBody.substring(0, 50)}...</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 테이블/컬럼 선택 (REST API → DB만) */}
|
|
{batchType === 'restapi-to-db' && toTables.length > 0 && (
|
|
<div>
|
|
<Label>테이블 선택</Label>
|
|
<Select onValueChange={handleToTableChange}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="테이블을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{toTables.map((table: string) => (
|
|
<SelectItem key={table} value={table}>
|
|
{table.toUpperCase()}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
|
|
fromApiFields,
|
|
toColumns,
|
|
fromApiData,
|
|
apiFieldMappings,
|
|
setApiFieldMappings,
|
|
apiFieldPathOverrides,
|
|
setApiFieldPathOverrides,
|
|
}: RestApiToDbMappingCardProps) {
|
|
// 샘플 JSON 문자열은 의존 데이터가 바뀔 때만 계산
|
|
const sampleJsonList = useMemo(
|
|
() =>
|
|
fromApiData.slice(0, 3).map((item) => JSON.stringify(item, null, 2)),
|
|
[fromApiData]
|
|
);
|
|
|
|
const firstSample = fromApiData[0] || null;
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>API 필드 → DB 컬럼 매핑</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
|
|
{fromApiFields.map((apiField) => (
|
|
<div
|
|
key={apiField}
|
|
className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg"
|
|
>
|
|
{/* API 필드 정보 */}
|
|
<div className="flex-1">
|
|
<div className="font-medium text-sm">{apiField}</div>
|
|
<div className="text-xs text-gray-500">
|
|
{firstSample && firstSample[apiField] !== undefined
|
|
? `예: ${String(firstSample[apiField]).substring(0, 30)}${
|
|
String(firstSample[apiField]).length > 30 ? "..." : ""
|
|
}`
|
|
: "API 필드"}
|
|
</div>
|
|
{/* JSON 경로 오버라이드 입력 */}
|
|
<div className="mt-1.5">
|
|
<Input
|
|
value={apiFieldPathOverrides[apiField] || ""}
|
|
onChange={(e) =>
|
|
setApiFieldPathOverrides((prev) => ({
|
|
...prev,
|
|
[apiField]: e.target.value,
|
|
}))
|
|
}
|
|
placeholder="JSON 경로 (예: response.access_token)"
|
|
className="h-7 text-xs"
|
|
/>
|
|
<p className="text-[11px] text-gray-500 mt-0.5">
|
|
비워두면 "{apiField}" 필드 전체를 사용하고, 입력하면 해당
|
|
경로의 값을 사용합니다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 화살표 */}
|
|
<div className="text-gray-400">
|
|
<ArrowRight className="w-4 h-4" />
|
|
</div>
|
|
|
|
{/* DB 컬럼 선택 */}
|
|
<div className="flex-1">
|
|
<Select
|
|
value={apiFieldMappings[apiField] || "none"}
|
|
onValueChange={(value) => {
|
|
setApiFieldMappings((prev) => ({
|
|
...prev,
|
|
[apiField]: value === "none" ? "" : value,
|
|
}));
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="DB 컬럼 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">선택 안함</SelectItem>
|
|
{toColumns.map((column) => (
|
|
<SelectItem
|
|
key={column.column_name}
|
|
value={column.column_name}
|
|
>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">
|
|
{column.column_name.toUpperCase()}
|
|
</span>
|
|
<span className="text-xs text-gray-500">
|
|
{column.data_type}
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{sampleJsonList.length > 0 && (
|
|
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
|
<div className="text-sm font-medium text-gray-700 mb-2">
|
|
샘플 데이터 (최대 3개)
|
|
</div>
|
|
<div className="space-y-2 max-h-40 overflow-y-auto">
|
|
{sampleJsonList.map((json, index) => (
|
|
<div
|
|
key={index}
|
|
className="text-xs bg-white p-2 rounded border"
|
|
>
|
|
<pre className="whitespace-pre-wrap">{json}</pre>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
});
|
|
|
|
const DbToRestApiMappingCard = memo(function DbToRestApiMappingCard({
|
|
fromColumns,
|
|
selectedColumns,
|
|
toApiFields,
|
|
dbToApiFieldMapping,
|
|
setDbToApiFieldMapping,
|
|
setToApiBody,
|
|
}: DbToRestApiMappingCardProps) {
|
|
const selectedColumnObjects = useMemo(
|
|
() =>
|
|
fromColumns.filter((column) =>
|
|
selectedColumns.includes(column.column_name)
|
|
),
|
|
[fromColumns, selectedColumns]
|
|
);
|
|
|
|
const autoJsonPreview = useMemo(() => {
|
|
if (selectedColumns.length === 0) {
|
|
return "";
|
|
}
|
|
const obj = selectedColumns.reduce((acc, col) => {
|
|
const apiField = dbToApiFieldMapping[col] || col;
|
|
acc[apiField] = `{{${col}}}`;
|
|
return acc;
|
|
}, {} as Record<string, string>);
|
|
return JSON.stringify(obj, null, 2);
|
|
}, [selectedColumns, dbToApiFieldMapping]);
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>DB 컬럼 → API 필드 매핑</CardTitle>
|
|
<CardDescription>
|
|
DB 컬럼 값을 REST API Request Body에 매핑하세요. Request Body
|
|
템플릿에서 {`{{컬럼명}}`} 형태로 사용됩니다.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
|
|
{selectedColumnObjects.map((column) => (
|
|
<div
|
|
key={column.column_name}
|
|
className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg"
|
|
>
|
|
{/* DB 컬럼 정보 */}
|
|
<div className="flex-1">
|
|
<div className="font-medium text-sm">{column.column_name}</div>
|
|
<div className="text-xs text-gray-500">
|
|
타입: {column.data_type} | NULL: {column.is_nullable ? "Y" : "N"}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 화살표 */}
|
|
<div className="text-gray-400">→</div>
|
|
|
|
{/* API 필드 선택 드롭다운 */}
|
|
<div className="flex-1">
|
|
<Select
|
|
value={dbToApiFieldMapping[column.column_name] || ""}
|
|
onValueChange={(value) => {
|
|
setDbToApiFieldMapping((prev) => ({
|
|
...prev,
|
|
[column.column_name]: value === "none" ? "" : value,
|
|
}));
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="API 필드 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">선택 안함</SelectItem>
|
|
{toApiFields.map((apiField) => (
|
|
<SelectItem key={apiField} value={apiField}>
|
|
{apiField}
|
|
</SelectItem>
|
|
))}
|
|
<SelectItem value="custom">직접 입력...</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* 직접 입력 모드 */}
|
|
{dbToApiFieldMapping[column.column_name] === "custom" && (
|
|
<input
|
|
type="text"
|
|
placeholder="API 필드명을 직접 입력하세요"
|
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mt-2"
|
|
onChange={(e) => {
|
|
setDbToApiFieldMapping((prev) => ({
|
|
...prev,
|
|
[column.column_name]: e.target.value,
|
|
}));
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<div className="text-xs text-gray-500 mt-1">
|
|
{dbToApiFieldMapping[column.column_name]
|
|
? `매핑: ${column.column_name} → ${dbToApiFieldMapping[column.column_name]}`
|
|
: `기본값: ${column.column_name} (DB 컬럼명 사용)`}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 템플릿 미리보기 */}
|
|
<div className="flex-1">
|
|
<div className="text-sm font-mono bg-white p-2 rounded border">
|
|
{`{{${column.column_name}}}`}
|
|
</div>
|
|
<div className="text-xs text-gray-500 mt-1">
|
|
실제 DB 값으로 치환됩니다
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{selectedColumns.length > 0 && (
|
|
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
|
<div className="text-sm font-medium text-blue-800">
|
|
자동 생성된 JSON 구조
|
|
</div>
|
|
<pre className="mt-1 text-xs text-blue-600 font-mono overflow-x-auto">
|
|
{autoJsonPreview}
|
|
</pre>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setToApiBody(autoJsonPreview);
|
|
toast.success(
|
|
"Request Body에 자동 생성된 JSON이 적용되었습니다."
|
|
);
|
|
}}
|
|
className="mt-2 px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
|
|
>
|
|
Request Body에 적용
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
|
<div className="text-sm font-medium text-blue-800">매핑 사용 예시</div>
|
|
<div className="text-xs text-blue-600 mt-1 font-mono">
|
|
{`{"id": "{{id}}", "name": "{{user_name}}", "email": "{{email}}"}`}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}); |