ERP-node/frontend/app/(main)/admin/batchmng/edit/[id]/page.tsx

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">-&gt;</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">-&gt;</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>
);
}