ERP-node/frontend/app/(main)/admin/batch-management-new/page.tsx

1733 lines
71 KiB
TypeScript
Raw Normal View History

"use client";
2025-11-27 11:48:03 +09:00
import React, { useState, useEffect, useMemo, memo } from "react";
2025-09-26 17:29:20 +09:00
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";
2025-09-26 17:29:20 +09:00
import { BatchManagementAPI } from "@/lib/api/batchManagement";
// 타입 정의
type BatchType = "db-to-restapi" | "restapi-to-db" | "restapi-to-restapi";
2025-09-26 17:29:20 +09:00
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;
}
2025-11-27 11:48:03 +09:00
interface RestApiToDbMappingCardProps {
fromApiFields: string[];
toColumns: BatchColumnInfo[];
fromApiData: any[];
mappingList: MappingItem[];
setMappingList: React.Dispatch<React.SetStateAction<MappingItem[]>>;
2025-11-27 11:48:03 +09:00
}
interface DbToRestApiMappingCardProps {
fromColumns: BatchColumnInfo[];
selectedColumns: string[];
toApiFields: string[];
dbToApiFieldMapping: Record<string, string>;
setDbToApiFieldMapping: React.Dispatch<React.SetStateAction<Record<string, string>>>;
2025-11-27 11:48:03 +09:00
setToApiBody: (body: string) => void;
}
export default function BatchManagementNewPage() {
2025-09-26 17:29:20 +09:00
const router = useRouter();
// 기본 상태
const [batchName, setBatchName] = useState("");
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
const [description, setDescription] = useState("");
2025-09-26 17:29:20 +09:00
// 인증 토큰 설정
const [authTokenMode, setAuthTokenMode] = useState<"direct" | "db">("direct"); // 직접입력 / DB에서 선택
const [authServiceName, setAuthServiceName] = useState("");
const [authServiceNames, setAuthServiceNames] = useState<string[]>([]);
2025-09-26 17:29:20 +09:00
// 연결 정보
const [connections, setConnections] = useState<BatchConnectionInfo[]>([]);
2025-09-26 17:29:20 +09:00
const [toConnection, setToConnection] = useState<BatchConnectionInfo | null>(null);
const [toTables, setToTables] = useState<string[]>([]);
2025-09-26 17:29:20 +09:00
const [toTable, setToTable] = useState("");
const [toColumns, setToColumns] = useState<BatchColumnInfo[]>([]);
2025-09-26 17:29:20 +09:00
// 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"); // 정적 값 또는 동적 값
2025-09-26 17:29:20 +09:00
// DB → REST API용 상태
const [fromConnection, setFromConnection] = useState<BatchConnectionInfo | null>(null);
2025-09-26 17:29:20 +09:00
const [fromTables, setFromTables] = useState<string[]>([]);
const [fromTable, setFromTable] = useState("");
2025-09-26 17:29:20 +09:00
const [fromColumns, setFromColumns] = useState<BatchColumnInfo[]>([]);
const [selectedColumns, setSelectedColumns] = useState<string[]>([]); // 선택된 컬럼들
const [dbToApiFieldMapping, setDbToApiFieldMapping] = useState<Record<string, string>>({}); // DB 컬럼 → API 필드 매핑
2025-09-26 17:29:20 +09:00
// 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 템플릿
2025-09-26 17:29:20 +09:00
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("");
2025-09-26 17:29:20 +09:00
// 배치 타입 상태
const [batchType, setBatchType] = useState<BatchType>("restapi-to-db");
2025-09-26 17:29:20 +09:00
// 배치 타입 옵션
const batchTypeOptions: BatchTypeOption[] = [
{
value: "restapi-to-db",
label: "REST API → DB",
description: "REST API에서 데이터베이스로 데이터 수집",
2025-09-26 17:29:20 +09:00
},
{
value: "db-to-restapi",
label: "DB → REST API",
description: "데이터베이스에서 REST API로 데이터 전송",
},
2025-09-26 17:29:20 +09:00
];
// 초기 데이터 로드
useEffect(() => {
loadConnections();
loadAuthServiceNames();
}, []);
// 인증 서비스명 목록 로드
const loadAuthServiceNames = async () => {
try {
const serviceNames = await BatchManagementAPI.getAuthServiceNames();
setAuthServiceNames(serviceNames);
} catch (error) {
console.error("인증 서비스 목록 로드 실패:", error);
}
};
2025-09-26 17:29:20 +09:00
// 배치 타입 변경 시 상태 초기화
useEffect(() => {
// 공통 초기화
setMappingList([]);
2025-09-26 17:29:20 +09:00
// REST API → DB 관련 초기화
setToConnection(null);
setToTables([]);
setToTable("");
setToColumns([]);
setFromApiUrl("");
setFromApiKey("");
setFromEndpoint("");
setFromApiData([]);
setFromApiFields([]);
2025-09-26 17:29:20 +09:00
// DB → REST API 관련 초기화
setFromConnection(null);
setFromTables([]);
setFromTable("");
setFromColumns([]);
2025-09-26 17:29:20 +09:00
setSelectedColumns([]);
setDbToApiFieldMapping({});
setToApiUrl("");
setToApiKey("");
setToEndpoint("");
setToApiBody("");
setToApiFields([]);
}, [batchType]);
// 연결 목록 로드
const loadConnections = async () => {
try {
2025-09-26 17:29:20 +09:00
const result = await BatchManagementAPI.getAvailableConnections();
setConnections(result || []);
} catch (error) {
2025-09-26 17:29:20 +09:00
console.error("연결 목록 로드 오류:", error);
toast.error("연결 목록을 불러오는데 실패했습니다.");
}
};
2025-09-26 17:29:20 +09:00
// TO 연결 변경 핸들러
const handleToConnectionChange = async (connectionValue: string) => {
let connection: BatchConnectionInfo | null = null;
if (connectionValue === "internal") {
2025-09-26 17:29:20 +09:00
// 내부 데이터베이스 선택
connection = connections.find((conn) => conn.type === "internal") || null;
2025-09-26 17:29:20 +09:00
} else {
// 외부 데이터베이스 선택
const connectionId = parseInt(connectionValue);
connection = connections.find((conn) => conn.id === connectionId) || null;
2025-09-26 17:29:20 +09:00
}
setToConnection(connection);
setToTable("");
setToColumns([]);
2025-09-26 17:29:20 +09:00
if (connection) {
try {
const connectionType = connection.type === "internal" ? "internal" : "external";
2025-09-26 17:29:20 +09:00
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)))
2025-09-26 17:29:20 +09:00
: [];
setToTables(tableNames);
} catch (error) {
console.error("테이블 목록 로드 오류:", error);
toast.error("테이블 목록을 불러오는데 실패했습니다.");
}
}
};
2025-09-26 17:29:20 +09:00
// TO 테이블 변경 핸들러
const handleToTableChange = async (tableName: string) => {
setToTable(tableName);
setToColumns([]);
if (toConnection && tableName) {
try {
const connectionType = toConnection.type === "internal" ? "internal" : "external";
2025-09-26 17:29:20 +09:00
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;
2025-09-26 17:29:20 +09:00
} else {
const connectionId = parseInt(connectionValue);
connection = connections.find((conn) => conn.id === connectionId) || null;
2025-09-26 17:29:20 +09:00
}
setFromConnection(connection);
setFromTable("");
setFromColumns([]);
if (connection) {
try {
const connectionType = connection.type === "internal" ? "internal" : "external";
2025-09-26 17:29:20 +09:00
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)))
2025-09-26 17:29:20 +09:00
: [];
setFromTables(tableNames);
} catch (error) {
console.error("테이블 목록 로드 오류:", error);
toast.error("테이블 목록을 불러오는데 실패했습니다.");
}
}
};
// FROM 테이블 변경 핸들러 (DB → REST API용)
const handleFromTableChange = async (tableName: string) => {
setFromTable(tableName);
2025-09-26 17:29:20 +09:00
setFromColumns([]);
setSelectedColumns([]); // 선택된 컬럼도 초기화
setDbToApiFieldMapping({}); // 매핑도 초기화
if (fromConnection && tableName) {
try {
const connectionType = fromConnection.type === "internal" ? "internal" : "external";
2025-09-26 17:29:20 +09:00
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 {
2025-09-26 17:29:20 +09:00
const result = await BatchManagementAPI.previewRestApiData(
toApiUrl,
toApiKey,
toEndpoint,
"GET", // 미리보기는 항상 GET으로
);
2025-09-26 17:29:20 +09:00
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) {
2025-09-26 17:29:20 +09:00
console.error("❌ TO API 미리보기 오류:", error);
toast.error("TO API 미리보기에 실패했습니다.");
setToApiFields([]);
}
};
2025-09-26 17:29:20 +09:00
// 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("인증 토큰 서비스를 선택해주세요.");
2025-09-26 17:29:20 +09:00
return;
}
try {
2025-09-26 17:29:20 +09:00
const result = await BatchManagementAPI.previewRestApiData(
fromApiUrl,
authTokenMode === "direct" ? fromApiKey : "", // 직접 입력일 때만 API 키 전달
2025-09-26 17:29:20 +09:00
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,
);
2025-09-26 17:29:20 +09:00
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);
2025-09-26 17:29:20 +09:00
toast.success(`API 데이터 미리보기 완료! ${extractedFields.length}개 필드, ${result.samples.length}개 레코드`);
} else {
setFromApiFields([]);
setFromApiData([]);
toast.warning("API에서 데이터를 가져올 수 없습니다.");
}
} catch (error) {
2025-09-26 17:29:20 +09:00
console.error("REST API 미리보기 오류:", error);
toast.error("API 데이터 미리보기에 실패했습니다.");
setFromApiFields([]);
setFromApiData([]);
}
};
2025-09-26 17:29:20 +09:00
// 배치 설정 저장
const handleSave = async () => {
if (!batchName.trim()) {
toast.error("배치명을 입력해주세요.");
return;
}
2025-09-26 17:29:20 +09:00
// 배치 타입별 검증 및 저장
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("최소 하나의 매핑을 설정해주세요.");
2025-09-26 17:29:20 +09:00
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,
}));
2025-09-26 17:29:20 +09:00
// 실제 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,
2025-09-26 17:29:20 +09:00
});
if (result.success) {
toast.success(result.message || "REST API 배치 설정이 저장되었습니다.");
setTimeout(() => {
router.push("/admin/batchmng");
2025-09-26 17:29:20 +09:00
}, 1000);
} else {
toast.error(result.message || "배치 저장에 실패했습니다.");
}
} catch (error) {
console.error("배치 저장 오류:", error);
toast.error("배치 저장 중 오류가 발생했습니다.");
}
return;
} else if (batchType === "db-to-restapi") {
2025-09-26 17:29:20 +09:00
// DB → REST API 배치 검증
if (!fromConnection || !fromTable || selectedColumns.length === 0) {
toast.error("소스 데이터베이스, 테이블, 컬럼을 선택해주세요.");
return;
}
2025-09-26 17:29:20 +09:00
if (!toApiUrl || !toApiKey || !toEndpoint) {
toast.error("대상 API URL, API Key, 엔드포인트를 입력해주세요.");
return;
}
if ((toApiMethod === "POST" || toApiMethod === "PUT") && !toApiBody) {
2025-09-26 17:29:20 +09:00
toast.error("POST/PUT 메서드의 경우 Request Body 템플릿을 입력해주세요.");
return;
}
2025-09-26 17:29:20 +09:00
// DELETE의 경우 빈 Request Body라도 템플릿 로직을 위해 "{}" 설정
let finalToApiBody = toApiBody;
if (toApiMethod === "DELETE" && !finalToApiBody.trim()) {
finalToApiBody = "{}";
2025-09-26 17:29:20 +09:00
}
2025-09-26 17:29:20 +09:00
// DB → REST API 매핑 생성 (선택된 컬럼만)
const selectedColumnObjects = fromColumns.filter((column) => selectedColumns.includes(column.column_name));
2025-09-26 17:29:20 +09:00
const dbMappings = selectedColumnObjects.map((column, index) => ({
from_connection_type: fromConnection.type === "internal" ? "internal" : "external",
from_connection_id: fromConnection.type === "internal" ? undefined : fromConnection.id,
2025-09-26 17:29:20 +09:00
from_table_name: fromTable,
from_column_name: column.column_name,
from_column_type: column.data_type,
to_connection_type: "restapi" as const,
2025-09-26 17:29:20 +09:00
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,
2025-09-26 17:29:20 +09:00
}));
2025-09-26 17:29:20 +09:00
// URL 경로 파라미터 매핑 추가 (PUT/DELETE용)
if ((toApiMethod === "PUT" || toApiMethod === "DELETE") && urlPathColumn) {
const urlPathColumnObject = fromColumns.find((col) => col.column_name === urlPathColumn);
2025-09-26 17:29:20 +09:00
if (urlPathColumnObject) {
dbMappings.push({
from_connection_type: fromConnection.type === "internal" ? "internal" : "external",
from_connection_id: fromConnection.type === "internal" ? undefined : fromConnection.id,
2025-09-26 17:29:20 +09:00
from_table_name: fromTable,
from_column_name: urlPathColumn,
from_column_type: urlPathColumnObject.data_type,
to_connection_type: "restapi" as const,
2025-09-26 17:29:20 +09:00
to_table_name: toEndpoint,
to_column_name: "URL_PATH_PARAM", // 특별한 식별자
2025-09-26 17:29:20 +09:00
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, // 마지막 순서
2025-09-26 17:29:20 +09:00
});
}
}
2025-09-26 17:29:20 +09:00
// 실제 API 호출 (기존 saveRestApiBatch 재사용)
try {
const result = await BatchManagementAPI.saveRestApiBatch({
batchName,
batchType,
cronSchedule,
description,
apiMappings: dbMappings,
authServiceName: authServiceName || undefined,
2025-09-26 17:29:20 +09:00
});
if (result.success) {
toast.success(result.message || "DB → REST API 배치 설정이 저장되었습니다.");
setTimeout(() => {
router.push("/admin/batchmng");
2025-09-26 17:29:20 +09:00
}, 1000);
} else {
toast.error(result.message || "배치 저장에 실패했습니다.");
}
} catch (error) {
console.error("배치 저장 오류:", error);
toast.error("배치 저장 중 오류가 발생했습니다.");
}
return;
}
2025-09-26 17:29:20 +09:00
toast.error("지원하지 않는 배치 타입입니다.");
};
return (
<div className="container mx-auto space-y-6 p-6">
<div className="flex items-center justify-between">
2025-09-26 17:29:20 +09:00
<h1 className="text-3xl font-bold"> </h1>
<div className="flex space-x-2">
<Button onClick={loadConnections} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
<Button onClick={handleSave}>
<Save className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
2025-09-26 17:29:20 +09:00
{/* 배치 타입 선택 */}
<div>
<Label> *</Label>
<div className="mt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
2025-09-26 17:29:20 +09:00
{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"
2025-09-26 17:29:20 +09:00
}`}
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" />
2025-09-26 17:29:20 +09:00
) : (
<Database className="h-4 w-4 text-green-600" />
2025-09-26 17:29:20 +09:00
)}
<div>
<div className="text-sm font-medium">{option.label}</div>
<div className="mt-1 text-xs text-gray-500">{option.description}</div>
2025-09-26 17:29:20 +09:00
</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>
2025-09-26 17:29:20 +09:00
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="배치에 대한 설명을 입력하세요"
/>
</div>
</CardContent>
</Card>
2025-09-26 17:29:20 +09:00
{/* FROM 설정 */}
<Card>
<CardHeader>
2025-09-26 17:29:20 +09:00
<CardTitle className="flex items-center">
{batchType === "restapi-to-db" ? (
2025-09-26 17:29:20 +09:00
<>
<Globe className="mr-2 h-5 w-5" />
2025-09-26 17:29:20 +09:00
FROM: REST API ()
</>
) : (
<>
<Database className="mr-2 h-5 w-5" />
2025-09-26 17:29:20 +09:00
FROM: 데이터베이스 ()
</>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
2025-09-26 17:29:20 +09:00
{/* REST API 설정 (REST API → DB) */}
{batchType === "restapi-to-db" && (
2025-09-26 17:29:20 +09:00
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
2025-09-26 17:29:20 +09:00
<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>
2025-09-26 17:29:20 +09:00
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
2025-09-26 17:29:20 +09:00
<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>
2025-09-26 17:29:20 +09:00
</SelectContent>
</Select>
</div>
</div>
2025-09-26 17:29:20 +09:00
{/* 데이터 배열 경로 */}
<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>
2025-09-26 17:29:20 +09:00
</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 className="grid grid-cols-1 gap-4 md:grid-cols-2">
<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="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 URL + 엔드포인트 + 인증 토큰 설정 완료 시 미리보기 버튼 표시 */}
{fromApiUrl &&
fromEndpoint &&
((authTokenMode === "direct" && fromApiKey) || (authTokenMode === "db" && authServiceName)) && (
<div className="space-y-3">
<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>
<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>
<Button onClick={previewRestApiData} variant="outline" className="w-full">
<RefreshCw className="mr-2 h-4 w-4" />
API
</Button>
2025-09-26 17:29:20 +09:00
</div>
)}
</div>
)}
2025-09-26 17:29:20 +09:00
{/* DB 설정 (DB → REST API) */}
{batchType === "db-to-restapi" && (
2025-09-26 17:29:20 +09:00
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
2025-09-26 17:29:20 +09:00
<div>
<Label> *</Label>
<Select
value={fromConnection?.id?.toString() || fromConnection?.type || ""}
onValueChange={handleFromConnectionChange}
>
2025-09-26 17:29:20 +09:00
<SelectTrigger>
<SelectValue placeholder="연결을 선택하세요" />
</SelectTrigger>
<SelectContent>
{connections.map((connection) => (
<SelectItem
key={connection.id || "internal"}
value={connection.id ? connection.id.toString() : "internal"}
2025-09-26 17:29:20 +09:00
>
{connection.name} ({connection.type === "internal" ? "내부 DB" : connection.db_type})
2025-09-26 17:29:20 +09:00
</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>
))}
2025-09-26 17:29:20 +09:00
</SelectContent>
</Select>
</div>
</div>
2025-09-26 17:29:20 +09:00
{/* 컬럼 선택 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">
2025-09-26 17:29:20 +09:00
{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));
2025-09-26 17:29:20 +09:00
}
}}
className="rounded border-gray-300"
/>
<label
2025-09-26 17:29:20 +09:00
htmlFor={`col-${column.column_name}`}
className="flex-1 cursor-pointer text-sm"
title={`타입: ${column.data_type} | NULL: ${column.is_nullable ? "Y" : "N"}`}
2025-09-26 17:29:20 +09:00
>
{column.column_name}
</label>
</div>
))}
</div>
2025-09-26 17:29:20 +09:00
{/* 선택된 컬럼 개수 표시 */}
<div className="mt-2 text-xs text-gray-500">
2025-09-26 17:29:20 +09:00
: {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>
2025-09-26 17:29:20 +09:00
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => {
const mapping: Record<string, string> = {};
selectedColumns.forEach((col) => {
2025-09-26 17:29:20 +09:00
// 스마트 매핑 로직
const matchingApiField = toApiFields.find((apiField) => {
2025-09-26 17:29:20 +09:00
const colLower = col.toLowerCase();
const apiLower = apiField.toLowerCase();
2025-09-26 17:29:20 +09:00
// 정확한 매치
if (colLower === apiLower) return true;
2025-09-26 17:29:20 +09:00
// 언더스코어 무시 매치
if (colLower.replace(/_/g, "") === apiLower.replace(/_/g, "")) return true;
2025-09-26 17:29:20 +09:00
// 의미적 매핑
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;
2025-09-26 17:29:20 +09:00
return false;
});
2025-09-26 17:29:20 +09:00
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"
2025-09-26 17:29:20 +09:00
>
</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"
2025-09-26 17:29:20 +09:00
>
</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,
)}
2025-09-26 17:29:20 +09:00
</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>,
),
2025-09-26 17:29:20 +09:00
null,
2,
2025-09-26 17:29:20 +09:00
);
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"
2025-09-26 17:29:20 +09:00
>
Request Body에
</button>
</div>
)}
</div>
)}
</div>
)}
</CardContent>
</Card>
2025-09-26 17:29:20 +09:00
{/* 매핑 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}
/>
)}
2025-09-26 17:29:20 +09:00
{/* 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}
/>
)}
2025-09-26 17:29:20 +09:00
{/* TO 설정 */}
<Card>
<CardHeader>
2025-09-26 17:29:20 +09:00
<CardTitle className="flex items-center">
{batchType === "restapi-to-db" ? (
2025-09-26 17:29:20 +09:00
<>
<Database className="mr-2 h-5 w-5" />
2025-09-26 17:29:20 +09:00
TO: 데이터베이스 ()
</>
) : (
<>
<Globe className="mr-2 h-5 w-5" />
2025-09-26 17:29:20 +09:00
TO: REST API ()
</>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
2025-09-26 17:29:20 +09:00
{/* DB 설정 (REST API → DB) */}
{batchType === "restapi-to-db" && (
2025-09-26 17:29:20 +09:00
<>
<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"}
2025-09-26 17:29:20 +09:00
>
{connection.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{/* REST API 설정 (DB → REST API) */}
{batchType === "db-to-restapi" && (
2025-09-26 17:29:20 +09:00
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
2025-09-26 17:29:20 +09:00
<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 gap-4 md:grid-cols-2">
2025-09-26 17:29:20 +09:00
<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}"}
2025-09-26 17:29:20 +09:00
</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") && (
2025-09-26 17:29:20 +09:00
<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">
2025-09-26 17:29:20 +09:00
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"
2025-09-26 17:29:20 +09:00
>
<Eye className="h-4 w-4" />
2025-09-26 17:29:20 +09:00
<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>
2025-09-26 17:29:20 +09:00
<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">
2025-09-26 17:29:20 +09:00
{field}
</span>
))}
</div>
</div>
)}
{/* Request Body 템플릿 */}
{(toApiMethod === "POST" || toApiMethod === "PUT") && (
2025-09-26 17:29:20 +09:00
<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"
2025-09-26 17:29:20 +09:00
/>
<div className="mt-1 text-xs text-gray-500">
DB {"{{컬럼명}}"} . : {"{{user_id}}, {{user_name}}"}
2025-09-26 17:29:20 +09:00
</div>
</div>
)}
2025-09-26 17:29:20 +09:00
{toApiUrl && toApiKey && toEndpoint && (
<div className="rounded-lg bg-gray-50 p-3">
2025-09-26 17:29:20 +09:00
<div className="text-sm font-medium text-gray-700">API </div>
<div className="mt-1 text-sm text-gray-600">
{toApiMethod} {toApiUrl}
{toEndpoint}
2025-09-26 17:29:20 +09:00
</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>}
2025-09-26 17:29:20 +09:00
</div>
)}
</div>
)}
{/* 테이블/컬럼 선택 (REST API → DB만) */}
{batchType === "restapi-to-db" && toTables.length > 0 && (
<div>
<Label> </Label>
<Select onValueChange={handleToTableChange}>
<SelectTrigger>
<SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
2025-09-26 17:29:20 +09:00
{toTables.map((table: string) => (
<SelectItem key={table} value={table}>
{table.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* INSERT/UPSERT 설정 (테이블 선택 후에만 표시) */}
{batchType === "restapi-to-db" && toTable && toColumns.length > 0 && (
<div className="space-y-4 border-t pt-4">
<div>
<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"
/>
<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"
/>
<span className="text-sm">UPSERT ( , )</span>
</label>
</div>
</div>
{saveMode === "UPSERT" && (
<div>
<Label htmlFor="conflictKey"> (Conflict Key) *</Label>
<Select value={conflictKey} onValueChange={setConflictKey}>
<SelectTrigger className="mt-2">
<SelectValue placeholder="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>
)}
</CardContent>
</Card>
</div>
);
2025-11-27 11:48:03 +09:00
}
const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
fromApiFields,
toColumns,
fromApiData,
mappingList,
setMappingList,
2025-11-27 11:48:03 +09:00
}: RestApiToDbMappingCardProps) {
// 샘플 JSON 문자열
2025-11-27 11:48:03 +09:00
const sampleJsonList = useMemo(
() => fromApiData.slice(0, 3).map((item) => JSON.stringify(item, null, 2)),
[fromApiData],
2025-11-27 11:48:03 +09:00
);
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)));
};
2025-11-27 11:48:03 +09:00
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle> </CardTitle>
<Button variant="outline" size="sm" onClick={addMapping} className="h-8 gap-1">
<Plus className="h-4 w-4" />
</Button>
</div>
<CardDescription>DB API .</CardDescription>
2025-11-27 11:48:03 +09:00
</CardHeader>
<CardContent>
{mappingList.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-8 text-center">
<p className="text-muted-foreground text-sm"> .</p>
<Button variant="link" onClick={addMapping} className="mt-2">
</Button>
</div>
) : (
<div className="space-y-3">
{mappingList.map((mapping, index) => (
<div key={mapping.id} className="bg-muted/30 flex items-center gap-3 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}
2025-11-27 11:48:03 +09:00
</div>
{/* DB 컬럼 선택 (좌측 - TO) */}
<div className="w-44 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-28 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,
})
2025-11-27 11:48:03 +09:00
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="api">API </SelectItem>
<SelectItem value="fixed"></SelectItem>
</SelectContent>
</Select>
2025-11-27 11:48:03 +09:00
</div>
{/* API 필드 선택 또는 고정값 입력 (우측 - FROM) */}
<div className="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, 20)}
{String(firstSample[field]).length > 20 ? "..." : ""})
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={mapping.fixedValue}
onChange={(e) => updateMapping(mapping.id, { fixedValue: e.target.value })}
placeholder="고정값 입력 (예: lsthira, COMPANY_A)"
className="h-9"
/>
)}
</div>
2025-11-27 11:48:03 +09:00
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="icon"
onClick={() => removeMapping(mapping.id)}
className="text-muted-foreground hover:text-destructive h-8 w-8 shrink-0"
2025-11-27 11:48:03 +09:00
>
<Trash2 className="h-4 w-4" />
</Button>
2025-11-27 11:48:03 +09:00
</div>
))}
</div>
)}
2025-11-27 11:48:03 +09:00
{/* 샘플 데이터 미리보기 */}
2025-11-27 11:48:03 +09:00
{sampleJsonList.length > 0 && (
<div className="bg-muted/30 mt-4 rounded-lg border p-3">
<div className="mb-2 text-sm font-medium"> ( 3)</div>
<div className="max-h-32 space-y-2 overflow-y-auto">
2025-11-27 11:48:03 +09:00
{sampleJsonList.map((json, index) => (
<div key={index} className="bg-background rounded border p-2 text-xs">
2025-11-27 11:48:03 +09:00
<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],
2025-11-27 11:48:03 +09:00
);
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>,
);
2025-11-27 11:48:03 +09:00
return JSON.stringify(obj, null, 2);
}, [selectedColumns, dbToApiFieldMapping]);
return (
<Card>
<CardHeader>
<CardTitle>DB API </CardTitle>
<CardDescription>
DB REST API Request Body에 . Request Body 릿 {"{{컬럼명}}"} .
2025-11-27 11:48:03 +09:00
</CardDescription>
</CardHeader>
<CardContent>
<div className="max-h-96 space-y-3 overflow-y-auto rounded-lg border p-4">
2025-11-27 11:48:03 +09:00
{selectedColumnObjects.map((column) => (
<div key={column.column_name} className="flex items-center space-x-4 rounded-lg bg-gray-50 p-3">
2025-11-27 11:48:03 +09:00
{/* DB 컬럼 정보 */}
<div className="flex-1">
<div className="text-sm font-medium">{column.column_name}</div>
2025-11-27 11:48:03 +09:00
<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"
2025-11-27 11:48:03 +09:00
onChange={(e) => {
setDbToApiFieldMapping((prev) => ({
...prev,
[column.column_name]: e.target.value,
}));
}}
/>
)}
<div className="mt-1 text-xs text-gray-500">
2025-11-27 11:48:03 +09:00
{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>
2025-11-27 11:48:03 +09:00
</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>
2025-11-27 11:48:03 +09:00
<button
type="button"
onClick={() => {
setToApiBody(autoJsonPreview);
toast.success("Request Body에 자동 생성된 JSON이 적용되었습니다.");
2025-11-27 11:48:03 +09:00
}}
className="mt-2 rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
2025-11-27 11:48:03 +09:00
>
Request Body에
</button>
</div>
)}
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
2025-11-27 11:48:03 +09:00
<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}}"}'}
2025-11-27 11:48:03 +09:00
</div>
</div>
</CardContent>
</Card>
);
});