1552 lines
64 KiB
TypeScript
1552 lines
64 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
import { Card, CardContent, CardHeader, CardTitle } 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 { Badge } from "@/components/ui/badge";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import {
|
|
BatchAPI,
|
|
BatchConfig,
|
|
BatchMapping,
|
|
ConnectionInfo,
|
|
} from "@/lib/api/batch";
|
|
import { BatchManagementAPI } from "@/lib/api/batchManagement";
|
|
|
|
interface BatchColumnInfo {
|
|
column_name: string;
|
|
data_type: string;
|
|
is_nullable: string;
|
|
}
|
|
|
|
// 배치 타입 감지 함수
|
|
const detectBatchType = (mapping: BatchMapping): 'db-to-db' | 'restapi-to-db' | 'db-to-restapi' => {
|
|
const fromType = mapping.from_connection_type;
|
|
const toType = mapping.to_connection_type;
|
|
|
|
if (fromType === 'restapi' && (toType === 'internal' || toType === 'external')) {
|
|
return 'restapi-to-db';
|
|
} else if ((fromType === 'internal' || fromType === 'external') && toType === 'restapi') {
|
|
return 'db-to-restapi';
|
|
} else {
|
|
return 'db-to-db';
|
|
}
|
|
};
|
|
|
|
export default function BatchEditPage() {
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const batchId = parseInt(params.id as string);
|
|
|
|
// 기본 상태
|
|
const [loading, setLoading] = useState(false);
|
|
const [batchConfig, setBatchConfig] = useState<BatchConfig | null>(null);
|
|
const [batchName, setBatchName] = useState("");
|
|
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
|
|
const [description, setDescription] = useState("");
|
|
const [isActive, setIsActive] = useState("Y");
|
|
const [saveMode, setSaveMode] = useState<"INSERT" | "UPSERT">("INSERT");
|
|
const [conflictKey, setConflictKey] = useState("");
|
|
const [authServiceName, setAuthServiceName] = useState("");
|
|
const [authServiceNames, setAuthServiceNames] = useState<string[]>([]);
|
|
const [dataArrayPath, setDataArrayPath] = useState("");
|
|
|
|
// 연결 정보
|
|
const [connections, setConnections] = useState<ConnectionInfo[]>([]);
|
|
const [fromConnection, setFromConnection] = useState<ConnectionInfo | null>(null);
|
|
const [toConnection, setToConnection] = useState<ConnectionInfo | null>(null);
|
|
|
|
// 테이블 및 컬럼 정보
|
|
const [fromTables, setFromTables] = useState<string[]>([]);
|
|
const [toTables, setToTables] = useState<string[]>([]);
|
|
const [fromTable, setFromTable] = useState("");
|
|
const [toTable, setToTable] = useState("");
|
|
const [fromColumns, setFromColumns] = useState<BatchColumnInfo[]>([]);
|
|
const [toColumns, setToColumns] = useState<BatchColumnInfo[]>([]);
|
|
|
|
// 매핑 정보
|
|
const [mappings, setMappings] = useState<BatchMapping[]>([]);
|
|
|
|
// 배치 타입 감지
|
|
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null);
|
|
|
|
// REST API 미리보기 상태
|
|
const [apiPreviewData, setApiPreviewData] = useState<any[]>([]);
|
|
const [fromApiFields, setFromApiFields] = useState<string[]>([]);
|
|
|
|
// 인증 토큰 모드 (직접 입력 / DB에서 선택)
|
|
const [authTokenMode, setAuthTokenMode] = useState<"direct" | "db">("direct");
|
|
const [fromApiKey, setFromApiKey] = useState("");
|
|
|
|
// API 파라미터 설정
|
|
const [apiParamType, setApiParamType] = useState<"none" | "url" | "query">("none");
|
|
const [apiParamName, setApiParamName] = useState("");
|
|
const [apiParamValue, setApiParamValue] = useState("");
|
|
const [apiParamSource, setApiParamSource] = useState<"static" | "dynamic">("static");
|
|
|
|
// 매핑 리스트 (새로운 UI용)
|
|
interface MappingItem {
|
|
id: string;
|
|
dbColumn: string;
|
|
sourceType: "api" | "fixed";
|
|
apiField: string;
|
|
fixedValue: string;
|
|
}
|
|
const [mappingList, setMappingList] = useState<MappingItem[]>([]);
|
|
|
|
// 페이지 로드 시 배치 정보 조회
|
|
useEffect(() => {
|
|
if (batchId) {
|
|
loadBatchConfig();
|
|
loadConnections();
|
|
loadAuthServiceNames();
|
|
}
|
|
}, [batchId]);
|
|
|
|
// 인증 서비스명 목록 로드
|
|
const loadAuthServiceNames = async () => {
|
|
try {
|
|
const names = await BatchAPI.getAuthServiceNames();
|
|
setAuthServiceNames(names);
|
|
} catch (error) {
|
|
console.error("인증 서비스 목록 로드 실패:", error);
|
|
}
|
|
};
|
|
|
|
// 연결 정보가 로드된 후 배치 설정의 연결 정보 설정
|
|
useEffect(() => {
|
|
if (batchConfig && connections.length > 0 && batchConfig.batch_mappings && batchConfig.batch_mappings.length > 0) {
|
|
const firstMapping = batchConfig.batch_mappings[0];
|
|
console.log("🔗 연결 정보 설정 시작:", firstMapping);
|
|
|
|
// FROM 연결 정보 설정
|
|
if (firstMapping.from_connection_type === 'internal') {
|
|
setFromConnection({ type: 'internal', name: '내부 DB' });
|
|
// 내부 DB 테이블 목록 로드
|
|
BatchAPI.getTablesFromConnection({ type: 'internal', name: '내부 DB' }).then(tables => {
|
|
console.log("📋 FROM 테이블 목록:", tables);
|
|
setFromTables(tables);
|
|
|
|
// 컬럼 정보도 로드
|
|
if (firstMapping.from_table_name) {
|
|
BatchAPI.getTableColumns({ type: 'internal', name: '내부 DB' }, firstMapping.from_table_name).then(columns => {
|
|
console.log("📊 FROM 컬럼 목록:", columns);
|
|
setFromColumns(columns);
|
|
});
|
|
}
|
|
});
|
|
} else if (firstMapping.from_connection_id) {
|
|
const fromConn = connections.find(c => c.id === firstMapping.from_connection_id);
|
|
if (fromConn) {
|
|
setFromConnection(fromConn);
|
|
// 외부 DB 테이블 목록 로드
|
|
BatchAPI.getTablesFromConnection(fromConn).then(tables => {
|
|
console.log("📋 FROM 테이블 목록:", tables);
|
|
setFromTables(tables);
|
|
|
|
// 컬럼 정보도 로드
|
|
if (firstMapping.from_table_name) {
|
|
BatchAPI.getTableColumns(fromConn, firstMapping.from_table_name).then(columns => {
|
|
console.log("📊 FROM 컬럼 목록:", columns);
|
|
setFromColumns(columns);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// TO 연결 정보 설정
|
|
if (firstMapping.to_connection_type === 'internal') {
|
|
setToConnection({ type: 'internal', name: '내부 DB' });
|
|
// 내부 DB 테이블 목록 로드
|
|
BatchAPI.getTablesFromConnection({ type: 'internal', name: '내부 DB' }).then(tables => {
|
|
console.log("📋 TO 테이블 목록:", tables);
|
|
setToTables(tables);
|
|
|
|
// 컬럼 정보도 로드
|
|
if (firstMapping.to_table_name) {
|
|
BatchAPI.getTableColumns({ type: 'internal', name: '내부 DB' }, firstMapping.to_table_name).then(columns => {
|
|
console.log("📊 TO 컬럼 목록:", columns);
|
|
setToColumns(columns);
|
|
});
|
|
}
|
|
});
|
|
} else if (firstMapping.to_connection_id) {
|
|
const toConn = connections.find(c => c.id === firstMapping.to_connection_id);
|
|
if (toConn) {
|
|
setToConnection(toConn);
|
|
// 외부 DB 테이블 목록 로드
|
|
BatchAPI.getTablesFromConnection(toConn).then(tables => {
|
|
console.log("📋 TO 테이블 목록:", tables);
|
|
setToTables(tables);
|
|
|
|
// 컬럼 정보도 로드
|
|
if (firstMapping.to_table_name) {
|
|
BatchAPI.getTableColumns(toConn, firstMapping.to_table_name).then(columns => {
|
|
console.log("📊 TO 컬럼 목록:", columns);
|
|
setToColumns(columns);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}, [batchConfig, connections]);
|
|
|
|
// 배치 설정 조회
|
|
const loadBatchConfig = async () => {
|
|
try {
|
|
setLoading(true);
|
|
console.log("🔍 배치 설정 조회 시작:", batchId);
|
|
|
|
const config = await BatchAPI.getBatchConfig(batchId);
|
|
console.log("📋 조회된 배치 설정:", config);
|
|
|
|
setBatchConfig(config);
|
|
setBatchName(config.batch_name);
|
|
setCronSchedule(config.cron_schedule);
|
|
setDescription(config.description || "");
|
|
setIsActive(config.is_active || "Y");
|
|
setSaveMode((config as any).save_mode || "INSERT");
|
|
setConflictKey((config as any).conflict_key || "");
|
|
setAuthServiceName((config as any).auth_service_name || "");
|
|
setDataArrayPath((config as any).data_array_path || "");
|
|
|
|
// 인증 토큰 모드 설정
|
|
if ((config as any).auth_service_name) {
|
|
setAuthTokenMode("db");
|
|
} else {
|
|
setAuthTokenMode("direct");
|
|
}
|
|
|
|
if (config.batch_mappings && config.batch_mappings.length > 0) {
|
|
// API 키 설정 (첫 번째 매핑에서)
|
|
const firstMappingForApiKey = config.batch_mappings[0];
|
|
if (firstMappingForApiKey.from_api_key) {
|
|
setFromApiKey(firstMappingForApiKey.from_api_key);
|
|
}
|
|
console.log("📊 매핑 정보:", config.batch_mappings);
|
|
console.log("📊 매핑 개수:", config.batch_mappings.length);
|
|
config.batch_mappings.forEach((mapping, idx) => {
|
|
console.log(`📊 매핑 #${idx + 1}:`, {
|
|
from: `${mapping.from_column_name} (${mapping.from_column_type})`,
|
|
to: `${mapping.to_column_name} (${mapping.to_column_type})`,
|
|
type: mapping.mapping_type
|
|
});
|
|
});
|
|
setMappings(config.batch_mappings);
|
|
|
|
// 첫 번째 매핑에서 연결 및 테이블 정보 추출
|
|
const firstMapping = config.batch_mappings[0];
|
|
setFromTable(firstMapping.from_table_name);
|
|
setToTable(firstMapping.to_table_name);
|
|
|
|
// 배치 타입 감지
|
|
const detectedBatchType = detectBatchType(firstMapping);
|
|
setBatchType(detectedBatchType);
|
|
console.log("🎯 감지된 배치 타입:", detectedBatchType);
|
|
|
|
// FROM 연결 정보 설정
|
|
if (firstMapping.from_connection_type === 'internal') {
|
|
setFromConnection({ type: 'internal', name: '내부 DB' });
|
|
} else if (firstMapping.from_connection_id) {
|
|
// 외부 연결은 connections 로드 후 설정
|
|
setTimeout(() => {
|
|
const fromConn = connections.find(c => c.id === firstMapping.from_connection_id);
|
|
if (fromConn) {
|
|
setFromConnection(fromConn);
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
// TO 연결 정보 설정
|
|
if (firstMapping.to_connection_type === 'internal') {
|
|
setToConnection({ type: 'internal', name: '내부 DB' });
|
|
} else if (firstMapping.to_connection_id) {
|
|
// 외부 연결은 connections 로드 후 설정
|
|
setTimeout(() => {
|
|
const toConn = connections.find(c => c.id === firstMapping.to_connection_id);
|
|
if (toConn) {
|
|
setToConnection(toConn);
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
console.log("🔗 테이블 정보 설정:", {
|
|
fromTable: firstMapping.from_table_name,
|
|
toTable: firstMapping.to_table_name,
|
|
fromConnectionType: firstMapping.from_connection_type,
|
|
toConnectionType: firstMapping.to_connection_type
|
|
});
|
|
|
|
// 기존 매핑을 mappingList로 변환
|
|
const convertedMappingList: MappingItem[] = config.batch_mappings.map((mapping, index) => ({
|
|
id: `mapping-${index}-${Date.now()}`,
|
|
dbColumn: mapping.to_column_name || "",
|
|
sourceType: (mapping as any).mapping_type === "fixed" ? "fixed" as const : "api" as const,
|
|
apiField: (mapping as any).mapping_type === "fixed" ? "" : mapping.from_column_name || "",
|
|
fixedValue: (mapping as any).mapping_type === "fixed" ? mapping.from_column_name || "" : "",
|
|
}));
|
|
setMappingList(convertedMappingList);
|
|
console.log("🔄 변환된 mappingList:", convertedMappingList);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error("❌ 배치 설정 조회 오류:", error);
|
|
toast.error("배치 설정을 불러오는데 실패했습니다.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// 연결 정보 조회
|
|
const loadConnections = async () => {
|
|
try {
|
|
const connectionList = await BatchAPI.getConnections();
|
|
setConnections(connectionList);
|
|
} catch (error) {
|
|
console.error("연결 정보 조회 오류:", error);
|
|
toast.error("연결 정보를 불러오는데 실패했습니다.");
|
|
}
|
|
};
|
|
|
|
// FROM 연결 변경 시
|
|
const handleFromConnectionChange = async (connectionId: string) => {
|
|
const connection = connections.find(c => c.id?.toString() === connectionId) ||
|
|
(connectionId === 'internal' ? { type: 'internal' as const, name: '내부 DB' } : null);
|
|
|
|
if (connection) {
|
|
setFromConnection(connection);
|
|
|
|
try {
|
|
const tables = await BatchAPI.getTablesFromConnection(connection);
|
|
setFromTables(tables);
|
|
setFromTable("");
|
|
setFromColumns([]);
|
|
} catch (error) {
|
|
console.error("테이블 목록 조회 오류:", error);
|
|
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
|
}
|
|
}
|
|
};
|
|
|
|
// TO 연결 변경 시
|
|
const handleToConnectionChange = async (connectionId: string) => {
|
|
const connection = connections.find(c => c.id?.toString() === connectionId) ||
|
|
(connectionId === 'internal' ? { type: 'internal' as const, name: '내부 DB' } : null);
|
|
|
|
if (connection) {
|
|
setToConnection(connection);
|
|
|
|
try {
|
|
const tables = await BatchAPI.getTablesFromConnection(connection);
|
|
setToTables(tables);
|
|
setToTable("");
|
|
setToColumns([]);
|
|
} catch (error) {
|
|
console.error("테이블 목록 조회 오류:", error);
|
|
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
|
}
|
|
}
|
|
};
|
|
|
|
// FROM 테이블 변경 시
|
|
const handleFromTableChange = async (tableName: string) => {
|
|
setFromTable(tableName);
|
|
|
|
if (fromConnection && tableName) {
|
|
try {
|
|
const columns = await BatchAPI.getTableColumns(fromConnection, tableName);
|
|
setFromColumns(columns);
|
|
} catch (error) {
|
|
console.error("컬럼 정보 조회 오류:", error);
|
|
toast.error("컬럼 정보를 불러오는데 실패했습니다.");
|
|
}
|
|
}
|
|
};
|
|
|
|
// TO 테이블 변경 시
|
|
const handleToTableChange = async (tableName: string) => {
|
|
setToTable(tableName);
|
|
|
|
if (toConnection && tableName) {
|
|
try {
|
|
const columns = await BatchAPI.getTableColumns(toConnection, tableName);
|
|
setToColumns(columns);
|
|
} catch (error) {
|
|
console.error("컬럼 정보 조회 오류:", error);
|
|
toast.error("컬럼 정보를 불러오는데 실패했습니다.");
|
|
}
|
|
}
|
|
};
|
|
|
|
// 매핑 추가
|
|
const addMapping = () => {
|
|
const newMapping: BatchMapping = {
|
|
from_connection_type: fromConnection?.type === 'internal' ? 'internal' : 'external',
|
|
from_connection_id: fromConnection?.type === 'internal' ? undefined : fromConnection?.id,
|
|
from_table_name: fromTable,
|
|
from_column_name: '',
|
|
from_column_type: '',
|
|
to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external',
|
|
to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id,
|
|
to_table_name: toTable,
|
|
to_column_name: '',
|
|
to_column_type: '',
|
|
mapping_type: 'direct',
|
|
mapping_order: mappings.length + 1
|
|
};
|
|
|
|
setMappings([...mappings, newMapping]);
|
|
};
|
|
|
|
// REST API → DB 매핑 추가
|
|
const addRestapiToDbMapping = () => {
|
|
if (!batchConfig || !batchConfig.batch_mappings || batchConfig.batch_mappings.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const first = batchConfig.batch_mappings[0] as any;
|
|
|
|
const newMapping: BatchMapping = {
|
|
// FROM: REST API (기존 설정 그대로 복사)
|
|
from_connection_type: "restapi" as any,
|
|
from_connection_id: first.from_connection_id,
|
|
from_table_name: first.from_table_name,
|
|
from_column_name: "",
|
|
from_column_type: "",
|
|
// TO: DB (기존 설정 그대로 복사)
|
|
to_connection_type: first.to_connection_type as any,
|
|
to_connection_id: first.to_connection_id,
|
|
to_table_name: first.to_table_name,
|
|
to_column_name: "",
|
|
to_column_type: "",
|
|
mapping_type: (first.mapping_type as any) || "direct",
|
|
mapping_order: mappings.length + 1,
|
|
};
|
|
|
|
setMappings((prev) => [...prev, newMapping]);
|
|
};
|
|
|
|
// mappingList 관련 함수들 (새로운 UI용)
|
|
const addMappingListItem = () => {
|
|
const newId = `mapping-${Date.now()}`;
|
|
setMappingList((prev) => [
|
|
...prev,
|
|
{
|
|
id: newId,
|
|
dbColumn: "",
|
|
sourceType: "api",
|
|
apiField: "",
|
|
fixedValue: "",
|
|
},
|
|
]);
|
|
};
|
|
|
|
const removeMappingListItem = (id: string) => {
|
|
setMappingList((prev) => prev.filter((m) => m.id !== id));
|
|
};
|
|
|
|
const updateMappingListItem = (id: string, updates: Partial<MappingItem>) => {
|
|
setMappingList((prev) => prev.map((m) => (m.id === id ? { ...m, ...updates } : m)));
|
|
};
|
|
|
|
// REST API 데이터 미리보기 (수정 화면용)
|
|
const previewRestApiData = async () => {
|
|
if (!mappings || mappings.length === 0) {
|
|
toast.error("미리보기할 REST API 매핑이 없습니다.");
|
|
return;
|
|
}
|
|
|
|
const first: any = mappings[0];
|
|
|
|
if (!first.from_api_url || !first.from_table_name) {
|
|
toast.error("API URL과 엔드포인트 정보가 없습니다.");
|
|
return;
|
|
}
|
|
|
|
// DB 선택 모드일 때만 서비스명 검증 (직접 입력 모드는 빈 값 허용 - 공개 API용)
|
|
if (authTokenMode === "db" && !authServiceName) {
|
|
toast.error("인증 토큰 서비스를 선택해주세요.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const method =
|
|
(first.from_api_method as "GET" | "POST" | "PUT" | "DELETE") || "GET";
|
|
|
|
const paramInfo =
|
|
apiParamType !== "none" && apiParamName && apiParamValue
|
|
? {
|
|
paramType: apiParamType,
|
|
paramName: apiParamName,
|
|
paramValue: apiParamValue,
|
|
paramSource: apiParamSource,
|
|
}
|
|
: undefined;
|
|
|
|
// authTokenMode에 따라 올바른 값 전달
|
|
const result = await BatchManagementAPI.previewRestApiData(
|
|
first.from_api_url,
|
|
authTokenMode === "direct" ? fromApiKey : "", // 직접 입력일 때만 API 키 전달
|
|
first.from_table_name,
|
|
method,
|
|
paramInfo,
|
|
first.from_api_body || undefined,
|
|
authTokenMode === "db" ? authServiceName : undefined, // DB 선택 모드일 때 서비스명 전달
|
|
dataArrayPath || undefined
|
|
);
|
|
|
|
setApiPreviewData(result.samples || []);
|
|
setFromApiFields(result.fields || []);
|
|
|
|
toast.success(
|
|
`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.samples.length}개 레코드`
|
|
);
|
|
} catch (error: any) {
|
|
console.error("REST API 미리보기 오류:", error);
|
|
toast.error(error?.message || "API 데이터 미리보기에 실패했습니다.");
|
|
}
|
|
};
|
|
|
|
// 매핑 삭제
|
|
const removeMapping = (index: number) => {
|
|
const updatedMappings = mappings.filter((_, i) => i !== index);
|
|
setMappings(updatedMappings);
|
|
};
|
|
|
|
// 매핑 업데이트
|
|
const updateMapping = (index: number, field: keyof BatchMapping, value: any) => {
|
|
setMappings(prevMappings => {
|
|
const updatedMappings = [...prevMappings];
|
|
updatedMappings[index] = { ...updatedMappings[index], [field]: value };
|
|
return updatedMappings;
|
|
});
|
|
};
|
|
|
|
// 배치 설정 저장
|
|
const saveBatchConfig = async () => {
|
|
// restapi-to-db인 경우 mappingList 사용, 아닌 경우 mappings 사용
|
|
const effectiveMappings = batchType === "restapi-to-db" ? mappingList : mappings;
|
|
|
|
if (!batchName || !cronSchedule || effectiveMappings.length === 0) {
|
|
toast.error("필수 항목을 모두 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
|
|
// restapi-to-db인 경우 mappingList를 mappings 형식으로 변환
|
|
let finalMappings: BatchMapping[] = mappings;
|
|
|
|
if (batchType === "restapi-to-db" && batchConfig?.batch_mappings?.[0]) {
|
|
const first = batchConfig.batch_mappings[0] as any;
|
|
finalMappings = mappingList
|
|
.filter((m) => m.dbColumn) // DB 컬럼이 선택된 것만
|
|
.map((m, index) => ({
|
|
// FROM: REST API (기존 설정 복사)
|
|
from_connection_type: "restapi" as any,
|
|
from_connection_id: first.from_connection_id,
|
|
from_table_name: first.from_table_name,
|
|
from_column_name: m.sourceType === "fixed" ? m.fixedValue : m.apiField,
|
|
from_column_type: m.sourceType === "fixed" ? "text" : "text",
|
|
from_api_url: mappings[0]?.from_api_url || first.from_api_url,
|
|
from_api_key: authTokenMode === "direct" ? fromApiKey : first.from_api_key,
|
|
from_api_method: mappings[0]?.from_api_method || first.from_api_method,
|
|
from_api_body: mappings[0]?.from_api_body || first.from_api_body,
|
|
// TO: DB (기존 설정 복사)
|
|
to_connection_type: first.to_connection_type as any,
|
|
to_connection_id: first.to_connection_id,
|
|
to_table_name: toTable || first.to_table_name,
|
|
to_column_name: m.dbColumn,
|
|
to_column_type: toColumns.find((c) => c.column_name === m.dbColumn)?.data_type || "text",
|
|
mapping_type: m.sourceType === "fixed" ? "fixed" : "direct",
|
|
mapping_order: index + 1,
|
|
})) as BatchMapping[];
|
|
}
|
|
|
|
await BatchAPI.updateBatchConfig(batchId, {
|
|
batchName,
|
|
description,
|
|
cronSchedule,
|
|
isActive,
|
|
mappings: finalMappings,
|
|
saveMode,
|
|
conflictKey: saveMode === "UPSERT" ? conflictKey : null, // INSERT면 null로 명시적 삭제
|
|
authServiceName: authTokenMode === "db" ? authServiceName : null, // 직접입력이면 null로 명시적 삭제
|
|
dataArrayPath: dataArrayPath || null
|
|
});
|
|
|
|
toast.success("배치 설정이 성공적으로 수정되었습니다.");
|
|
router.push("/admin/batchmng");
|
|
|
|
} catch (error) {
|
|
console.error("배치 설정 수정 실패:", error);
|
|
toast.error("배치 설정 수정에 실패했습니다.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
if (loading && !batchConfig) {
|
|
return (
|
|
<div className="container mx-auto p-6">
|
|
<div className="flex items-center justify-center h-64">
|
|
<RefreshCw className="w-8 h-8 animate-spin" />
|
|
<span className="ml-2">배치 설정을 불러오는 중...</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto space-y-6 p-6">
|
|
{/* 페이지 헤더 */}
|
|
<div className="flex items-center gap-4 border-b pb-4">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => router.push("/admin/batchmng")}
|
|
className="gap-2"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
목록으로
|
|
</Button>
|
|
<h1 className="text-3xl font-bold">배치 설정 수정</h1>
|
|
</div>
|
|
|
|
{/* 기본 정보 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
기본 정보
|
|
{batchType && (
|
|
<Badge variant="outline">
|
|
{batchType === "db-to-db" && "DB -> DB"}
|
|
{batchType === "restapi-to-db" && "REST API -> DB"}
|
|
{batchType === "db-to-restapi" && "DB -> REST API"}
|
|
</Badge>
|
|
)}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div>
|
|
<Label htmlFor="batchName">배치명 *</Label>
|
|
<Input
|
|
id="batchName"
|
|
value={batchName}
|
|
onChange={(e) => setBatchName(e.target.value)}
|
|
placeholder="배치명을 입력하세요"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="cronSchedule">실행 스케줄 (Cron) *</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="배치에 대한 설명을 입력하세요"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="isActive"
|
|
checked={isActive === "Y"}
|
|
onCheckedChange={(checked) => setIsActive(checked ? "Y" : "N")}
|
|
/>
|
|
<Label htmlFor="isActive">활성화</Label>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* FROM/TO 섹션 가로 배치 */}
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
{/* FROM 설정 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>FROM (소스)</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{batchType === "db-to-db" && (
|
|
<>
|
|
<div>
|
|
<Label>연결</Label>
|
|
<Select
|
|
value={
|
|
fromConnection?.type === "internal"
|
|
? "internal"
|
|
: fromConnection?.id?.toString() || ""
|
|
}
|
|
onValueChange={handleFromConnectionChange}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="소스 연결을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="internal">내부 DB</SelectItem>
|
|
{connections
|
|
.filter((conn) => conn.id)
|
|
.map((conn) => (
|
|
<SelectItem key={conn.id} value={conn.id!.toString()}>
|
|
{conn.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>테이블</Label>
|
|
<Select value={fromTable} onValueChange={handleFromTableChange}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="소스 테이블을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{fromTables.map((table) => (
|
|
<SelectItem key={table} value={table}>
|
|
{table}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{batchType === "restapi-to-db" && mappings.length > 0 && (
|
|
<>
|
|
{/* API 서버 URL */}
|
|
<div>
|
|
<Label>API 서버 URL *</Label>
|
|
<Input
|
|
value={mappings[0]?.from_api_url || ""}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
setMappings((prev) => {
|
|
if (prev.length === 0) return prev;
|
|
const updated = [...prev];
|
|
updated[0] = { ...updated[0], from_api_url: value } as any;
|
|
return updated;
|
|
});
|
|
}}
|
|
placeholder="https://api.example.com"
|
|
/>
|
|
</div>
|
|
|
|
{/* 인증 토큰 (Authorization) */}
|
|
<div>
|
|
<Label>인증 토큰 (Authorization)</Label>
|
|
<div className="mt-2 flex items-center 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")}
|
|
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
|
|
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>엔드포인트 *</Label>
|
|
<Input
|
|
value={mappings[0]?.from_table_name || ""}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
setMappings((prev) => {
|
|
if (prev.length === 0) return prev;
|
|
const updated = [...prev];
|
|
updated[0] = { ...updated[0], from_table_name: value } as any;
|
|
return updated;
|
|
});
|
|
}}
|
|
placeholder="/api/users"
|
|
/>
|
|
</div>
|
|
|
|
{/* HTTP 메서드 */}
|
|
<div>
|
|
<Label>HTTP 메서드</Label>
|
|
<Select
|
|
value={mappings[0]?.from_api_method || "GET"}
|
|
onValueChange={(value) => {
|
|
setMappings((prev) => {
|
|
if (prev.length === 0) return prev;
|
|
const updated = [...prev];
|
|
updated[0] = { ...updated[0], from_api_method: value } as any;
|
|
return updated;
|
|
});
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="HTTP 메서드 선택" />
|
|
</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>데이터 배열 경로</Label>
|
|
<Input
|
|
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용) */}
|
|
{(mappings[0]?.from_api_method === "POST" ||
|
|
mappings[0]?.from_api_method === "PUT" ||
|
|
mappings[0]?.from_api_method === "DELETE") && (
|
|
<div>
|
|
<Label>Request Body (JSON)</Label>
|
|
<Textarea
|
|
value={mappings[0]?.from_api_body || ""}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
setMappings((prev) => {
|
|
if (prev.length === 0) return prev;
|
|
const updated = [...prev];
|
|
updated[0] = { ...updated[0], from_api_body: value } as any;
|
|
return updated;
|
|
});
|
|
}}
|
|
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>파라미터명 *</Label>
|
|
<Input
|
|
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>
|
|
{apiParamSource === "static" ? "파라미터 값" : "파라미터 템플릿"} *
|
|
</Label>
|
|
<Input
|
|
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">
|
|
실제 호출: {mappings[0]?.from_table_name || "/api/users"}?{apiParamName || "userId"}=
|
|
{apiParamValue || "123"}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{batchType === "db-to-restapi" && mappings.length > 0 && (
|
|
<>
|
|
<div>
|
|
<Label>소스 테이블</Label>
|
|
<Input value={mappings[0]?.from_table_name || ""} readOnly />
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* TO 설정 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>TO (대상)</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{batchType === "db-to-db" && (
|
|
<>
|
|
<div>
|
|
<Label>연결</Label>
|
|
<Select
|
|
value={
|
|
toConnection?.type === "internal"
|
|
? "internal"
|
|
: toConnection?.id?.toString() || ""
|
|
}
|
|
onValueChange={handleToConnectionChange}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="대상 연결을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="internal">내부 DB</SelectItem>
|
|
{connections
|
|
.filter((conn) => conn.id)
|
|
.map((conn) => (
|
|
<SelectItem key={conn.id} value={conn.id!.toString()}>
|
|
{conn.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>테이블</Label>
|
|
<Select
|
|
value={toTable}
|
|
onValueChange={handleToTableChange}
|
|
disabled={!toConnection}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="대상 테이블을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{toTables.map((table) => (
|
|
<SelectItem key={table} value={table}>
|
|
{table}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{batchType === "restapi-to-db" && (
|
|
<>
|
|
<div>
|
|
<Label>데이터베이스 연결 *</Label>
|
|
<Select
|
|
value={
|
|
toConnection?.type === "internal"
|
|
? "internal"
|
|
: toConnection?.id?.toString() || ""
|
|
}
|
|
onValueChange={handleToConnectionChange}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="대상 연결을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="internal">내부 DB</SelectItem>
|
|
{connections
|
|
.filter((conn) => conn.id)
|
|
.map((conn) => (
|
|
<SelectItem key={conn.id} value={conn.id!.toString()}>
|
|
{conn.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>대상 테이블 *</Label>
|
|
<Select
|
|
value={toTable}
|
|
onValueChange={handleToTableChange}
|
|
disabled={!toConnection}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={toConnection ? "테이블을 선택하세요" : "먼저 연결을 선택하세요"} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{toTables.map((table) => (
|
|
<SelectItem key={table} value={table}>
|
|
{table}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{batchType === "db-to-restapi" && mappings.length > 0 && (
|
|
<>
|
|
<div>
|
|
<Label>API URL</Label>
|
|
<Input value={mappings[0]?.to_api_url || ""} readOnly />
|
|
</div>
|
|
<div>
|
|
<Label>API 엔드포인트</Label>
|
|
<Input value={mappings[0]?.to_table_name || ""} readOnly />
|
|
</div>
|
|
<div>
|
|
<Label>HTTP 메서드</Label>
|
|
<Input value={mappings[0]?.to_api_method || "POST"} readOnly />
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* 저장 모드 설정 - 항상 표시 (단계적 활성화) */}
|
|
<div className="border-t pt-4">
|
|
<Label>저장 모드</Label>
|
|
<Select
|
|
value={saveMode}
|
|
onValueChange={(value: "INSERT" | "UPSERT") => {
|
|
setSaveMode(value);
|
|
// INSERT로 변경 시 충돌 기준 컬럼 초기화
|
|
if (value === "INSERT") {
|
|
setConflictKey("");
|
|
}
|
|
}}
|
|
disabled={!toTable}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="저장 모드 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="INSERT">INSERT (항상 새로 추가)</SelectItem>
|
|
<SelectItem value="UPSERT">
|
|
UPSERT (있으면 업데이트, 없으면 추가)
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-muted-foreground mt-1.5 text-xs">
|
|
UPSERT: 동일한 키가 있으면 업데이트, 없으면 새로 추가합니다.
|
|
</p>
|
|
</div>
|
|
|
|
{/* 충돌 기준 컬럼 - UPSERT일 때만 활성화 */}
|
|
<div>
|
|
<Label>충돌 기준 컬럼</Label>
|
|
<Select
|
|
value={conflictKey || "none"}
|
|
onValueChange={(value) => setConflictKey(value === "none" ? "" : value)}
|
|
disabled={saveMode !== "UPSERT" || toColumns.length === 0}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={saveMode !== "UPSERT" ? "UPSERT 모드를 선택하세요" : "컬럼을 선택하세요"} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">선택 안함</SelectItem>
|
|
{toColumns.map((col) => (
|
|
<SelectItem key={col.column_name} value={col.column_name}>
|
|
<div className="flex items-center gap-2">
|
|
<span>{col.column_name}</span>
|
|
<span className="text-muted-foreground text-xs">({col.data_type})</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-muted-foreground mt-1.5 text-xs">
|
|
UPSERT 시 중복 여부를 판단할 컬럼을 선택하세요.
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* API 데이터 미리보기 버튼 */}
|
|
{batchType === "restapi-to-db" && (
|
|
<div className="flex justify-center">
|
|
<Button
|
|
variant="outline"
|
|
onClick={previewRestApiData}
|
|
disabled={mappings.length === 0}
|
|
>
|
|
<RefreshCw className="mr-2 h-4 w-4" />
|
|
API 데이터 미리보기
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 컬럼 매핑 섹션 - 좌우 분리 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center justify-between">
|
|
{batchType === "db-to-db" && "컬럼 매핑"}
|
|
{batchType === "restapi-to-db" && "컬럼 매핑 설정"}
|
|
{batchType === "db-to-restapi" && "DB 컬럼 -> API 필드 매핑"}
|
|
</CardTitle>
|
|
{batchType === "restapi-to-db" && (
|
|
<p className="text-muted-foreground text-sm">DB 컬럼에 API 필드 또는 고정값을 매핑합니다.</p>
|
|
)}
|
|
</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>
|
|
{apiPreviewData.length > 0 ? (
|
|
<div className="bg-muted/30 h-[360px] overflow-y-auto rounded-lg border p-3">
|
|
<div className="space-y-2">
|
|
{apiPreviewData.slice(0, 3).map((item, index) => (
|
|
<div key={index} className="bg-background rounded border p-2">
|
|
<pre className="whitespace-pre-wrap font-mono text-xs">
|
|
{JSON.stringify(item, null, 2)}
|
|
</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>
|
|
{(batchType === "db-to-db" || batchType === "restapi-to-db") && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={batchType === "db-to-db" ? addMapping : addMappingListItem}
|
|
className="h-8 gap-1"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
매핑 추가
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* restapi-to-db 새로운 매핑 UI */}
|
|
{batchType === "restapi-to-db" && (
|
|
<>
|
|
{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={addMappingListItem} 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) =>
|
|
updateMappingListItem(mapping.id, { dbColumn: value === "none" ? "" : value })
|
|
}
|
|
>
|
|
<SelectTrigger className="h-9">
|
|
<SelectValue placeholder="선택 안함" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">선택 안함</SelectItem>
|
|
{toColumns.map((col) => {
|
|
const isUsed =
|
|
mappingList.some((m) => m.dbColumn === 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") =>
|
|
updateMappingListItem(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) =>
|
|
updateMappingListItem(mapping.id, { apiField: value === "none" ? "" : value })
|
|
}
|
|
>
|
|
<SelectTrigger className="h-9">
|
|
<SelectValue placeholder="선택 안함" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">선택 안함</SelectItem>
|
|
{fromApiFields.map((field) => (
|
|
<SelectItem key={field} value={field}>
|
|
<div className="flex items-center gap-2">
|
|
<span>{field}</span>
|
|
{apiPreviewData[0] && apiPreviewData[0][field] !== undefined && (
|
|
<span className="text-muted-foreground text-xs">
|
|
(예: {String(apiPreviewData[0][field]).substring(0, 15)}
|
|
{String(apiPreviewData[0][field]).length > 15 ? "..." : ""})
|
|
</span>
|
|
)}
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
value={mapping.fixedValue}
|
|
onChange={(e) => updateMappingListItem(mapping.id, { fixedValue: e.target.value })}
|
|
placeholder="고정값 입력"
|
|
className="h-9"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* 삭제 버튼 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => removeMappingListItem(mapping.id)}
|
|
className="text-muted-foreground hover:text-destructive h-8 w-8 shrink-0"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* db-to-db 기존 매핑 UI */}
|
|
{batchType === "db-to-db" && (
|
|
<>
|
|
{mappings.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">
|
|
{mappings.map((mapping, index) => (
|
|
<div
|
|
key={index}
|
|
className="bg-background flex items-center gap-2 rounded-lg border p-3"
|
|
>
|
|
<div className="flex-1">
|
|
<Select
|
|
value={mapping.from_column_name || ""}
|
|
onValueChange={(value) => {
|
|
updateMapping(index, "from_column_name", value);
|
|
const selectedColumn = fromColumns.find(
|
|
(col) => col.column_name === value
|
|
);
|
|
if (selectedColumn) {
|
|
updateMapping(
|
|
index,
|
|
"from_column_type",
|
|
selectedColumn.data_type
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-9 text-xs">
|
|
<SelectValue placeholder="FROM 컬럼" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{fromColumns.map((column) => (
|
|
<SelectItem
|
|
key={column.column_name}
|
|
value={column.column_name}
|
|
>
|
|
{column.column_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<span className="text-muted-foreground text-xs">-></span>
|
|
<div className="flex-1">
|
|
<Select
|
|
value={mapping.to_column_name || ""}
|
|
onValueChange={(value) => {
|
|
updateMapping(index, "to_column_name", value);
|
|
const selectedColumn = toColumns.find(
|
|
(col) => col.column_name === value
|
|
);
|
|
if (selectedColumn) {
|
|
updateMapping(
|
|
index,
|
|
"to_column_type",
|
|
selectedColumn.data_type
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-9 text-xs">
|
|
<SelectValue placeholder="TO 컬럼" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{toColumns.map((column) => (
|
|
<SelectItem
|
|
key={column.column_name}
|
|
value={column.column_name}
|
|
>
|
|
{column.column_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => removeMapping(index)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* db-to-restapi 기존 매핑 UI */}
|
|
{batchType === "db-to-restapi" && (
|
|
<>
|
|
{mappings.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>
|
|
</div>
|
|
) : (
|
|
<div className="bg-muted/30 h-[360px] space-y-3 overflow-y-auto rounded-lg border p-3">
|
|
{mappings.map((mapping, index) => (
|
|
<div
|
|
key={index}
|
|
className="bg-background flex items-center gap-2 rounded-lg border p-3"
|
|
>
|
|
<div className="flex-1">
|
|
<Input
|
|
value={mapping.from_column_name || ""}
|
|
readOnly
|
|
className="h-9 text-xs"
|
|
/>
|
|
</div>
|
|
<span className="text-muted-foreground text-xs">-></span>
|
|
<div className="flex-1">
|
|
<Input
|
|
value={mapping.to_column_name || ""}
|
|
readOnly
|
|
className="h-9 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 하단 버튼 */}
|
|
<div className="flex justify-end space-x-2 border-t pt-6">
|
|
<Button variant="outline" onClick={() => router.push("/admin/batchmng")}>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
onClick={saveBatchConfig}
|
|
disabled={loading || (batchType === "restapi-to-db" ? mappingList.length === 0 : mappings.length === 0)}
|
|
>
|
|
{loading ? (
|
|
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Save className="mr-2 h-4 w-4" />
|
|
)}
|
|
{loading ? "저장 중..." : "배치 설정 저장"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|