- Replaced existing toast error messages with the new `showErrorToast` utility across multiple components, improving consistency in error reporting. - Updated error messages to provide more specific guidance for users, enhancing the overall user experience during error scenarios. - Ensured that all relevant error handling in batch management, external call configurations, cascading management, and screen management components now utilizes the new utility for better maintainability.
1791 lines
75 KiB
TypeScript
1791 lines
75 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, ArrowLeft, Save, RefreshCw, Globe, Database, Eye } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
|
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 MappingItem {
|
|
id: string;
|
|
dbColumn: string;
|
|
sourceType: "api" | "fixed";
|
|
apiField: string;
|
|
fixedValue: string;
|
|
}
|
|
|
|
interface RestApiToDbMappingCardProps {
|
|
fromApiFields: string[];
|
|
toColumns: BatchColumnInfo[];
|
|
fromApiData: any[];
|
|
mappingList: MappingItem[];
|
|
setMappingList: React.Dispatch<React.SetStateAction<MappingItem[]>>;
|
|
}
|
|
|
|
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 [authTokenMode, setAuthTokenMode] = useState<"direct" | "db">("direct"); // 직접입력 / DB에서 선택
|
|
const [authServiceName, setAuthServiceName] = useState("");
|
|
const [authServiceNames, setAuthServiceNames] = useState<string[]>([]);
|
|
|
|
// 연결 정보
|
|
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)
|
|
const [dataArrayPath, setDataArrayPath] = useState(""); // 데이터 배열 경로 (예: response, data.items)
|
|
|
|
// 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[]>([]);
|
|
|
|
// 통합 매핑 리스트
|
|
const [mappingList, setMappingList] = useState<MappingItem[]>([]);
|
|
|
|
// INSERT/UPSERT 설정
|
|
const [saveMode, setSaveMode] = useState<"INSERT" | "UPSERT">("INSERT");
|
|
const [conflictKey, setConflictKey] = useState("");
|
|
|
|
// 배치 타입 상태
|
|
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();
|
|
loadAuthServiceNames();
|
|
}, []);
|
|
|
|
// 인증 서비스명 목록 로드
|
|
const loadAuthServiceNames = async () => {
|
|
try {
|
|
const serviceNames = await BatchManagementAPI.getAuthServiceNames();
|
|
setAuthServiceNames(serviceNames);
|
|
} catch (error) {
|
|
console.error("인증 서비스 목록 로드 실패:", error);
|
|
}
|
|
};
|
|
|
|
// 배치 타입 변경 시 상태 초기화
|
|
useEffect(() => {
|
|
// 공통 초기화
|
|
setMappingList([]);
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 직접 입력 모드일 때만 토큰 검증
|
|
if (authTokenMode === "direct" && !fromApiKey) {
|
|
toast.error("인증 토큰을 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
// DB 선택 모드일 때 서비스명 검증
|
|
if (authTokenMode === "db" && !authServiceName) {
|
|
toast.error("인증 토큰 서비스를 선택해주세요.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await BatchManagementAPI.previewRestApiData(
|
|
fromApiUrl,
|
|
authTokenMode === "direct" ? fromApiKey : "", // 직접 입력일 때만 API 키 전달
|
|
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,
|
|
// DB 선택 모드일 때 서비스명 전달
|
|
authTokenMode === "db" ? authServiceName : undefined,
|
|
// 데이터 배열 경로 전달
|
|
dataArrayPath || 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") {
|
|
// 유효한 매핑만 필터링 (DB 컬럼이 선택되고, API 필드 또는 고정값이 있는 것)
|
|
const validMappings = mappingList.filter(
|
|
(m) => m.dbColumn && (m.sourceType === "api" ? m.apiField : m.fixedValue),
|
|
);
|
|
|
|
if (validMappings.length === 0) {
|
|
toast.error("최소 하나의 매핑을 설정해주세요.");
|
|
return;
|
|
}
|
|
|
|
// UPSERT 모드일 때 conflict key 검증
|
|
if (saveMode === "UPSERT" && !conflictKey) {
|
|
toast.error("UPSERT 모드에서는 충돌 기준 컬럼을 선택해주세요.");
|
|
return;
|
|
}
|
|
|
|
// 통합 매핑 리스트를 배치 매핑 형태로 변환
|
|
// 고정값 매핑도 동일한 from_connection_type을 사용해야 같은 그룹으로 처리됨
|
|
const apiMappings = validMappings.map((mapping) => ({
|
|
from_connection_type: "restapi" as const, // 고정값도 동일한 소스 타입 사용
|
|
from_table_name: fromEndpoint,
|
|
from_column_name: mapping.sourceType === "api" ? mapping.apiField : mapping.fixedValue,
|
|
from_api_url: fromApiUrl,
|
|
from_api_key: authTokenMode === "direct" ? fromApiKey : "",
|
|
from_api_method: fromApiMethod,
|
|
from_api_body:
|
|
fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE" ? fromApiBody : undefined,
|
|
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: mapping.dbColumn,
|
|
mapping_type: mapping.sourceType === "fixed" ? ("fixed" as const) : ("direct" as const),
|
|
fixed_value: mapping.sourceType === "fixed" ? mapping.fixedValue : undefined,
|
|
}));
|
|
|
|
// 실제 API 호출
|
|
try {
|
|
const result = await BatchManagementAPI.saveRestApiBatch({
|
|
batchName,
|
|
batchType,
|
|
cronSchedule,
|
|
description,
|
|
apiMappings,
|
|
authServiceName: authTokenMode === "db" ? authServiceName : undefined,
|
|
dataArrayPath: dataArrayPath || undefined,
|
|
saveMode,
|
|
conflictKey: saveMode === "UPSERT" ? conflictKey : undefined,
|
|
});
|
|
|
|
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);
|
|
showErrorToast("배치 설정 저장에 실패했습니다", error, {
|
|
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
|
|
});
|
|
}
|
|
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,
|
|
authServiceName: authServiceName || undefined,
|
|
});
|
|
|
|
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);
|
|
showErrorToast("배치 설정 저장에 실패했습니다", error, {
|
|
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
toast.error("지원하지 않는 배치 타입입니다.");
|
|
};
|
|
|
|
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>
|
|
|
|
{/* 기본 정보 */}
|
|
<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-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="h-4 w-4 text-blue-600" />
|
|
) : (
|
|
<Database className="h-4 w-4 text-green-600" />
|
|
)}
|
|
<div>
|
|
<div className="text-sm font-medium">{option.label}</div>
|
|
<div className="mt-1 text-xs text-gray-500">{option.description}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div>
|
|
<Label htmlFor="batchName">배치명 *</Label>
|
|
<Input
|
|
id="batchName"
|
|
value={batchName}
|
|
onChange={(e) => setBatchName(e.target.value)}
|
|
placeholder="배치명을 입력하세요"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="cronSchedule">실행 스케줄 *</Label>
|
|
<Input
|
|
id="cronSchedule"
|
|
value={cronSchedule}
|
|
onChange={(e) => setCronSchedule(e.target.value)}
|
|
placeholder="0 12 * * *"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="description">설명</Label>
|
|
<Textarea
|
|
id="description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="배치에 대한 설명을 입력하세요"
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* FROM/TO 설정 - 가로 배치 */}
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
{/* FROM 설정 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center">
|
|
{batchType === "restapi-to-db" ? (
|
|
<>
|
|
<Globe className="mr-2 h-5 w-5" />
|
|
FROM: REST API (소스)
|
|
</>
|
|
) : (
|
|
<>
|
|
<Database className="mr-2 h-5 w-5" />
|
|
FROM: 데이터베이스 (소스)
|
|
</>
|
|
)}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* REST API 설정 (REST API → DB) */}
|
|
{batchType === "restapi-to-db" && (
|
|
<div className="space-y-4">
|
|
{/* API 서버 URL */}
|
|
<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>인증 토큰 (Authorization)</Label>
|
|
{/* 토큰 설정 방식 선택 */}
|
|
<div className="mt-2 flex gap-4">
|
|
<label className="flex cursor-pointer items-center gap-1.5">
|
|
<input
|
|
type="radio"
|
|
name="authTokenMode"
|
|
value="direct"
|
|
checked={authTokenMode === "direct"}
|
|
onChange={() => {
|
|
setAuthTokenMode("direct");
|
|
setAuthServiceName("");
|
|
}}
|
|
className="h-3.5 w-3.5"
|
|
/>
|
|
<span className="text-xs">직접 입력</span>
|
|
</label>
|
|
<label className="flex cursor-pointer items-center gap-1.5">
|
|
<input
|
|
type="radio"
|
|
name="authTokenMode"
|
|
value="db"
|
|
checked={authTokenMode === "db"}
|
|
onChange={() => setAuthTokenMode("db")}
|
|
className="h-3.5 w-3.5"
|
|
/>
|
|
<span className="text-xs">DB에서 선택</span>
|
|
</label>
|
|
</div>
|
|
{/* 직접 입력 모드 */}
|
|
{authTokenMode === "direct" && (
|
|
<Input
|
|
id="fromApiKey"
|
|
value={fromApiKey}
|
|
onChange={(e) => setFromApiKey(e.target.value)}
|
|
placeholder="Bearer eyJhbGciOiJIUzI1NiIs..."
|
|
className="mt-2"
|
|
/>
|
|
)}
|
|
{/* DB 선택 모드 */}
|
|
{authTokenMode === "db" && (
|
|
<Select
|
|
value={authServiceName || "none"}
|
|
onValueChange={(value) => setAuthServiceName(value === "none" ? "" : value)}
|
|
>
|
|
<SelectTrigger className="mt-2">
|
|
<SelectValue placeholder="서비스명 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">선택 안 함</SelectItem>
|
|
{authServiceNames.map((name) => (
|
|
<SelectItem key={name} value={name}>
|
|
{name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
{authTokenMode === "direct"
|
|
? "API 호출 시 Authorization 헤더에 사용할 토큰을 입력하세요."
|
|
: "auth_tokens 테이블에서 선택한 서비스의 최신 토큰을 사용합니다."}
|
|
</p>
|
|
</div>
|
|
|
|
{/* 엔드포인트 */}
|
|
<div>
|
|
<Label htmlFor="fromEndpoint">엔드포인트 *</Label>
|
|
<Input
|
|
id="fromEndpoint"
|
|
value={fromEndpoint}
|
|
onChange={(e) => setFromEndpoint(e.target.value)}
|
|
placeholder="/api/users"
|
|
/>
|
|
</div>
|
|
|
|
{/* HTTP 메서드 */}
|
|
<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>
|
|
<Label htmlFor="dataArrayPath">데이터 배열 경로</Label>
|
|
<Input
|
|
id="dataArrayPath"
|
|
value={dataArrayPath}
|
|
onChange={(e) => setDataArrayPath(e.target.value)}
|
|
placeholder="response (예: data.items, results)"
|
|
/>
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
API 응답에서 배열 데이터가 있는 경로를 입력하세요. 비워두면 응답 전체를 사용합니다.
|
|
<br />
|
|
예시: response, data.items, result.list
|
|
</p>
|
|
</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="mt-1 text-xs text-gray-500">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="mt-1 text-sm text-gray-600">특정 사용자나 조건으로 데이터를 조회할 때 사용합니다.</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>
|
|
<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>
|
|
<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="mt-1 text-xs text-gray-500">
|
|
동적값은 배치 실행 시 설정된 값으로 치환됩니다. 예: {"{{user_id}}"} → 실제 사용자 ID
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{apiParamType === "url" && (
|
|
<div className="rounded-lg bg-blue-50 p-3">
|
|
<div className="text-sm font-medium text-blue-800">URL 파라미터 예시</div>
|
|
<div className="mt-1 text-sm text-blue-700">
|
|
엔드포인트: /api/users/{`{${apiParamName || "userId"}}`}
|
|
</div>
|
|
<div className="text-sm text-blue-700">실제 호출: /api/users/{apiParamValue || "123"}</div>
|
|
</div>
|
|
)}
|
|
|
|
{apiParamType === "query" && (
|
|
<div className="rounded-lg bg-green-50 p-3">
|
|
<div className="text-sm font-medium text-green-800">쿼리 파라미터 예시</div>
|
|
<div className="mt-1 text-sm text-green-700">
|
|
실제 호출: {fromEndpoint || "/api/users"}?{apiParamName || "userId"}=
|
|
{apiParamValue || "123"}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* API 호출 미리보기 정보 */}
|
|
{fromApiUrl && fromEndpoint && (
|
|
<div className="rounded-lg bg-gray-50 p-3">
|
|
<div className="text-sm font-medium text-gray-700">API 호출 정보</div>
|
|
<div className="mt-1 text-sm text-gray-600">
|
|
{fromApiMethod} {fromApiUrl}
|
|
{apiParamType === "url" && apiParamName && apiParamValue
|
|
? fromEndpoint.replace(`{${apiParamName}}`, apiParamValue) || fromEndpoint + `/${apiParamValue}`
|
|
: fromEndpoint}
|
|
{apiParamType === "query" && apiParamName && apiParamValue
|
|
? `?${apiParamName}=${apiParamValue}`
|
|
: ""}
|
|
</div>
|
|
{((authTokenMode === "direct" && fromApiKey) || (authTokenMode === "db" && authServiceName)) && (
|
|
<div className="mt-1 text-xs text-gray-500">
|
|
{authTokenMode === "direct"
|
|
? `Authorization: Bearer ${fromApiKey.substring(0, 15)}...`
|
|
: `Authorization: DB 토큰 (${authServiceName})`}
|
|
</div>
|
|
)}
|
|
{apiParamType !== "none" && apiParamName && apiParamValue && (
|
|
<div className="mt-1 text-xs text-blue-600">
|
|
파라미터: {apiParamName} = {apiParamValue} ({apiParamSource === "static" ? "고정값" : "동적값"})
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* DB 설정 (DB → REST API) */}
|
|
{batchType === "db-to-restapi" && (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<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="mt-2 grid max-h-60 grid-cols-2 gap-2 overflow-y-auto rounded-lg border p-3 md:grid-cols-3 lg:grid-cols-4">
|
|
{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="flex-1 cursor-pointer text-sm"
|
|
title={`타입: ${column.data_type} | NULL: ${column.is_nullable ? "Y" : "N"}`}
|
|
>
|
|
{column.column_name}
|
|
</label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 선택된 컬럼 개수 표시 */}
|
|
<div className="mt-2 text-xs text-gray-500">
|
|
선택된 컬럼: {selectedColumns.length}개 / 전체: {fromColumns.length}개
|
|
</div>
|
|
|
|
{/* 빠른 매핑 버튼들 */}
|
|
{selectedColumns.length > 0 && toApiFields.length > 0 && (
|
|
<div className="mt-3 rounded-lg border border-green-200 bg-green-50 p-3">
|
|
<div className="mb-2 text-sm font-medium text-green-800">빠른 매핑</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="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
|
|
>
|
|
스마트 자동 매핑
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDbToApiFieldMapping({});
|
|
toast.success("매핑이 초기화되었습니다.");
|
|
}}
|
|
className="rounded bg-gray-600 px-3 py-1 text-xs text-white hover:bg-gray-700"
|
|
>
|
|
매핑 초기화
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 자동 생성된 JSON 미리보기 */}
|
|
{selectedColumns.length > 0 && (
|
|
<div className="mt-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
|
<div className="mb-2 text-sm font-medium text-blue-800">자동 생성된 JSON 구조</div>
|
|
<pre className="overflow-x-auto font-mono text-xs text-blue-600">
|
|
{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 rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
|
|
>
|
|
Request Body에 적용
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* TO 설정 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center">
|
|
{batchType === "restapi-to-db" ? (
|
|
<>
|
|
<Database className="mr-2 h-5 w-5" />
|
|
TO: 데이터베이스 (대상)
|
|
</>
|
|
) : (
|
|
<>
|
|
<Globe className="mr-2 h-5 w-5" />
|
|
TO: REST API (대상)
|
|
</>
|
|
)}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* DB 설정 (REST API → DB) - 단계적 활성화 */}
|
|
{batchType === "restapi-to-db" && (
|
|
<div className="space-y-4">
|
|
{/* 1. 커넥션 선택 - 항상 활성화 */}
|
|
<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>
|
|
|
|
{/* 2. 테이블 선택 - 커넥션 선택 후 활성화 */}
|
|
<div className={toTables.length === 0 ? "pointer-events-none opacity-50" : ""}>
|
|
<Label>테이블 선택 *</Label>
|
|
<Select onValueChange={handleToTableChange} disabled={toTables.length === 0}>
|
|
<SelectTrigger>
|
|
<SelectValue
|
|
placeholder={toTables.length === 0 ? "먼저 커넥션을 선택하세요" : "테이블을 선택하세요"}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{toTables.map((table: string) => (
|
|
<SelectItem key={table} value={table}>
|
|
{table.toUpperCase()}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 3. 저장 모드 - 테이블 선택 후 활성화 */}
|
|
<div className={!toTable ? "pointer-events-none opacity-50" : ""}>
|
|
<Label>저장 모드</Label>
|
|
<div className="mt-2 flex gap-4">
|
|
<label className="flex cursor-pointer items-center gap-2">
|
|
<input
|
|
type="radio"
|
|
name="saveMode"
|
|
value="INSERT"
|
|
checked={saveMode === "INSERT"}
|
|
onChange={() => {
|
|
setSaveMode("INSERT");
|
|
setConflictKey("");
|
|
}}
|
|
className="h-4 w-4"
|
|
disabled={!toTable}
|
|
/>
|
|
<span className="text-sm">INSERT (새 데이터 추가)</span>
|
|
</label>
|
|
<label className="flex cursor-pointer items-center gap-2">
|
|
<input
|
|
type="radio"
|
|
name="saveMode"
|
|
value="UPSERT"
|
|
checked={saveMode === "UPSERT"}
|
|
onChange={() => setSaveMode("UPSERT")}
|
|
className="h-4 w-4"
|
|
disabled={!toTable}
|
|
/>
|
|
<span className="text-sm">UPSERT (있으면 업데이트, 없으면 추가)</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 4. 충돌 기준 컬럼 - UPSERT 선택 후 활성화 */}
|
|
<div className={saveMode !== "UPSERT" ? "pointer-events-none opacity-50" : ""}>
|
|
<Label htmlFor="conflictKey">충돌 기준 컬럼 (Conflict Key) *</Label>
|
|
<Select value={conflictKey} onValueChange={setConflictKey} disabled={saveMode !== "UPSERT"}>
|
|
<SelectTrigger className="mt-2">
|
|
<SelectValue
|
|
placeholder={
|
|
saveMode !== "UPSERT" ? "UPSERT 모드를 선택하세요" : "UPSERT 기준 컬럼을 선택하세요"
|
|
}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{toColumns.map((col) => (
|
|
<SelectItem key={col.column_name} value={col.column_name}>
|
|
{col.column_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
이 컬럼 값이 같으면 UPDATE, 없으면 INSERT 합니다. (예: device_serial_number)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* REST API 설정 (DB → REST API) */}
|
|
{batchType === "db-to-restapi" && (
|
|
<div className="space-y-4">
|
|
{/* API 서버 URL */}
|
|
<div>
|
|
<Label htmlFor="toApiUrl">API 서버 URL *</Label>
|
|
<Input
|
|
id="toApiUrl"
|
|
value={toApiUrl}
|
|
onChange={(e) => setToApiUrl(e.target.value)}
|
|
placeholder="https://api.example.com"
|
|
/>
|
|
</div>
|
|
|
|
{/* API 키 */}
|
|
<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>
|
|
<Label htmlFor="toEndpoint">엔드포인트 *</Label>
|
|
<Input
|
|
id="toEndpoint"
|
|
value={toEndpoint}
|
|
onChange={(e) => setToEndpoint(e.target.value)}
|
|
placeholder="/api/users"
|
|
/>
|
|
{(toApiMethod === "PUT" || toApiMethod === "DELETE") && (
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
실제 URL: {toEndpoint}/{urlPathColumn ? `{${urlPathColumn}}` : "{ID}"}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* HTTP 메서드 */}
|
|
<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>
|
|
|
|
{/* 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="mt-1 text-xs text-gray-500">
|
|
PUT/DELETE 요청 시 URL 경로에 포함될 컬럼을 선택하세요. (예: USER_ID → /api/users/user123)
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* TO API 미리보기 버튼 */}
|
|
<div className="flex justify-center">
|
|
<button
|
|
type="button"
|
|
onClick={previewToApiData}
|
|
className="flex items-center space-x-2 rounded-md bg-green-600 px-4 py-2 text-white hover:bg-green-700"
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
<span>API 필드 미리보기</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* TO API 필드 표시 */}
|
|
{toApiFields.length > 0 && (
|
|
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
|
|
<div className="mb-2 text-sm font-medium text-green-800">
|
|
API 필드 목록 ({toApiFields.length}개)
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{toApiFields.map((field) => (
|
|
<span key={field} className="rounded bg-green-100 px-2 py-1 text-xs text-green-700">
|
|
{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="h-24 w-full rounded-md border p-2 font-mono text-sm"
|
|
/>
|
|
<div className="mt-1 text-xs text-gray-500">
|
|
DB 컬럼 값을 {"{{컬럼명}}"} 형태로 매핑하세요. 예: {"{{user_id}}, {{user_name}}"}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* API 호출 정보 */}
|
|
{toApiUrl && toApiKey && toEndpoint && (
|
|
<div className="rounded-lg bg-gray-50 p-3">
|
|
<div className="text-sm font-medium text-gray-700">API 호출 정보</div>
|
|
<div className="mt-1 text-sm text-gray-600">
|
|
{toApiMethod} {toApiUrl}
|
|
{toEndpoint}
|
|
</div>
|
|
<div className="mt-1 text-xs text-gray-500">Headers: X-API-Key: {toApiKey.substring(0, 10)}...</div>
|
|
{toApiBody && (
|
|
<div className="mt-1 text-xs text-blue-600">Body: {toApiBody.substring(0, 50)}...</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* API 데이터 미리보기 버튼 - FROM/TO 섹션 아래 */}
|
|
{batchType === "restapi-to-db" && (
|
|
<div className="flex justify-center">
|
|
<Button
|
|
onClick={previewRestApiData}
|
|
variant="outline"
|
|
disabled={!fromApiUrl || !fromEndpoint || !toTable}
|
|
className="gap-2"
|
|
>
|
|
<RefreshCw className="h-4 w-4" />
|
|
데이터 불러오고 매핑하기
|
|
</Button>
|
|
{(!fromApiUrl || !fromEndpoint || !toTable) && (
|
|
<p className="ml-4 flex items-center text-xs text-gray-500">
|
|
FROM 섹션과 TO 섹션의 필수 값을 모두 입력해야 합니다.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 매핑 UI - 배치 타입별 동적 렌더링 */}
|
|
{/* REST API → DB 매핑 */}
|
|
{batchType === "restapi-to-db" && fromApiFields.length > 0 && toColumns.length > 0 && (
|
|
<RestApiToDbMappingCard
|
|
fromApiFields={fromApiFields}
|
|
toColumns={toColumns}
|
|
fromApiData={fromApiData}
|
|
mappingList={mappingList}
|
|
setMappingList={setMappingList}
|
|
/>
|
|
)}
|
|
|
|
{/* 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}
|
|
/>
|
|
)}
|
|
|
|
{/* 하단 액션 버튼 */}
|
|
<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" />
|
|
새로고침
|
|
</Button>
|
|
<Button onClick={handleSave} className="gap-2">
|
|
<Save className="h-4 w-4" />
|
|
저장
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
|
|
fromApiFields,
|
|
toColumns,
|
|
fromApiData,
|
|
mappingList,
|
|
setMappingList,
|
|
}: RestApiToDbMappingCardProps) {
|
|
// 샘플 JSON 문자열
|
|
const sampleJsonList = useMemo(
|
|
() => fromApiData.slice(0, 3).map((item) => JSON.stringify(item, null, 2)),
|
|
[fromApiData],
|
|
);
|
|
|
|
const firstSample = fromApiData[0] || null;
|
|
|
|
// 이미 매핑된 DB 컬럼들
|
|
const mappedDbColumns = useMemo(() => mappingList.map((m) => m.dbColumn).filter(Boolean), [mappingList]);
|
|
|
|
// 매핑 추가
|
|
const addMapping = () => {
|
|
const newId = `mapping-${Date.now()}`;
|
|
setMappingList((prev) => [
|
|
...prev,
|
|
{
|
|
id: newId,
|
|
dbColumn: "",
|
|
sourceType: "api",
|
|
apiField: "",
|
|
fixedValue: "",
|
|
},
|
|
]);
|
|
};
|
|
|
|
// 매핑 삭제
|
|
const removeMapping = (id: string) => {
|
|
setMappingList((prev) => prev.filter((m) => m.id !== id));
|
|
};
|
|
|
|
// 매핑 업데이트
|
|
const updateMapping = (id: string, updates: Partial<MappingItem>) => {
|
|
setMappingList((prev) => prev.map((m) => (m.id === id ? { ...m, ...updates } : m)));
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>컬럼 매핑 설정</CardTitle>
|
|
<CardDescription>DB 컬럼에 API 필드 또는 고정값을 매핑합니다.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
{/* 왼쪽: 샘플 데이터 */}
|
|
<div className="flex flex-col">
|
|
<div className="mb-3 flex h-8 items-center">
|
|
<h4 className="text-sm font-semibold">샘플 데이터 (최대 3개)</h4>
|
|
</div>
|
|
{sampleJsonList.length > 0 ? (
|
|
<div className="bg-muted/30 h-[360px] overflow-y-auto rounded-lg border p-3">
|
|
<div className="space-y-2">
|
|
{sampleJsonList.map((json, index) => (
|
|
<div key={index} className="bg-background rounded border p-2">
|
|
<pre className="font-mono text-xs whitespace-pre-wrap">{json}</pre>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-[360px] items-center justify-center rounded-lg border border-dashed">
|
|
<p className="text-muted-foreground text-sm">
|
|
API 데이터 미리보기를 실행하면 샘플 데이터가 표시됩니다.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 오른쪽: 매핑 영역 (스크롤) */}
|
|
<div className="flex flex-col">
|
|
<div className="mb-3 flex h-8 items-center justify-between">
|
|
<h4 className="text-sm font-semibold">매핑 설정</h4>
|
|
<Button variant="outline" size="sm" onClick={addMapping} className="h-8 gap-1">
|
|
<Plus className="h-4 w-4" />
|
|
매핑 추가
|
|
</Button>
|
|
</div>
|
|
|
|
{mappingList.length === 0 ? (
|
|
<div className="flex h-[360px] flex-col items-center justify-center rounded-lg border border-dashed text-center">
|
|
<p className="text-muted-foreground text-sm">매핑이 없습니다.</p>
|
|
<Button variant="link" onClick={addMapping} className="mt-2">
|
|
매핑 추가하기
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="bg-muted/30 h-[360px] space-y-3 overflow-y-auto rounded-lg border p-3">
|
|
{mappingList.map((mapping, index) => (
|
|
<div key={mapping.id} className="bg-background flex items-center gap-2 rounded-lg border p-3">
|
|
{/* 순서 표시 */}
|
|
<div className="bg-primary/10 text-primary flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium">
|
|
{index + 1}
|
|
</div>
|
|
|
|
{/* DB 컬럼 선택 (좌측 - TO) */}
|
|
<div className="w-36 shrink-0">
|
|
<Select
|
|
value={mapping.dbColumn || "none"}
|
|
onValueChange={(value) =>
|
|
updateMapping(mapping.id, { dbColumn: value === "none" ? "" : value })
|
|
}
|
|
>
|
|
<SelectTrigger className="h-9">
|
|
<SelectValue placeholder="DB 컬럼" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">선택 안함</SelectItem>
|
|
{toColumns.map((col) => {
|
|
const isUsed =
|
|
mappedDbColumns.includes(col.column_name) && mapping.dbColumn !== col.column_name;
|
|
return (
|
|
<SelectItem key={col.column_name} value={col.column_name} disabled={isUsed}>
|
|
<div className="flex items-center gap-2">
|
|
<span className={isUsed ? "text-muted-foreground" : ""}>{col.column_name}</span>
|
|
<span className="text-muted-foreground text-xs">({col.data_type})</span>
|
|
</div>
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 화살표 */}
|
|
<ArrowLeft className="text-muted-foreground h-4 w-4 shrink-0" />
|
|
|
|
{/* 소스 타입 선택 */}
|
|
<div className="w-24 shrink-0">
|
|
<Select
|
|
value={mapping.sourceType}
|
|
onValueChange={(value: "api" | "fixed") =>
|
|
updateMapping(mapping.id, {
|
|
sourceType: value,
|
|
apiField: value === "fixed" ? "" : mapping.apiField,
|
|
fixedValue: value === "api" ? "" : mapping.fixedValue,
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-9">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="api">API 필드</SelectItem>
|
|
<SelectItem value="fixed">고정값</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* API 필드 선택 또는 고정값 입력 (우측 - FROM) */}
|
|
<div className="min-w-0 flex-1">
|
|
{mapping.sourceType === "api" ? (
|
|
<Select
|
|
value={mapping.apiField || "none"}
|
|
onValueChange={(value) =>
|
|
updateMapping(mapping.id, { apiField: value === "none" ? "" : value })
|
|
}
|
|
>
|
|
<SelectTrigger className="h-9">
|
|
<SelectValue placeholder="API 필드" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">선택 안함</SelectItem>
|
|
{fromApiFields.map((field) => (
|
|
<SelectItem key={field} value={field}>
|
|
<div className="flex items-center gap-2">
|
|
<span>{field}</span>
|
|
{firstSample && firstSample[field] !== undefined && (
|
|
<span className="text-muted-foreground text-xs">
|
|
(예: {String(firstSample[field]).substring(0, 15)}
|
|
{String(firstSample[field]).length > 15 ? "..." : ""})
|
|
</span>
|
|
)}
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
value={mapping.fixedValue}
|
|
onChange={(e) => updateMapping(mapping.id, { fixedValue: e.target.value })}
|
|
placeholder="고정값 입력"
|
|
className="h-9"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* 삭제 버튼 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => removeMapping(mapping.id)}
|
|
className="text-muted-foreground hover:text-destructive h-8 w-8 shrink-0"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</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="max-h-96 space-y-3 overflow-y-auto rounded-lg border p-4">
|
|
{selectedColumnObjects.map((column) => (
|
|
<div key={column.column_name} className="flex items-center space-x-4 rounded-lg bg-gray-50 p-3">
|
|
{/* DB 컬럼 정보 */}
|
|
<div className="flex-1">
|
|
<div className="text-sm font-medium">{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="mt-2 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
onChange={(e) => {
|
|
setDbToApiFieldMapping((prev) => ({
|
|
...prev,
|
|
[column.column_name]: e.target.value,
|
|
}));
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<div className="mt-1 text-xs text-gray-500">
|
|
{dbToApiFieldMapping[column.column_name]
|
|
? `매핑: ${column.column_name} → ${dbToApiFieldMapping[column.column_name]}`
|
|
: `기본값: ${column.column_name} (DB 컬럼명 사용)`}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 템플릿 미리보기 */}
|
|
<div className="flex-1">
|
|
<div className="rounded border bg-white p-2 font-mono text-sm">{`{{${column.column_name}}}`}</div>
|
|
<div className="mt-1 text-xs text-gray-500">실제 DB 값으로 치환됩니다</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{selectedColumns.length > 0 && (
|
|
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
|
<div className="text-sm font-medium text-blue-800">자동 생성된 JSON 구조</div>
|
|
<pre className="mt-1 overflow-x-auto font-mono text-xs text-blue-600">{autoJsonPreview}</pre>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setToApiBody(autoJsonPreview);
|
|
toast.success("Request Body에 자동 생성된 JSON이 적용되었습니다.");
|
|
}}
|
|
className="mt-2 rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
|
|
>
|
|
Request Body에 적용
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
|
<div className="text-sm font-medium text-blue-800">매핑 사용 예시</div>
|
|
<div className="mt-1 font-mono text-xs text-blue-600">
|
|
{'{"id": "{{id}}", "name": "{{user_name}}", "email": "{{email}}"}'}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
});
|