575098698c
- 평가 필드 미선택 시 select 에 빨간 테두리 + ring 강조 (한눈에 누락 식별) - 라벨에 * 필수 마크 + placeholder 를 '조건을 평가할 API 필드 선택 (필수)' 로 변경 - 안내 텍스트 추가: 'status 컬럼에 enrlFg 의 J01→active 변환 시 평가 필드=enrlFg' 예시 - 저장된 apiField 가 fromApiFields 옵션에 없을 때 동적으로 (저장값) 라벨로 추가 → 응답 미리보기 안 한 편집 모드에서도 기존 값 보존되어 그대로 저장 가능 - cn 유틸 import 추가 (조건부 클래스 적용 시 ReferenceError 방지) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2739 lines
124 KiB
TypeScript
2739 lines
124 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useEffect, useMemo, memo } from "react";
|
||
import { useRouter, useSearchParams } from "next/navigation";
|
||
import { useTabStore } from "@/stores/tabStore";
|
||
import { BatchAPI } from "@/lib/api/batch";
|
||
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, Link as LinkIcon } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||
import { cn } from "@/lib/utils";
|
||
import { BatchManagementAPI } from "@/lib/api/batchManagement";
|
||
import {
|
||
ExternalRestApiConnectionAPI,
|
||
ExternalRestApiConnection,
|
||
} from "@/lib/api/externalRestApiConnection";
|
||
|
||
// 타입 정의
|
||
type BatchType = "db-to-restapi" | "restapi-to-db" | "restapi-to-restapi";
|
||
|
||
interface BatchTypeOption {
|
||
value: BatchType;
|
||
label: string;
|
||
description: string;
|
||
}
|
||
|
||
interface BatchConnectionInfo {
|
||
id: number;
|
||
name: string;
|
||
type: string;
|
||
}
|
||
|
||
interface BatchColumnInfo {
|
||
column_name: string;
|
||
data_type: string;
|
||
is_nullable: string;
|
||
}
|
||
|
||
// 통합 매핑 아이템 타입
|
||
interface ConditionalRule {
|
||
when: string;
|
||
then: string;
|
||
}
|
||
interface ConditionalConfig {
|
||
rules: ConditionalRule[];
|
||
default: string;
|
||
}
|
||
interface MappingItem {
|
||
id: string;
|
||
dbColumn: string;
|
||
sourceType: "api" | "fixed" | "conditional";
|
||
apiField: string;
|
||
fixedValue: string;
|
||
// 조건부 변환: source 필드값에 따라 다른 값을 저장
|
||
// apiField 가 평가할 API 필드명 (예: dept_name)
|
||
// conditionalConfig.rules: [{ when: '영업부', then: '1' }, ...]
|
||
// conditionalConfig.default: 매칭 안되면 사용할 기본값
|
||
conditionalConfig?: ConditionalConfig;
|
||
}
|
||
|
||
interface RestApiToDbMappingCardProps {
|
||
fromApiFields: string[];
|
||
toColumns: BatchColumnInfo[];
|
||
fromApiData: any[];
|
||
mappingList: MappingItem[];
|
||
setMappingList: React.Dispatch<React.SetStateAction<MappingItem[]>>;
|
||
}
|
||
|
||
interface DbToRestApiMappingCardProps {
|
||
fromColumns: BatchColumnInfo[];
|
||
selectedColumns: string[];
|
||
toApiFields: string[];
|
||
dbToApiFieldMapping: Record<string, string>;
|
||
setDbToApiFieldMapping: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||
setToApiBody: (body: string) => void;
|
||
}
|
||
|
||
interface BatchManagementNewPageProps {
|
||
adminParams?: { id?: string; edit?: string };
|
||
}
|
||
|
||
export default function BatchManagementNewPage(props: BatchManagementNewPageProps = {}) {
|
||
const router = useRouter();
|
||
const { openTab } = useTabStore();
|
||
// adminUrl 파싱: /admin/batch-management-new?edit=12 형태로 들어오면 query 추출
|
||
const searchParams = useSearchParams();
|
||
const editIdRaw =
|
||
props.adminParams?.edit ??
|
||
props.adminParams?.id ??
|
||
searchParams?.get("edit") ??
|
||
null;
|
||
const editId = editIdRaw && !isNaN(Number(editIdRaw)) ? Number(editIdRaw) : null;
|
||
const isEditMode = editId !== null;
|
||
const [editLoading, setEditLoading] = useState(false);
|
||
|
||
// 기본 상태
|
||
const [batchName, setBatchName] = useState("");
|
||
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
|
||
const [description, setDescription] = useState("");
|
||
|
||
// 인증 토큰 설정
|
||
const [authTokenMode, setAuthTokenMode] = useState<"direct" | "db">("direct"); // 직접입력 / DB에서 선택
|
||
const [authServiceName, setAuthServiceName] = useState("");
|
||
const [authServiceNames, setAuthServiceNames] = useState<string[]>([]);
|
||
|
||
// 연결 정보
|
||
const [connections, setConnections] = useState<BatchConnectionInfo[]>([]);
|
||
const [toConnection, setToConnection] = useState<BatchConnectionInfo | null>(null);
|
||
const [toTables, setToTables] = useState<string[]>([]);
|
||
const [toTable, setToTable] = useState("");
|
||
const [toColumns, setToColumns] = useState<BatchColumnInfo[]>([]);
|
||
|
||
// REST API 설정 (REST API → DB용)
|
||
const [fromApiUrl, setFromApiUrl] = useState("");
|
||
const [fromApiKey, setFromApiKey] = useState("");
|
||
const [fromEndpoint, setFromEndpoint] = useState("");
|
||
const [fromApiMethod, setFromApiMethod] = useState<"GET" | "POST" | "PUT" | "DELETE">("GET");
|
||
const [fromApiBody, setFromApiBody] = useState(""); // Request Body (JSON)
|
||
const [dataArrayPath, setDataArrayPath] = useState(""); // 데이터 배열 경로 (예: response, data.items)
|
||
|
||
// 등록된 REST API 연결 (외부 커넥션 관리에서 등록된 연결 선택)
|
||
const [registeredRestApis, setRegisteredRestApis] = useState<ExternalRestApiConnection[]>([]);
|
||
const [selectedRestApiId, setSelectedRestApiId] = useState<string>("manual"); // "manual" 또는 연결 id 문자열
|
||
|
||
// REST API 파라미터 설정
|
||
const [apiParamType, setApiParamType] = useState<"none" | "url" | "query">("none");
|
||
const [apiParamName, setApiParamName] = useState(""); // 파라미터명 (예: userId, id)
|
||
const [apiParamValue, setApiParamValue] = useState(""); // 파라미터 값 또는 템플릿
|
||
const [apiParamSource, setApiParamSource] = useState<"static" | "dynamic">("static"); // 정적 값 또는 동적 값
|
||
|
||
// DB → REST API용 상태
|
||
const [fromConnection, setFromConnection] = useState<BatchConnectionInfo | null>(null);
|
||
const [fromTables, setFromTables] = useState<string[]>([]);
|
||
const [fromTable, setFromTable] = useState("");
|
||
const [fromColumns, setFromColumns] = useState<BatchColumnInfo[]>([]);
|
||
const [selectedColumns, setSelectedColumns] = useState<string[]>([]); // 선택된 컬럼들
|
||
const [dbToApiFieldMapping, setDbToApiFieldMapping] = useState<Record<string, string>>({}); // DB 컬럼 → API 필드 매핑
|
||
|
||
// REST API 대상 설정 (DB → REST API용)
|
||
const [toApiUrl, setToApiUrl] = useState("");
|
||
const [toApiKey, setToApiKey] = useState("");
|
||
const [toEndpoint, setToEndpoint] = useState("");
|
||
const [toApiMethod, setToApiMethod] = useState<"POST" | "PUT" | "DELETE">("POST");
|
||
const [toApiBody, setToApiBody] = useState<string>(""); // Request Body 템플릿
|
||
const [toApiFields, setToApiFields] = useState<string[]>([]); // TO API 필드 목록
|
||
const [urlPathColumn, setUrlPathColumn] = useState(""); // URL 경로에 사용할 컬럼 (PUT/DELETE용)
|
||
|
||
// API 데이터 미리보기
|
||
const [fromApiData, setFromApiData] = useState<any[]>([]);
|
||
const [fromApiFields, setFromApiFields] = useState<string[]>([]);
|
||
|
||
// 응답 원본 + 빠른 테스트 (DB 테이블 선택 전에 응답 형태 확인용)
|
||
const [rawResponse, setRawResponse] = useState<any>(null);
|
||
const [rawResponseLoading, setRawResponseLoading] = useState(false);
|
||
const [rawResponseError, setRawResponseError] = useState<string>("");
|
||
|
||
// 통합 매핑 리스트
|
||
const [mappingList, setMappingList] = useState<MappingItem[]>([]);
|
||
|
||
// INSERT/UPSERT 설정
|
||
const [saveMode, setSaveMode] = useState<"INSERT" | "UPSERT">("INSERT");
|
||
const [conflictKey, setConflictKey] = useState("");
|
||
|
||
// 행 단위 제외 필터 — 특정 API 필드 값에 해당하는 행을 동기화에서 제외
|
||
// 예) loginId="wace" 인 통합 ERP 계정 제외
|
||
const [rowFilterRules, setRowFilterRules] = useState<Array<{ column: string; op: "eq" | "neq"; value: string }>>([]);
|
||
|
||
// 배치 타입 상태
|
||
const [batchType, setBatchType] = useState<BatchType>("restapi-to-db");
|
||
|
||
// 배치 타입 옵션
|
||
const batchTypeOptions: BatchTypeOption[] = [
|
||
{
|
||
value: "restapi-to-db",
|
||
label: "REST API → DB",
|
||
description: "REST API에서 데이터베이스로 데이터 수집",
|
||
},
|
||
{
|
||
value: "db-to-restapi",
|
||
label: "DB → REST API",
|
||
description: "데이터베이스에서 REST API로 데이터 전송",
|
||
},
|
||
];
|
||
|
||
// 초기 데이터 로드
|
||
useEffect(() => {
|
||
loadConnections();
|
||
loadAuthServiceNames();
|
||
loadRegisteredRestApis();
|
||
}, []);
|
||
|
||
// 등록된 REST API 연결 목록 로드 (외부 커넥션 관리)
|
||
const loadRegisteredRestApis = async () => {
|
||
try {
|
||
const list = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" });
|
||
setRegisteredRestApis(list);
|
||
} catch (error) {
|
||
console.error("등록된 REST API 연결 목록 로드 실패:", error);
|
||
}
|
||
};
|
||
|
||
// FROM API 응답 빠른 테스트 (DB 테이블 선택 전에 응답 구조 확인용)
|
||
// - 등록된 연결을 사용하면 testConnectionById, 직접 입력이면 testConnection 호출
|
||
// - 응답 본문을 그대로 보여줘서 어떤 필드가 있는지 확인 → DB 매핑 시 사용
|
||
const runQuickResponseTest = async () => {
|
||
setRawResponseError("");
|
||
if (!fromApiUrl?.trim() || !fromEndpoint?.trim()) {
|
||
setRawResponseError("API 서버 URL과 엔드포인트를 입력하거나 등록된 연결을 선택해주세요.");
|
||
return;
|
||
}
|
||
setRawResponseLoading(true);
|
||
setRawResponse(null);
|
||
try {
|
||
let result: any;
|
||
if (selectedRestApiId !== "manual") {
|
||
// 등록된 연결: 백엔드가 인증(Wehago HMAC 등)을 처리
|
||
result = await ExternalRestApiConnectionAPI.testConnectionById(
|
||
Number(selectedRestApiId),
|
||
fromEndpoint,
|
||
);
|
||
} else {
|
||
// 직접 입력 모드
|
||
let parsedBody: any = undefined;
|
||
if (
|
||
(fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE") &&
|
||
fromApiBody
|
||
) {
|
||
try {
|
||
parsedBody = JSON.parse(fromApiBody);
|
||
} catch {
|
||
parsedBody = fromApiBody;
|
||
}
|
||
}
|
||
result = await ExternalRestApiConnectionAPI.testConnection({
|
||
base_url: fromApiUrl,
|
||
endpoint: fromEndpoint,
|
||
method: fromApiMethod as any,
|
||
headers: { "Content-Type": "application/json" },
|
||
body: parsedBody,
|
||
auth_type: fromApiKey ? "bearer" : "none",
|
||
auth_config: fromApiKey
|
||
? { token: fromApiKey.replace(/^Bearer\s+/i, "") }
|
||
: undefined,
|
||
timeout: 30000,
|
||
});
|
||
}
|
||
|
||
if (result.success) {
|
||
setRawResponse(result.response_data);
|
||
|
||
// 응답에서 배열 자동 탐색해서 fromApiFields/fromApiData 도 채움 (매핑 카드 즉시 표시)
|
||
const findArr = (o: any, depth = 0): any[] | null => {
|
||
if (Array.isArray(o)) return o;
|
||
if (depth >= 4 || typeof o !== "object" || o === null) return null;
|
||
for (const v of Object.values(o)) {
|
||
const a = findArr(v, depth + 1);
|
||
if (a) return a;
|
||
}
|
||
return null;
|
||
};
|
||
const arr = findArr(result.response_data);
|
||
if (arr && arr.length > 0 && typeof arr[0] === "object" && arr[0] !== null) {
|
||
const fields = Object.keys(arr[0]);
|
||
setFromApiFields(fields);
|
||
setFromApiData(arr);
|
||
toast.success(
|
||
`응답 수신 (${result.response_time ?? "-"}ms) — 배열 ${arr.length}건 / 필드 ${fields.length}개 추출. 아래에서 DB 컬럼과 매핑하세요.`
|
||
);
|
||
} else if (
|
||
result.response_data &&
|
||
typeof result.response_data === "object" &&
|
||
!Array.isArray(result.response_data)
|
||
) {
|
||
// 단일 객체 응답
|
||
const fields = Object.keys(result.response_data as any);
|
||
setFromApiFields(fields);
|
||
setFromApiData([result.response_data]);
|
||
toast.success(`응답 수신 (${result.response_time ?? "-"}ms) — 필드 ${fields.length}개 추출`);
|
||
} else {
|
||
toast.success(`응답 수신 (${result.response_time ?? "-"}ms)`);
|
||
}
|
||
} else {
|
||
setRawResponseError(result.message || result.error_details || "API 호출 실패");
|
||
}
|
||
} catch (e: any) {
|
||
setRawResponseError(e?.message || "API 호출 중 오류");
|
||
} finally {
|
||
setRawResponseLoading(false);
|
||
}
|
||
};
|
||
|
||
// Amaranth (Wehago/RPS ERP) 연결 → 권장 적재 대상 테이블 매핑 (편집 모드 외 신규 생성 시 자동 셋팅)
|
||
// 사용자 선택 즉시 TO 패널의 DB 커넥션(내부 DB) + 테이블 + 컬럼 + UPSERT/충돌키 + 매핑까지 자동 채운다
|
||
const AMARANTH_TARGET_PRESETS: Record<
|
||
string,
|
||
{
|
||
table: string;
|
||
saveMode: "INSERT" | "UPSERT";
|
||
conflictKey: string;
|
||
mappings: Array<{ from: string; to: string }>;
|
||
dataArrayPath: string;
|
||
}
|
||
> = {
|
||
"Amaranth - 부서": {
|
||
table: "dept_info",
|
||
saveMode: "UPSERT",
|
||
conflictKey: "dept_code",
|
||
dataArrayPath: "data",
|
||
mappings: [
|
||
{ from: "deptCd", to: "dept_code" },
|
||
{ from: "deptNm", to: "dept_name" },
|
||
{ from: "upDeptCd", to: "parent_dept_code" },
|
||
{ from: "deptShortNm", to: "dept_short_name" },
|
||
{ from: "deptEngNm", to: "dept_eng_name" },
|
||
{ from: "deptLvl", to: "dept_level" },
|
||
{ from: "sortSq", to: "sort_seq" },
|
||
{ from: "useYn", to: "use_yn" },
|
||
],
|
||
},
|
||
"Amaranth - 사원": {
|
||
table: "user_info",
|
||
saveMode: "UPSERT",
|
||
conflictKey: "sabun",
|
||
dataArrayPath: "data",
|
||
mappings: [
|
||
{ from: "sabun", to: "sabun" },
|
||
{ from: "empSeq", to: "user_id" }, // user_info.user_id NOT NULL 충족 — loginId 배치가 나중에 덮어씀
|
||
{ from: "empSeq", to: "emp_seq" }, // loginId 배치의 매칭 키
|
||
{ from: "empNm", to: "user_name" },
|
||
{ from: "empNmEn", to: "user_name_eng" },
|
||
{ from: "deptCd", to: "dept_code" },
|
||
{ from: "deptNm", to: "dept_name" },
|
||
{ from: "posCd", to: "position_code" },
|
||
{ from: "posNm", to: "position_name" },
|
||
{ from: "rspofcCd", to: "rank_code" },
|
||
{ from: "rspofcNm", to: "rank_name" },
|
||
{ from: "emalAdd", to: "email" },
|
||
{ from: "outemalAdd", to: "out_email" },
|
||
{ from: "tel", to: "tel" },
|
||
{ from: "emgcTel", to: "cell_phone" },
|
||
{ from: "joinDt", to: "join_date" },
|
||
{ from: "retireDt", to: "retire_date" },
|
||
{ from: "wrkSttsCd", to: "work_status" },
|
||
{ from: "sexFg", to: "gender_fg" },
|
||
],
|
||
},
|
||
"Amaranth - Wehago 사용자": {
|
||
table: "user_info",
|
||
saveMode: "UPSERT",
|
||
conflictKey: "emp_seq",
|
||
dataArrayPath: "body.data",
|
||
mappings: [
|
||
{ from: "empSeq", to: "emp_seq" },
|
||
{ from: "loginId", to: "user_id" },
|
||
],
|
||
},
|
||
"Amaranth - 거래처": {
|
||
table: "customer_mng",
|
||
saveMode: "UPSERT",
|
||
conflictKey: "customer_code",
|
||
dataArrayPath: "data",
|
||
mappings: [
|
||
{ from: "trCd", to: "customer_code" },
|
||
{ from: "trNm", to: "customer_name" },
|
||
{ from: "trShortNm", to: "short_name" },
|
||
{ from: "bizNo", to: "business_number" },
|
||
{ from: "corpNo", to: "corp_number" },
|
||
{ from: "repNm", to: "ceo_name" },
|
||
{ from: "bizCondtn", to: "biz_condition" },
|
||
{ from: "bizItem", to: "biz_item" },
|
||
{ from: "zipCd", to: "zip_code" },
|
||
{ from: "addr", to: "address" },
|
||
{ from: "addrDtl", to: "address_detail" },
|
||
{ from: "tel", to: "tel" },
|
||
{ from: "faxNo", to: "fax_no" },
|
||
{ from: "hpNo", to: "hp_no" },
|
||
{ from: "emalAdd", to: "email" },
|
||
{ from: "chargeNm", to: "charge_name" },
|
||
{ from: "chargeTel", to: "charge_tel" },
|
||
{ from: "chargeEmal", to: "charge_email" },
|
||
{ from: "trFg", to: "customer_type" },
|
||
{ from: "bankNm", to: "bank_name" },
|
||
{ from: "bankAcct", to: "bank_account" },
|
||
{ from: "acctOwner", to: "account_owner" },
|
||
{ from: "natnCd", to: "nation_code" },
|
||
{ from: "currCd", to: "currency_code" },
|
||
{ from: "useYn", to: "use_yn" },
|
||
{ from: "regDt", to: "reg_date" },
|
||
],
|
||
},
|
||
"Amaranth - 창고": {
|
||
table: "warehouse_info",
|
||
saveMode: "UPSERT",
|
||
conflictKey: "warehouse_code",
|
||
dataArrayPath: "data",
|
||
mappings: [
|
||
{ from: "lctnCd", to: "warehouse_code" },
|
||
{ from: "lctnNm", to: "warehouse_name" },
|
||
{ from: "lctnFg", to: "warehouse_type" },
|
||
{ from: "lctnFgNm", to: "warehouse_type_name" },
|
||
{ from: "upLctnCd", to: "parent_loc_code" },
|
||
{ from: "baselocFg", to: "baseloc_fg" },
|
||
{ from: "sortSq", to: "sort_seq" },
|
||
{ from: "useYn", to: "use_yn" },
|
||
],
|
||
},
|
||
"Amaranth - 계정과목": {
|
||
table: "account_code_info",
|
||
saveMode: "UPSERT",
|
||
conflictKey: "account_code",
|
||
dataArrayPath: "data",
|
||
mappings: [
|
||
{ from: "acctCd", to: "account_code" },
|
||
{ from: "acctNm", to: "account_name" },
|
||
{ from: "acctTy", to: "account_type" },
|
||
{ from: "acctNmk", to: "account_short" },
|
||
{ from: "groupSeq", to: "group_seq" },
|
||
{ from: "groupCd", to: "group_code" },
|
||
{ from: "groupNm", to: "group_name" },
|
||
{ from: "drcrFg", to: "dr_cr_fg" },
|
||
{ from: "subDisp", to: "sub_disp" },
|
||
{ from: "subDispNm", to: "sub_disp_name" },
|
||
{ from: "chFg", to: "ch_fg" },
|
||
{ from: "chFgNm", to: "ch_fg_name" },
|
||
{ from: "budFg", to: "bud_fg" },
|
||
{ from: "budFgNm", to: "bud_fg_name" },
|
||
{ from: "attrFg", to: "attr_fg" },
|
||
{ from: "attrFgNm", to: "attr_fg_name" },
|
||
{ from: "racctCd", to: "racct_code" },
|
||
{ from: "racctNm", to: "racct_name" },
|
||
{ from: "fillYn", to: "fill_yn" },
|
||
{ from: "ctrlCds", to: "ctrl_cds" },
|
||
{ from: "extInputCd", to: "ext_input_cd" },
|
||
{ from: "useYn", to: "use_yn" },
|
||
],
|
||
},
|
||
};
|
||
|
||
// 등록된 연결 선택 시 폼 자동 채우기 + API 호출 + 응답 필드 추출 (자동 매핑 준비)
|
||
const applyRegisteredRestApi = async (id: string) => {
|
||
setSelectedRestApiId(id);
|
||
if (id === "manual") return;
|
||
const conn = registeredRestApis.find((c) => String(c.id) === id);
|
||
if (!conn) return;
|
||
|
||
setFromApiUrl(conn.base_url || "");
|
||
setFromEndpoint(conn.endpoint_path || "");
|
||
setFromApiMethod((conn.default_method as any) || "GET");
|
||
setFromApiBody(conn.default_body || "");
|
||
|
||
// ── Amaranth 프리셋이면 TO DB 자동 셋팅 (편집 모드는 이미 자체 로직이 있으므로 스킵) ──
|
||
const preset = AMARANTH_TARGET_PRESETS[conn.connection_name];
|
||
if (preset && !isEditMode) {
|
||
setDataArrayPath(preset.dataArrayPath);
|
||
setSaveMode(preset.saveMode);
|
||
setConflictKey(preset.conflictKey);
|
||
// 내부 DB 커넥션 자동 선택 + 테이블/컬럼 로드
|
||
const internalConn = connections.find((c) => c.type === "internal");
|
||
if (internalConn) {
|
||
setToConnection(internalConn);
|
||
try {
|
||
const tablesResult = await BatchManagementAPI.getTablesFromConnection(
|
||
"internal",
|
||
internalConn.id,
|
||
);
|
||
const tableNames = Array.isArray(tablesResult)
|
||
? tablesResult.map((t: any) => (typeof t === "string" ? t : t.table_name || String(t)))
|
||
: [];
|
||
setToTables(tableNames);
|
||
// 대상 테이블 자동 선택 + 컬럼 로드
|
||
if (tableNames.includes(preset.table)) {
|
||
setToTable(preset.table);
|
||
try {
|
||
const cols = await BatchManagementAPI.getTableColumns(
|
||
"internal",
|
||
preset.table,
|
||
internalConn.id,
|
||
);
|
||
if (cols && cols.length > 0) setToColumns(cols);
|
||
} catch (e) {
|
||
console.warn("[preset] 테이블 컬럼 로드 실패", e);
|
||
}
|
||
} else {
|
||
// 테이블이 없으면 백엔드에 자동 생성 요청 (관련 테이블이 마이그레이션돼 있어야 정상)
|
||
toast.warning(
|
||
`대상 테이블 '${preset.table}' 이 내부 DB에 없어요. 200_erp_sync_tables 마이그레이션을 실행했는지 확인해주세요.`,
|
||
);
|
||
}
|
||
} catch (e) {
|
||
console.warn("[preset] 내부 DB 테이블 목록 로드 실패", e);
|
||
}
|
||
}
|
||
// 매핑 자동 구성 — 응답 호출 후에 채우기 위해 마킹
|
||
}
|
||
|
||
// 인증 토큰 자동 채움 (직접 입력 모드)
|
||
setAuthTokenMode("direct");
|
||
setAuthServiceName("");
|
||
if (conn.auth_type === "bearer" && conn.auth_config?.token) {
|
||
setFromApiKey(`Bearer ${conn.auth_config.token}`);
|
||
} else if (conn.auth_type === "api-key" && conn.auth_config?.keyValue) {
|
||
setFromApiKey(conn.auth_config.keyValue);
|
||
} else if (conn.auth_type === "wehago") {
|
||
// Wehago/Amaranth: 백엔드가 매 요청마다 wehago-sign(HMAC) 자동 부착하므로 토큰 입력 불필요
|
||
setFromApiKey("");
|
||
} else {
|
||
setFromApiKey("");
|
||
}
|
||
|
||
// 자동으로 API 호출 → 응답 본문 + 필드 추출하여 매핑 카드 즉시 활성화
|
||
setRawResponseError("");
|
||
setRawResponseLoading(true);
|
||
setRawResponse(null);
|
||
try {
|
||
const result = await ExternalRestApiConnectionAPI.testConnectionById(
|
||
Number(id),
|
||
conn.endpoint_path || undefined,
|
||
);
|
||
|
||
if (result.success) {
|
||
setRawResponse(result.response_data);
|
||
|
||
const findArr = (o: any, depth = 0): any[] | null => {
|
||
if (Array.isArray(o)) return o;
|
||
if (depth >= 4 || typeof o !== "object" || o === null) return null;
|
||
for (const v of Object.values(o)) {
|
||
const a = findArr(v, depth + 1);
|
||
if (a) return a;
|
||
}
|
||
return null;
|
||
};
|
||
const arr = findArr(result.response_data);
|
||
if (arr && arr.length > 0 && typeof arr[0] === "object" && arr[0] !== null) {
|
||
const fields = Object.keys(arr[0]);
|
||
setFromApiFields(fields);
|
||
setFromApiData(arr);
|
||
|
||
// Amaranth 프리셋이면 매핑 목록도 자동 구성 (편집 모드는 자체 로직이 처리)
|
||
if (preset && !isEditMode) {
|
||
const autoMappings: MappingItem[] = preset.mappings
|
||
.filter((m) => fields.includes(m.from))
|
||
.map((m, idx) => ({
|
||
id: `auto-${idx}-${Date.now()}`,
|
||
dbColumn: m.to,
|
||
sourceType: "api" as const,
|
||
apiField: m.from,
|
||
fixedValue: "",
|
||
}));
|
||
if (autoMappings.length > 0) {
|
||
setMappingList(autoMappings);
|
||
}
|
||
}
|
||
|
||
toast.success(
|
||
`'${conn.connection_name}' API 호출 완료 — 배열 ${arr.length}건 / 필드 ${fields.length}개 추출. ${preset && !isEditMode ? "매핑 자동 구성 완료." : "우측에서 DB 테이블을 선택하면 매핑 화면이 열려요."}`,
|
||
);
|
||
} else if (
|
||
result.response_data &&
|
||
typeof result.response_data === "object" &&
|
||
!Array.isArray(result.response_data)
|
||
) {
|
||
const fields = Object.keys(result.response_data as any);
|
||
setFromApiFields(fields);
|
||
setFromApiData([result.response_data]);
|
||
toast.success(`'${conn.connection_name}' API 호출 완료 — 필드 ${fields.length}개 추출`);
|
||
} else {
|
||
toast.success(`'${conn.connection_name}' API 호출 완료 — 응답을 받았어요`);
|
||
}
|
||
} else {
|
||
setRawResponseError(result.message || result.error_details || "API 호출 실패");
|
||
toast.error(`'${conn.connection_name}' API 호출 실패: ${result.message || ""}`);
|
||
}
|
||
} catch (e: any) {
|
||
setRawResponseError(e?.message || "API 호출 중 오류");
|
||
toast.error(`API 호출 중 오류: ${e?.message ?? ""}`);
|
||
} finally {
|
||
setRawResponseLoading(false);
|
||
}
|
||
};
|
||
|
||
// 인증 서비스명 목록 로드
|
||
const loadAuthServiceNames = async () => {
|
||
try {
|
||
const serviceNames = await BatchManagementAPI.getAuthServiceNames();
|
||
setAuthServiceNames(serviceNames);
|
||
} catch (error) {
|
||
console.error("인증 서비스 목록 로드 실패:", error);
|
||
}
|
||
};
|
||
|
||
// 배치 타입 변경 시 상태 초기화
|
||
// - 편집 모드에서는 사용자가 다른 타입으로 직접 바꾸지 않는 한 폼 데이터를 보존해야 하므로
|
||
// 초기 진입 시 (editLoading 동안) 의 batchType 변화는 무시한다.
|
||
const [hasInitialized, setHasInitialized] = useState(false);
|
||
useEffect(() => {
|
||
if (!hasInitialized) {
|
||
setHasInitialized(true);
|
||
return;
|
||
}
|
||
if (isEditMode) return; // 편집 모드는 데이터를 보존
|
||
// 공통 초기화
|
||
setMappingList([]);
|
||
|
||
// REST API → DB 관련 초기화
|
||
setToConnection(null);
|
||
setToTables([]);
|
||
setToTable("");
|
||
setToColumns([]);
|
||
setFromApiUrl("");
|
||
setFromApiKey("");
|
||
setFromEndpoint("");
|
||
setFromApiData([]);
|
||
setFromApiFields([]);
|
||
setSelectedRestApiId("manual");
|
||
|
||
// DB → REST API 관련 초기화
|
||
setFromConnection(null);
|
||
setFromTables([]);
|
||
setFromTable("");
|
||
setFromColumns([]);
|
||
setSelectedColumns([]);
|
||
setDbToApiFieldMapping({});
|
||
setToApiUrl("");
|
||
setToApiKey("");
|
||
setToEndpoint("");
|
||
setToApiBody("");
|
||
setToApiFields([]);
|
||
}, [batchType]);
|
||
|
||
// 연결 목록 로드
|
||
const loadConnections = async () => {
|
||
try {
|
||
const result = await BatchManagementAPI.getAvailableConnections();
|
||
setConnections(result || []);
|
||
} catch (error) {
|
||
console.error("연결 목록 로드 오류:", error);
|
||
toast.error("연결 목록을 불러오는데 실패했습니다.");
|
||
}
|
||
};
|
||
|
||
// ──────────────────────────────────────────────────────────────────
|
||
// 편집 모드 (?edit=N) — 기존 배치 설정 불러와 폼 자동 채움
|
||
// - 연결 목록 + 등록된 REST API 목록 + DB 커넥션이 모두 로드된 뒤 실행
|
||
// ──────────────────────────────────────────────────────────────────
|
||
useEffect(() => {
|
||
if (!isEditMode || !editId) return;
|
||
if (connections.length === 0) return;
|
||
if (registeredRestApis.length === 0) return;
|
||
|
||
let cancelled = false;
|
||
(async () => {
|
||
setEditLoading(true);
|
||
try {
|
||
const cfg = await BatchAPI.getBatchConfig(editId);
|
||
if (cancelled || !cfg) return;
|
||
|
||
// 기본 정보
|
||
setBatchName(cfg.batch_name || "");
|
||
setCronSchedule(cfg.cron_schedule || "0 12 * * *");
|
||
setDescription(cfg.description || "");
|
||
if ((cfg as any).save_mode) setSaveMode((cfg as any).save_mode);
|
||
if ((cfg as any).conflict_key) setConflictKey((cfg as any).conflict_key);
|
||
if ((cfg as any).data_array_path) setDataArrayPath((cfg as any).data_array_path);
|
||
// 행 단위 제외 필터 로드
|
||
const rfRaw = (cfg as any).row_filter_config;
|
||
if (rfRaw) {
|
||
try {
|
||
const parsed = typeof rfRaw === "string" ? JSON.parse(rfRaw) : rfRaw;
|
||
const rules = Array.isArray(parsed?.exclude) ? parsed.exclude : [];
|
||
setRowFilterRules(rules.map((r: any) => ({
|
||
column: String(r.column || ""),
|
||
op: (r.op === "neq" ? "neq" : "eq") as "eq" | "neq",
|
||
value: String(r.value ?? ""),
|
||
})));
|
||
} catch {/* ignore */}
|
||
}
|
||
if ((cfg as any).auth_service_name) {
|
||
setAuthTokenMode("db");
|
||
setAuthServiceName((cfg as any).auth_service_name);
|
||
}
|
||
|
||
const mappings = cfg.batch_mappings || [];
|
||
if (mappings.length === 0) {
|
||
toast.warning("이 배치에는 매핑 정보가 없어요.");
|
||
return;
|
||
}
|
||
const first: any = mappings[0];
|
||
|
||
// FROM/TO 타입 결정
|
||
const fromType = first.from_connection_type;
|
||
const toType = first.to_connection_type;
|
||
if (fromType === "restapi") {
|
||
setBatchType("restapi-to-db");
|
||
|
||
// 등록된 REST API 연결인 경우 → 자동 적용 (URL/엔드포인트/Body/인증)
|
||
if (first.from_connection_id) {
|
||
const conn = registeredRestApis.find((c) => c.id === first.from_connection_id);
|
||
if (conn) {
|
||
setSelectedRestApiId(String(conn.id));
|
||
setFromApiUrl(conn.base_url || first.from_api_url || "");
|
||
setFromEndpoint(conn.endpoint_path || first.from_table_name || "");
|
||
setFromApiMethod((conn.default_method as any) || (first.from_api_method as any) || "POST");
|
||
setFromApiBody(conn.default_body || first.from_api_body || "");
|
||
setAuthTokenMode("direct");
|
||
if (conn.auth_type !== "wehago") {
|
||
if (conn.auth_type === "bearer" && conn.auth_config?.token) {
|
||
setFromApiKey(`Bearer ${conn.auth_config.token}`);
|
||
} else if (conn.auth_type === "api-key" && conn.auth_config?.keyValue) {
|
||
setFromApiKey(conn.auth_config.keyValue);
|
||
}
|
||
}
|
||
} else {
|
||
// 등록 연결 ID 가 있는데 목록에 없는 경우 → 직접 입력 모드 fallback
|
||
setSelectedRestApiId("manual");
|
||
setFromApiUrl(first.from_api_url || "");
|
||
setFromEndpoint(first.from_table_name || "");
|
||
setFromApiMethod((first.from_api_method as any) || "GET");
|
||
setFromApiBody(first.from_api_body || "");
|
||
setFromApiKey(first.from_api_key || "");
|
||
}
|
||
} else {
|
||
// 직접 입력으로 저장된 경우
|
||
setSelectedRestApiId("manual");
|
||
setFromApiUrl(first.from_api_url || "");
|
||
setFromEndpoint(first.from_table_name || "");
|
||
setFromApiMethod((first.from_api_method as any) || "GET");
|
||
setFromApiBody(first.from_api_body || "");
|
||
setFromApiKey(first.from_api_key || "");
|
||
}
|
||
|
||
// TO: DB 테이블 자동 선택
|
||
let toConn: BatchConnectionInfo | null = null;
|
||
if (toType === "internal") {
|
||
toConn = connections.find((c) => c.type === "internal") || null;
|
||
} else if (first.to_connection_id) {
|
||
toConn = connections.find((c) => c.id === first.to_connection_id) || null;
|
||
}
|
||
|
||
if (toConn && first.to_table_name) {
|
||
// handleToConnectionChange + handleToTableChange 의 흐름을 직접 트리거
|
||
setToConnection(toConn);
|
||
try {
|
||
const connectionType = toConn.type === "internal" ? "internal" : "external";
|
||
const tablesResult = await BatchManagementAPI.getTablesFromConnection(connectionType, toConn.id);
|
||
const tableNames = Array.isArray(tablesResult)
|
||
? tablesResult.map((t: any) => (typeof t === "string" ? t : t.table_name || String(t)))
|
||
: [];
|
||
if (!cancelled) setToTables(tableNames);
|
||
setToTable(first.to_table_name);
|
||
const cols = await BatchManagementAPI.getTableColumns(connectionType, first.to_table_name, toConn.id);
|
||
if (!cancelled && cols && cols.length > 0) setToColumns(cols);
|
||
} catch (e) {
|
||
console.error("[edit] TO 테이블/컬럼 로드 실패", e);
|
||
}
|
||
}
|
||
|
||
// 매핑 리스트 복원 — direct/fixed/conditional 모두 처리
|
||
const restoredMappings: MappingItem[] = mappings.map((m: any, idx: number) => {
|
||
const mt: string = m.mapping_type || "direct";
|
||
const sourceType: MappingItem["sourceType"] =
|
||
mt === "fixed" ? "fixed" : mt === "conditional" ? "conditional" : "api";
|
||
let conditionalConfig: ConditionalConfig | undefined;
|
||
if (sourceType === "conditional" && m.mapping_config) {
|
||
try {
|
||
const parsed = JSON.parse(m.mapping_config);
|
||
conditionalConfig = {
|
||
rules: Array.isArray(parsed.rules) ? parsed.rules : [],
|
||
default: parsed.default ?? "",
|
||
};
|
||
} catch {
|
||
conditionalConfig = { rules: [], default: "" };
|
||
}
|
||
}
|
||
return {
|
||
id: `edit-${idx}-${Date.now()}`,
|
||
dbColumn: m.to_column_name || "",
|
||
sourceType,
|
||
apiField:
|
||
sourceType === "fixed" ? "" : m.from_column_name || "",
|
||
fixedValue: sourceType === "fixed" ? m.from_column_name || "" : "",
|
||
conditionalConfig,
|
||
};
|
||
});
|
||
if (!cancelled) setMappingList(restoredMappings);
|
||
|
||
// 등록된 연결이 있으면 자동으로 API 호출 → fromApiFields 채워서 매핑 드롭다운에 필드 노출
|
||
if (first.from_connection_id) {
|
||
try {
|
||
const result = await ExternalRestApiConnectionAPI.testConnectionById(
|
||
first.from_connection_id,
|
||
first.from_table_name || undefined,
|
||
);
|
||
if (!cancelled && result?.success) {
|
||
setRawResponse(result.response_data);
|
||
const findArr = (o: any, depth = 0): any[] | null => {
|
||
if (Array.isArray(o)) return o;
|
||
if (depth >= 4 || typeof o !== "object" || o === null) return null;
|
||
for (const v of Object.values(o)) {
|
||
const a = findArr(v, depth + 1);
|
||
if (a) return a;
|
||
}
|
||
return null;
|
||
};
|
||
const arr = findArr(result.response_data);
|
||
if (arr && arr.length > 0 && typeof arr[0] === "object" && arr[0] !== null) {
|
||
setFromApiFields(Object.keys(arr[0]));
|
||
setFromApiData(arr);
|
||
} else {
|
||
// 응답이 배열이 아니어도 — 저장된 매핑의 from_column_name 들로 fromApiFields 를 채워서 드롭다운에 보이게 함
|
||
const savedFields = Array.from(
|
||
new Set(
|
||
mappings
|
||
.filter((m: any) => m.mapping_type !== "fixed" && m.from_column_name)
|
||
.map((m: any) => m.from_column_name as string),
|
||
),
|
||
);
|
||
if (savedFields.length > 0) setFromApiFields(savedFields);
|
||
}
|
||
} else {
|
||
// 호출 실패해도 저장된 필드들로 fromApiFields 를 채워 매핑 드롭다운에 표시
|
||
const savedFields = Array.from(
|
||
new Set(
|
||
mappings
|
||
.filter((m: any) => m.mapping_type !== "fixed" && m.from_column_name)
|
||
.map((m: any) => m.from_column_name as string),
|
||
),
|
||
);
|
||
if (savedFields.length > 0) setFromApiFields(savedFields);
|
||
}
|
||
} catch (e) {
|
||
console.error("[edit] API 자동 호출 실패", e);
|
||
const savedFields = Array.from(
|
||
new Set(
|
||
mappings
|
||
.filter((m: any) => m.mapping_type !== "fixed" && m.from_column_name)
|
||
.map((m: any) => m.from_column_name as string),
|
||
),
|
||
);
|
||
if (savedFields.length > 0) setFromApiFields(savedFields);
|
||
}
|
||
} else {
|
||
// 등록된 연결 없는 경우 — 저장된 from_column_name 들을 그대로 노출
|
||
const savedFields = Array.from(
|
||
new Set(
|
||
mappings
|
||
.filter((m: any) => m.mapping_type !== "fixed" && m.from_column_name)
|
||
.map((m: any) => m.from_column_name as string),
|
||
),
|
||
);
|
||
if (savedFields.length > 0) setFromApiFields(savedFields);
|
||
}
|
||
|
||
toast.success(`'${cfg.batch_name}' 편집 모드로 불러왔어요. 필요한 부분만 수정하세요.`);
|
||
} else {
|
||
// DB → REST API 등 다른 타입은 기존 편집 페이지로 라우팅
|
||
toast.info("이 배치 타입은 기본 편집 화면에서 열어주세요.");
|
||
}
|
||
} catch (e: any) {
|
||
console.error("[edit] 배치 로드 실패", e);
|
||
toast.error("배치 정보를 불러올 수 없어요.");
|
||
} finally {
|
||
if (!cancelled) setEditLoading(false);
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isEditMode, editId, connections.length, registeredRestApis.length]);
|
||
|
||
// TO 연결 변경 핸들러
|
||
const handleToConnectionChange = async (connectionValue: string) => {
|
||
let connection: BatchConnectionInfo | null = null;
|
||
|
||
if (connectionValue === "internal") {
|
||
// 내부 데이터베이스 선택
|
||
connection = connections.find((conn) => conn.type === "internal") || null;
|
||
} else {
|
||
// 외부 데이터베이스 선택
|
||
const connectionId = parseInt(connectionValue);
|
||
connection = connections.find((conn) => conn.id === connectionId) || null;
|
||
}
|
||
|
||
setToConnection(connection);
|
||
setToTable("");
|
||
setToColumns([]);
|
||
|
||
if (connection) {
|
||
try {
|
||
const connectionType = connection.type === "internal" ? "internal" : "external";
|
||
const result = await BatchManagementAPI.getTablesFromConnection(connectionType, connection.id);
|
||
const tableNames = Array.isArray(result)
|
||
? result.map((table: any) => (typeof table === "string" ? table : table.table_name || String(table)))
|
||
: [];
|
||
setToTables(tableNames);
|
||
} catch (error) {
|
||
console.error("테이블 목록 로드 오류:", error);
|
||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||
}
|
||
}
|
||
};
|
||
|
||
// TO 테이블 변경 핸들러
|
||
const handleToTableChange = async (tableName: string) => {
|
||
setToTable(tableName);
|
||
setToColumns([]);
|
||
|
||
if (toConnection && tableName) {
|
||
try {
|
||
const connectionType = toConnection.type === "internal" ? "internal" : "external";
|
||
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, toConnection.id);
|
||
if (result && result.length > 0) {
|
||
setToColumns(result);
|
||
} else {
|
||
setToColumns([]);
|
||
}
|
||
} catch (error) {
|
||
console.error("❌ 컬럼 목록 로드 오류:", error);
|
||
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
||
setToColumns([]);
|
||
}
|
||
}
|
||
};
|
||
|
||
// FROM 연결 변경 핸들러 (DB → REST API용)
|
||
const handleFromConnectionChange = async (connectionValue: string) => {
|
||
let connection: BatchConnectionInfo | null = null;
|
||
if (connectionValue === "internal") {
|
||
connection = connections.find((conn) => conn.type === "internal") || null;
|
||
} else {
|
||
const connectionId = parseInt(connectionValue);
|
||
connection = connections.find((conn) => conn.id === connectionId) || null;
|
||
}
|
||
setFromConnection(connection);
|
||
setFromTable("");
|
||
setFromColumns([]);
|
||
|
||
if (connection) {
|
||
try {
|
||
const connectionType = connection.type === "internal" ? "internal" : "external";
|
||
const result = await BatchManagementAPI.getTablesFromConnection(connectionType, connection.id);
|
||
const tableNames = Array.isArray(result)
|
||
? result.map((table: any) => (typeof table === "string" ? table : table.table_name || String(table)))
|
||
: [];
|
||
setFromTables(tableNames);
|
||
} catch (error) {
|
||
console.error("테이블 목록 로드 오류:", error);
|
||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||
}
|
||
}
|
||
};
|
||
|
||
// FROM 테이블 변경 핸들러 (DB → REST API용)
|
||
const handleFromTableChange = async (tableName: string) => {
|
||
setFromTable(tableName);
|
||
setFromColumns([]);
|
||
setSelectedColumns([]); // 선택된 컬럼도 초기화
|
||
setDbToApiFieldMapping({}); // 매핑도 초기화
|
||
|
||
if (fromConnection && tableName) {
|
||
try {
|
||
const connectionType = fromConnection.type === "internal" ? "internal" : "external";
|
||
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, fromConnection.id);
|
||
if (result && result.length > 0) {
|
||
setFromColumns(result);
|
||
} else {
|
||
setFromColumns([]);
|
||
}
|
||
} catch (error) {
|
||
console.error("❌ FROM 컬럼 목록 로드 오류:", error);
|
||
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
||
setFromColumns([]);
|
||
}
|
||
}
|
||
};
|
||
|
||
// TO API 미리보기 (DB → REST API용)
|
||
const previewToApiData = async () => {
|
||
if (!toApiUrl || !toApiKey || !toEndpoint) {
|
||
toast.error("API URL, API Key, 엔드포인트를 모두 입력해주세요.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const result = await BatchManagementAPI.previewRestApiData(
|
||
toApiUrl,
|
||
toApiKey,
|
||
toEndpoint,
|
||
"GET", // 미리보기는 항상 GET으로
|
||
);
|
||
|
||
if (result.fields && result.fields.length > 0) {
|
||
setToApiFields(result.fields);
|
||
toast.success(`TO API 필드 ${result.fields.length}개를 조회했습니다.`);
|
||
} else {
|
||
setToApiFields([]);
|
||
toast.warning("TO API에서 필드를 찾을 수 없습니다.");
|
||
}
|
||
} catch (error) {
|
||
console.error("❌ TO API 미리보기 오류:", error);
|
||
toast.error("TO API 미리보기에 실패했습니다.");
|
||
setToApiFields([]);
|
||
}
|
||
};
|
||
|
||
// REST API 데이터 미리보기
|
||
const previewRestApiData = async () => {
|
||
// 등록된 REST API 연결이 선택된 경우엔 인증/URL/엔드포인트 검증을 백엔드가 처리하므로 스킵
|
||
const useRegisteredConn = selectedRestApiId !== "manual";
|
||
|
||
if (!useRegisteredConn) {
|
||
// 직접 입력 모드 — URL/엔드포인트/토큰 검증
|
||
if (!fromApiUrl || !fromEndpoint) {
|
||
toast.error("API URL과 엔드포인트를 모두 입력해주세요.");
|
||
return;
|
||
}
|
||
if (authTokenMode === "direct" && !fromApiKey) {
|
||
toast.error("인증 토큰을 입력해주세요.");
|
||
return;
|
||
}
|
||
if (authTokenMode === "db" && !authServiceName) {
|
||
toast.error("인증 토큰 서비스를 선택해주세요.");
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const result = await BatchManagementAPI.previewRestApiData(
|
||
fromApiUrl,
|
||
authTokenMode === "direct" ? fromApiKey : "", // 직접 입력일 때만 API 키 전달
|
||
fromEndpoint,
|
||
fromApiMethod,
|
||
// 파라미터 정보 추가
|
||
apiParamType !== "none"
|
||
? {
|
||
paramType: apiParamType,
|
||
paramName: apiParamName,
|
||
paramValue: apiParamValue,
|
||
paramSource: apiParamSource,
|
||
}
|
||
: undefined,
|
||
// Request Body 추가 (POST/PUT/DELETE)
|
||
fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE" ? fromApiBody : undefined,
|
||
// DB 선택 모드일 때 서비스명 전달
|
||
authTokenMode === "db" ? authServiceName : undefined,
|
||
// 데이터 배열 경로 전달
|
||
dataArrayPath || undefined,
|
||
// 등록된 연결 ID (Wehago/Amaranth 인증 자동 처리)
|
||
selectedRestApiId !== "manual" ? Number(selectedRestApiId) : undefined,
|
||
);
|
||
|
||
if (result.fields && result.fields.length > 0) {
|
||
setFromApiFields(result.fields);
|
||
setFromApiData(result.samples);
|
||
toast.success(`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.totalCount}개 레코드`);
|
||
} else if (result.samples && result.samples.length > 0) {
|
||
// 백엔드에서 fields를 제대로 보내지 않은 경우, 프론트엔드에서 직접 추출
|
||
const extractedFields = Object.keys(result.samples[0]);
|
||
setFromApiFields(extractedFields);
|
||
setFromApiData(result.samples);
|
||
|
||
toast.success(`API 데이터 미리보기 완료! ${extractedFields.length}개 필드, ${result.samples.length}개 레코드`);
|
||
} else {
|
||
setFromApiFields([]);
|
||
setFromApiData([]);
|
||
toast.warning("API에서 데이터를 가져올 수 없습니다.");
|
||
}
|
||
} catch (error) {
|
||
console.error("REST API 미리보기 오류:", error);
|
||
toast.error("API 데이터 미리보기에 실패했습니다.");
|
||
setFromApiFields([]);
|
||
setFromApiData([]);
|
||
}
|
||
};
|
||
|
||
// 배치 설정 저장
|
||
const handleSave = async () => {
|
||
if (!batchName.trim()) {
|
||
toast.error("배치명을 입력해주세요.");
|
||
return;
|
||
}
|
||
|
||
// 배치 타입별 검증 및 저장
|
||
if (batchType === "restapi-to-db") {
|
||
// 유효한 매핑만 필터링
|
||
// api: dbColumn + apiField 둘 다 필요
|
||
// fixed: dbColumn + fixedValue 둘 다 필요
|
||
// conditional: dbColumn + apiField (평가할 API 필드) + 최소 1개 룰 또는 default 필요
|
||
const isValid = (m: MappingItem) => {
|
||
if (!m.dbColumn) return false;
|
||
if (m.sourceType === "api") return !!m.apiField;
|
||
if (m.sourceType === "fixed") return m.fixedValue !== undefined && m.fixedValue !== null;
|
||
if (m.sourceType === "conditional") {
|
||
if (!m.apiField) return false;
|
||
const c = m.conditionalConfig;
|
||
return !!c && ((c.rules && c.rules.length > 0) || c.default !== undefined);
|
||
}
|
||
return false;
|
||
};
|
||
const validMappings = mappingList.filter(isValid);
|
||
const droppedMappings = mappingList.filter((m) => m.dbColumn && !isValid(m));
|
||
|
||
// ⚠ 누락된 매핑이 있으면 사용자가 알아차릴 수 있게 명시적으로 차단 + 어떤 컬럼/이유인지 표시
|
||
// (이전엔 silent drop 되어 저장된 줄 알았는데 일부 매핑이 누락되는 문제 발생)
|
||
if (droppedMappings.length > 0) {
|
||
const reasons = droppedMappings.map((m) => {
|
||
if (m.sourceType === "conditional" && !m.apiField) return `${m.dbColumn}: 평가 필드 미선택`;
|
||
if (m.sourceType === "conditional") return `${m.dbColumn}: 규칙 또는 기본값 필요`;
|
||
if (m.sourceType === "api") return `${m.dbColumn}: API 필드 미선택`;
|
||
return `${m.dbColumn}: 값 누락`;
|
||
}).join(", ");
|
||
toast.error(`매핑 입력 누락 — ${reasons}`);
|
||
return;
|
||
}
|
||
|
||
if (validMappings.length === 0) {
|
||
toast.error("최소 하나의 매핑을 설정해주세요.");
|
||
return;
|
||
}
|
||
|
||
// UPSERT 모드일 때 conflict key 검증
|
||
if (saveMode === "UPSERT" && !conflictKey) {
|
||
toast.error("UPSERT 모드에서는 충돌 기준 컬럼을 선택해주세요.");
|
||
return;
|
||
}
|
||
|
||
// 통합 매핑 리스트를 배치 매핑 형태로 변환
|
||
// 고정값 매핑도 동일한 from_connection_type을 사용해야 같은 그룹으로 처리됨
|
||
const apiMappings = validMappings.map((mapping) => {
|
||
// from_column_name: api/conditional 은 평가할 API 필드, fixed 는 고정값
|
||
let fromColumnName: string;
|
||
if (mapping.sourceType === "fixed") fromColumnName = mapping.fixedValue;
|
||
else fromColumnName = mapping.apiField; // api & conditional 모두 apiField 사용
|
||
const mappingType =
|
||
mapping.sourceType === "fixed"
|
||
? ("fixed" as const)
|
||
: mapping.sourceType === "conditional"
|
||
? ("conditional" as const)
|
||
: ("direct" as const);
|
||
return {
|
||
from_connection_type: "restapi" as const,
|
||
from_table_name: fromEndpoint,
|
||
from_column_name: fromColumnName,
|
||
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: mappingType,
|
||
fixed_value: mapping.sourceType === "fixed" ? mapping.fixedValue : undefined,
|
||
// 조건부 룰을 mapping_config(JSON 문자열)에 저장
|
||
mapping_config:
|
||
mapping.sourceType === "conditional" && mapping.conditionalConfig
|
||
? JSON.stringify(mapping.conditionalConfig)
|
||
: undefined,
|
||
};
|
||
});
|
||
|
||
// 실제 API 호출
|
||
try {
|
||
if (isEditMode && editId) {
|
||
// 편집 모드 — 기존 배치 업데이트
|
||
await BatchAPI.updateBatchConfig(editId, {
|
||
batchName,
|
||
description,
|
||
cronSchedule,
|
||
isActive: "Y",
|
||
saveMode,
|
||
conflictKey: saveMode === "UPSERT" ? conflictKey : undefined,
|
||
authServiceName: authTokenMode === "db" ? authServiceName : undefined,
|
||
dataArrayPath: dataArrayPath || undefined,
|
||
rowFilterConfig: rowFilterRules.length > 0
|
||
? { exclude: rowFilterRules.filter(r => r.column && r.value !== "") }
|
||
: null,
|
||
mappings: apiMappings,
|
||
} as any);
|
||
toast.success("배치가 수정되었습니다.");
|
||
setTimeout(() => {
|
||
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||
}, 800);
|
||
} else {
|
||
const result = await BatchManagementAPI.saveRestApiBatch({
|
||
batchName,
|
||
batchType,
|
||
cronSchedule,
|
||
description,
|
||
apiMappings,
|
||
authServiceName: authTokenMode === "db" ? authServiceName : undefined,
|
||
dataArrayPath: dataArrayPath || undefined,
|
||
saveMode,
|
||
conflictKey: saveMode === "UPSERT" ? conflictKey : undefined,
|
||
rowFilterConfig: rowFilterRules.length > 0
|
||
? { exclude: rowFilterRules.filter(r => r.column && r.value !== "") }
|
||
: null,
|
||
} as any);
|
||
|
||
if (result.success) {
|
||
toast.success(result.message || "REST API 배치 설정이 저장되었습니다.");
|
||
setTimeout(() => {
|
||
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||
}, 1000);
|
||
} else {
|
||
toast.error(result.message || "배치 저장에 실패했습니다.");
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("배치 저장 오류:", error);
|
||
showErrorToast("배치 설정 저장에 실패했습니다", error, {
|
||
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
|
||
});
|
||
}
|
||
return;
|
||
} else if (batchType === "db-to-restapi") {
|
||
// DB → REST API 배치 검증
|
||
if (!fromConnection || !fromTable || selectedColumns.length === 0) {
|
||
toast.error("소스 데이터베이스, 테이블, 컬럼을 선택해주세요.");
|
||
return;
|
||
}
|
||
|
||
if (!toApiUrl || !toApiKey || !toEndpoint) {
|
||
toast.error("대상 API URL, API Key, 엔드포인트를 입력해주세요.");
|
||
return;
|
||
}
|
||
|
||
if ((toApiMethod === "POST" || toApiMethod === "PUT") && !toApiBody) {
|
||
toast.error("POST/PUT 메서드의 경우 Request Body 템플릿을 입력해주세요.");
|
||
return;
|
||
}
|
||
|
||
// DELETE의 경우 빈 Request Body라도 템플릿 로직을 위해 "{}" 설정
|
||
let finalToApiBody = toApiBody;
|
||
if (toApiMethod === "DELETE" && !finalToApiBody.trim()) {
|
||
finalToApiBody = "{}";
|
||
}
|
||
|
||
// DB → REST API 매핑 생성 (선택된 컬럼만)
|
||
const selectedColumnObjects = fromColumns.filter((column) => selectedColumns.includes(column.column_name));
|
||
const dbMappings = selectedColumnObjects.map((column, index) => ({
|
||
from_connection_type: fromConnection.type === "internal" ? "internal" : "external",
|
||
from_connection_id: fromConnection.type === "internal" ? undefined : fromConnection.id,
|
||
from_table_name: fromTable,
|
||
from_column_name: column.column_name,
|
||
from_column_type: column.data_type,
|
||
to_connection_type: "restapi" as const,
|
||
to_table_name: toEndpoint, // API 엔드포인트
|
||
to_column_name: dbToApiFieldMapping[column.column_name] || column.column_name, // 매핑된 API 필드명
|
||
to_api_url: toApiUrl,
|
||
to_api_key: toApiKey,
|
||
to_api_method: toApiMethod,
|
||
to_api_body: finalToApiBody, // Request Body 템플릿
|
||
mapping_type: "template" as const,
|
||
mapping_order: index + 1,
|
||
}));
|
||
|
||
// URL 경로 파라미터 매핑 추가 (PUT/DELETE용)
|
||
if ((toApiMethod === "PUT" || toApiMethod === "DELETE") && urlPathColumn) {
|
||
const urlPathColumnObject = fromColumns.find((col) => col.column_name === urlPathColumn);
|
||
if (urlPathColumnObject) {
|
||
dbMappings.push({
|
||
from_connection_type: fromConnection.type === "internal" ? "internal" : "external",
|
||
from_connection_id: fromConnection.type === "internal" ? undefined : fromConnection.id,
|
||
from_table_name: fromTable,
|
||
from_column_name: urlPathColumn,
|
||
from_column_type: urlPathColumnObject.data_type,
|
||
to_connection_type: "restapi" as const,
|
||
to_table_name: toEndpoint,
|
||
to_column_name: "URL_PATH_PARAM", // 특별한 식별자
|
||
to_api_url: toApiUrl,
|
||
to_api_key: toApiKey,
|
||
to_api_method: toApiMethod,
|
||
to_api_body: finalToApiBody,
|
||
mapping_type: "url_path" as const,
|
||
mapping_order: 999, // 마지막 순서
|
||
});
|
||
}
|
||
}
|
||
|
||
// 실제 API 호출 (기존 saveRestApiBatch 재사용)
|
||
try {
|
||
const result = await BatchManagementAPI.saveRestApiBatch({
|
||
batchName,
|
||
batchType,
|
||
cronSchedule,
|
||
description,
|
||
apiMappings: dbMappings,
|
||
authServiceName: authServiceName || undefined,
|
||
});
|
||
|
||
if (result.success) {
|
||
toast.success(result.message || "DB → REST API 배치 설정이 저장되었습니다.");
|
||
setTimeout(() => {
|
||
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||
}, 1000);
|
||
} else {
|
||
toast.error(result.message || "배치 저장에 실패했습니다.");
|
||
}
|
||
} catch (error) {
|
||
console.error("배치 저장 오류:", error);
|
||
showErrorToast("배치 설정 저장에 실패했습니다", error, {
|
||
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
toast.error("지원하지 않는 배치 타입입니다.");
|
||
};
|
||
|
||
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||
|
||
return (
|
||
<div className="bg-background h-full overflow-y-auto">
|
||
<div className="mx-auto w-full max-w-[1920px] space-y-3 px-3 pb-20 pt-2 sm:px-4 2xl:px-5">
|
||
{/* 헤더 — 더 컴팩트 */}
|
||
<div className="sticky top-0 z-10 -mx-3 -mt-2 flex items-center justify-between border-b bg-background/95 px-3 py-1.5 backdrop-blur sm:-mx-4 sm:px-4 2xl:-mx-5 2xl:px-5">
|
||
<div className="flex items-center gap-1.5">
|
||
<button onClick={goBack} className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
||
<ArrowLeft className="h-3.5 w-3.5" />
|
||
</button>
|
||
<div className="flex items-baseline gap-2">
|
||
<h1 className="text-sm font-semibold">
|
||
{isEditMode ? `배치 편집 #${editId}` : "고급 배치 생성"}
|
||
</h1>
|
||
<p className="text-[11px] text-muted-foreground">REST API / DB 간 데이터 동기화</p>
|
||
{editLoading && (
|
||
<span className="text-[11px] text-muted-foreground">(불러오는 중...)</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<Button onClick={goBack} variant="outline" size="sm" className="h-7 gap-1 px-2 text-xs">취소</Button>
|
||
<Button onClick={handleSave} size="sm" className="h-7 gap-1 px-2 text-xs">
|
||
<Save className="h-3 w-3" />
|
||
{isEditMode ? "수정" : "저장"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 배치 타입 + 기본 정보를 한 줄로 (FullHD에서 가로 활용) */}
|
||
<div className="grid grid-cols-1 gap-2 2xl:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]">
|
||
{/* 배치 타입 선택 — 컴팩트 가로 칩 */}
|
||
<div className="flex gap-2">
|
||
{batchTypeOptions.map((option) => (
|
||
<button
|
||
key={option.value}
|
||
onClick={() => setBatchType(option.value)}
|
||
className={`flex flex-1 items-center gap-2 rounded-md border px-2.5 py-2 text-left transition-all ${
|
||
batchType === option.value
|
||
? "border-primary bg-primary/5 ring-1 ring-primary/30"
|
||
: "border-border hover:border-muted-foreground/30 hover:bg-muted/50"
|
||
}`}
|
||
>
|
||
<div className={`flex h-7 w-7 shrink-0 items-center justify-center rounded ${batchType === option.value ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
|
||
{option.value === "restapi-to-db" ? <Globe className="h-3.5 w-3.5" /> : <Database className="h-3.5 w-3.5" />}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="text-xs font-medium leading-tight">{option.label}</div>
|
||
<div className="text-[10px] text-muted-foreground leading-tight">{option.description}</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* 기본 정보 */}
|
||
<div className="rounded-md border p-2.5">
|
||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_180px_1fr]">
|
||
<div className="space-y-1">
|
||
<Label htmlFor="batchName" className="text-[11px]">배치명 <span className="text-destructive">*</span></Label>
|
||
<Input id="batchName" value={batchName} onChange={e => setBatchName(e.target.value)} placeholder="배치명" className="h-7 text-xs" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label htmlFor="cronSchedule" className="text-[11px]">실행 스케줄 <span className="text-destructive">*</span></Label>
|
||
<Input id="cronSchedule" value={cronSchedule} onChange={e => setCronSchedule(e.target.value)} placeholder="0 12 * * *" className="h-7 font-mono text-xs" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label htmlFor="description" className="text-[11px]">설명</Label>
|
||
<Input id="description" value={description} onChange={e => setDescription(e.target.value)} placeholder="설명 (선택)" className="h-7 text-xs" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* FROM/TO 설정 - 가로 배치 (gap 축소) */}
|
||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||
{/* FROM 설정 */}
|
||
<Card>
|
||
<CardHeader className="px-3 pb-2 pt-3">
|
||
<CardTitle className="flex items-center text-sm">
|
||
{batchType === "restapi-to-db" ? (
|
||
<>
|
||
<Globe className="mr-1.5 h-4 w-4" />
|
||
FROM: REST API (소스)
|
||
</>
|
||
) : (
|
||
<>
|
||
<Database className="mr-1.5 h-4 w-4" />
|
||
FROM: 데이터베이스 (소스)
|
||
</>
|
||
)}
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2 px-3 pb-3 pt-0">
|
||
{/* REST API 설정 (REST API → DB) — 컴팩트 */}
|
||
{batchType === "restapi-to-db" && (
|
||
<div className="space-y-2">
|
||
{/* 등록된 REST API 연결에서 선택 — 헤더+Select 한 줄 */}
|
||
<div className="rounded-md border bg-muted/30 p-2">
|
||
<div className="grid grid-cols-[auto_1fr] items-center gap-2">
|
||
<Label className="flex items-center gap-1 text-[11px] font-medium whitespace-nowrap">
|
||
<LinkIcon className="h-3 w-3" />
|
||
등록된 연결
|
||
</Label>
|
||
<Select value={selectedRestApiId} onValueChange={applyRegisteredRestApi}>
|
||
<SelectTrigger className="h-7 text-xs">
|
||
<SelectValue placeholder="등록된 연결 선택 (또는 직접 입력)" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="manual">직접 입력 (등록된 연결 사용 안 함)</SelectItem>
|
||
{registeredRestApis.map((conn) => (
|
||
<SelectItem key={conn.id} value={String(conn.id)}>
|
||
{conn.connection_name}
|
||
{conn.auth_type === "wehago" && (
|
||
<span className="ml-2 text-[10px] text-muted-foreground">[Wehago]</span>
|
||
)}
|
||
{conn.auth_type === "bearer" && (
|
||
<span className="ml-2 text-[10px] text-muted-foreground">[Bearer]</span>
|
||
)}
|
||
{conn.auth_type === "api-key" && (
|
||
<span className="ml-2 text-[10px] text-muted-foreground">[Key]</span>
|
||
)}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 등록된 연결 선택 시 한 줄 요약 — Method · URL · Auth */}
|
||
{selectedRestApiId !== "manual" && (() => {
|
||
const conn = registeredRestApis.find((c) => String(c.id) === selectedRestApiId);
|
||
if (!conn) return null;
|
||
const authLabel =
|
||
conn.auth_type === "wehago" ? "HMAC-SHA256 자동 서명"
|
||
: conn.auth_type === "bearer" ? "Bearer (저장)"
|
||
: conn.auth_type === "api-key" ? "API Key (저장)"
|
||
: conn.auth_type === "none" ? "없음" : conn.auth_type;
|
||
return (
|
||
<div className="rounded-md border bg-muted/30 px-2 py-1.5 text-[11px]">
|
||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||
<span className="rounded bg-primary/10 px-1 py-0.5 text-[10px] font-semibold text-primary">
|
||
{conn.auth_type === "wehago" ? "Wehago" : conn.auth_type.toUpperCase()}
|
||
</span>
|
||
<span className="font-mono text-[10px] text-muted-foreground">{conn.default_method || "POST"}</span>
|
||
<span className="font-mono break-all">{conn.base_url}{conn.endpoint_path || ""}</span>
|
||
<span className="ml-auto text-[10px] text-muted-foreground">인증: {authLabel}</span>
|
||
</div>
|
||
{conn.default_body && (
|
||
<details className="mt-1">
|
||
<summary className="cursor-pointer text-[10px] text-primary hover:underline">Request Body</summary>
|
||
<pre className="mt-1 max-h-[120px] overflow-auto rounded bg-background p-2 text-[10px]">{conn.default_body}</pre>
|
||
</details>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* 직접 입력 모드일 때만 노출되는 수동 입력 영역 — 컴팩트 그리드 */}
|
||
{selectedRestApiId === "manual" && (
|
||
<div className="space-y-2">
|
||
{/* URL + 메서드 한 줄 */}
|
||
<div className="grid grid-cols-[1fr_120px] gap-2">
|
||
<div className="space-y-1">
|
||
<Label htmlFor="fromApiUrl" className="text-[11px]">API 서버 URL *</Label>
|
||
<Input id="fromApiUrl" value={fromApiUrl} onChange={(e) => setFromApiUrl(e.target.value)} placeholder="https://api.example.com" className="h-7 text-xs" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-[11px]">메서드</Label>
|
||
<Select value={fromApiMethod} onValueChange={(value: any) => setFromApiMethod(value)}>
|
||
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="GET">GET</SelectItem>
|
||
<SelectItem value="POST">POST</SelectItem>
|
||
<SelectItem value="PUT">PUT</SelectItem>
|
||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 엔드포인트 + 데이터 배열 경로 한 줄 */}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="space-y-1">
|
||
<Label htmlFor="fromEndpoint" className="text-[11px]">엔드포인트 *</Label>
|
||
<Input id="fromEndpoint" value={fromEndpoint} onChange={(e) => setFromEndpoint(e.target.value)} placeholder="/api/users" className="h-7 text-xs" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label htmlFor="dataArrayPath" className="text-[11px]">데이터 배열 경로</Label>
|
||
<Input id="dataArrayPath" value={dataArrayPath} onChange={(e) => setDataArrayPath(e.target.value)} placeholder="resultData (비우면 자동)" className="h-7 text-xs" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* 인증 토큰 — 라디오 + 입력을 한 줄로 */}
|
||
<div className="space-y-1">
|
||
<Label className="text-[11px]">인증 토큰</Label>
|
||
<div className="grid grid-cols-[auto_1fr] items-center gap-2">
|
||
<div className="flex gap-1 rounded-md border p-0.5">
|
||
<button type="button" onClick={() => { setAuthTokenMode("direct"); setAuthServiceName(""); }} className={`h-6 rounded px-2 text-[11px] ${authTokenMode === "direct" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"}`}>직접 입력</button>
|
||
<button type="button" onClick={() => setAuthTokenMode("db")} className={`h-6 rounded px-2 text-[11px] ${authTokenMode === "db" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"}`}>DB에서 선택</button>
|
||
</div>
|
||
{authTokenMode === "direct" ? (
|
||
<Input id="fromApiKey" value={fromApiKey} onChange={(e) => setFromApiKey(e.target.value)} placeholder="Bearer eyJhbGciOiJIUzI1NiIs..." className="h-7 text-xs" />
|
||
) : (
|
||
<Select value={authServiceName || "none"} onValueChange={(v) => setAuthServiceName(v === "none" ? "" : v)}>
|
||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="서비스명" /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">선택 안 함</SelectItem>
|
||
{authServiceNames.map((name) => (<SelectItem key={name} value={name}>{name}</SelectItem>))}
|
||
</SelectContent>
|
||
</Select>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Request Body (POST/PUT/DELETE용) — 줄어든 높이 */}
|
||
{(fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE") && (
|
||
<div className="space-y-1">
|
||
<Label htmlFor="fromApiBody" className="text-[11px]">Request Body (JSON)</Label>
|
||
<Textarea id="fromApiBody" value={fromApiBody} onChange={(e) => setFromApiBody(e.target.value)} placeholder='{"username": "myuser"}' className="min-h-[60px] text-xs" rows={3} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 등록된 연결 모드에서도 dataArrayPath 는 노출 — 응답 배열 위치 다를 수 있음 */}
|
||
{selectedRestApiId !== "manual" && (
|
||
<div className="grid grid-cols-[auto_1fr] items-center gap-2">
|
||
<Label htmlFor="dataArrayPath2" className="text-[11px] whitespace-nowrap">데이터 배열 경로</Label>
|
||
<Input
|
||
id="dataArrayPath2"
|
||
value={dataArrayPath}
|
||
onChange={(e) => setDataArrayPath(e.target.value)}
|
||
placeholder="resultData (비우면 자동 탐색)"
|
||
className="h-7 text-xs"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 빠른 응답 테스트 — 컴팩트 */}
|
||
<div className="rounded-md border bg-muted/20 p-2">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<Label className="text-[11px] font-medium">응답 미리보기 (Quick Test)</Label>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={runQuickResponseTest}
|
||
disabled={rawResponseLoading || !fromApiUrl || !fromEndpoint}
|
||
className="h-6 gap-1 px-2 text-[11px]"
|
||
>
|
||
<Eye className="h-3 w-3" />
|
||
{rawResponseLoading ? "호출 중..." : "응답 확인"}
|
||
</Button>
|
||
</div>
|
||
|
||
{rawResponseError && (
|
||
<div className="mt-1.5 rounded border border-destructive/30 bg-destructive/10 p-1.5 text-[11px] text-destructive">
|
||
{rawResponseError}
|
||
</div>
|
||
)}
|
||
|
||
{rawResponse !== null && rawResponse !== undefined && (() => {
|
||
// 응답에서 배열을 자동 탐색 (최대 4단계)
|
||
const findArray = (o: any, depth = 0): any[] | null => {
|
||
if (Array.isArray(o)) return o;
|
||
if (depth >= 4 || typeof o !== "object" || o === null) return null;
|
||
for (const v of Object.values(o)) {
|
||
const arr = findArray(v, depth + 1);
|
||
if (arr) return arr;
|
||
}
|
||
return null;
|
||
};
|
||
const arr = findArray(rawResponse);
|
||
const fields =
|
||
arr && arr.length > 0 && typeof arr[0] === "object" && arr[0] !== null
|
||
? Object.keys(arr[0])
|
||
: rawResponse && typeof rawResponse === "object" && !Array.isArray(rawResponse)
|
||
? Object.keys(rawResponse)
|
||
: [];
|
||
|
||
return (
|
||
<div className="mt-2 space-y-1.5">
|
||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||
<span>
|
||
{arr ? (
|
||
<>
|
||
배열 <span className="font-semibold text-foreground">{arr.length}</span>건 ·
|
||
</>
|
||
) : null}
|
||
필드 <span className="font-semibold text-foreground">{fields.length}</span>개
|
||
</span>
|
||
</div>
|
||
|
||
{fields.length > 0 && (
|
||
<details className="rounded border bg-background">
|
||
<summary className="cursor-pointer px-2 py-1 text-[11px] font-medium text-muted-foreground hover:bg-muted/50">
|
||
매핑 가능 필드 보기 ({fields.length})
|
||
</summary>
|
||
<div className="max-h-[120px] overflow-auto p-2">
|
||
<div className="flex flex-wrap gap-1">
|
||
{fields.map((f) => (
|
||
<span key={f} className="rounded border bg-muted/30 px-1.5 py-0.5 font-mono text-[10px]">{f}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</details>
|
||
)}
|
||
|
||
<details className="rounded border bg-background">
|
||
<summary className="cursor-pointer px-2 py-1 text-[11px] font-medium text-muted-foreground hover:bg-muted/50">
|
||
응답 본문 (JSON) 보기
|
||
</summary>
|
||
<pre className="max-h-[180px] overflow-auto p-2 text-[10px] leading-relaxed font-mono">
|
||
{(() => {
|
||
try {
|
||
return JSON.stringify(rawResponse, null, 2);
|
||
} catch {
|
||
return String(rawResponse);
|
||
}
|
||
})()}
|
||
</pre>
|
||
</details>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{/* API 파라미터 설정 — 사용 시에만 펼침 */}
|
||
<details className="rounded-md border bg-muted/20 p-2" open={apiParamType !== "none"}>
|
||
<summary className="cursor-pointer text-[11px] font-medium hover:text-foreground">
|
||
API 파라미터 설정 {apiParamType !== "none" && <span className="ml-1 rounded bg-primary/10 px-1 text-[10px] text-primary">활성</span>}
|
||
</summary>
|
||
<div className="mt-2 space-y-2">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="space-y-1">
|
||
<Label className="text-[11px]">파라미터 타입</Label>
|
||
<Select value={apiParamType} onValueChange={(value: any) => setApiParamType(value)}>
|
||
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">파라미터 없음</SelectItem>
|
||
<SelectItem value="url">URL 파라미터</SelectItem>
|
||
<SelectItem value="query">쿼리 파라미터</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{apiParamType !== "none" && (
|
||
<div className="space-y-1">
|
||
<Label htmlFor="apiParamName" className="text-[11px]">파라미터명 *</Label>
|
||
<Input id="apiParamName" value={apiParamName} onChange={(e) => setApiParamName(e.target.value)} placeholder="userId, id" className="h-7 text-xs" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
{apiParamType !== "none" && (
|
||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||
<div className="space-y-1">
|
||
<Label className="text-[11px]">소스</Label>
|
||
<Select value={apiParamSource} onValueChange={(value: any) => setApiParamSource(value)}>
|
||
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="static">고정값</SelectItem>
|
||
<SelectItem value="dynamic">동적값</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label htmlFor="apiParamValue" className="text-[11px]">{apiParamSource === "static" ? "값" : "템플릿"} *</Label>
|
||
<Input id="apiParamValue" value={apiParamValue} onChange={(e) => setApiParamValue(e.target.value)} placeholder={apiParamSource === "static" ? "123" : "{{user_id}}"} className="h-7 text-xs" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</details>
|
||
|
||
{/* API 호출 미리보기 정보 — 한 줄로 */}
|
||
{fromApiUrl && fromEndpoint && (
|
||
<div className="rounded-md bg-muted px-2 py-1.5 text-[11px]">
|
||
<span className="font-medium">호출:</span>{" "}
|
||
<span className="font-mono text-muted-foreground">
|
||
{fromApiMethod} {fromApiUrl}
|
||
{apiParamType === "url" && apiParamName && apiParamValue
|
||
? fromEndpoint.replace(`{${apiParamName}}`, apiParamValue) || fromEndpoint + `/${apiParamValue}`
|
||
: fromEndpoint}
|
||
{apiParamType === "query" && apiParamName && apiParamValue ? `?${apiParamName}=${apiParamValue}` : ""}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* DB 설정 (DB → REST API) */}
|
||
{batchType === "db-to-restapi" && (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
<div>
|
||
<Label>소스 연결 *</Label>
|
||
<Select
|
||
value={fromConnection?.id?.toString() || fromConnection?.type || ""}
|
||
onValueChange={handleFromConnectionChange}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="연결을 선택하세요" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{connections.map((connection) => (
|
||
<SelectItem
|
||
key={connection.id || "internal"}
|
||
value={connection.id ? connection.id.toString() : "internal"}
|
||
>
|
||
{connection.name} ({connection.type === "internal" ? "내부 DB" : connection.db_type})
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label>소스 테이블 *</Label>
|
||
<Select value={fromTable} onValueChange={handleFromTableChange}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="테이블을 선택하세요" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{Array.isArray(fromTables) &&
|
||
fromTables.map((table: string) => (
|
||
<SelectItem key={table} value={table}>
|
||
{table.toUpperCase()}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 컬럼 선택 UI */}
|
||
{fromColumns.length > 0 && (
|
||
<div>
|
||
<Label>컬럼 선택 (선택된 컬럼이 API로 전송됩니다)</Label>
|
||
<div className="mt-2 grid max-h-60 grid-cols-2 gap-2 overflow-y-auto rounded-lg border p-3 md:grid-cols-3 lg:grid-cols-4">
|
||
{fromColumns.map((column) => (
|
||
<div key={column.column_name} className="flex items-center space-x-2">
|
||
<input
|
||
type="checkbox"
|
||
id={`col-${column.column_name}`}
|
||
checked={selectedColumns.includes(column.column_name)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setSelectedColumns([...selectedColumns, column.column_name]);
|
||
} else {
|
||
setSelectedColumns(selectedColumns.filter((col) => col !== column.column_name));
|
||
}
|
||
}}
|
||
className="rounded border-input"
|
||
/>
|
||
<label
|
||
htmlFor={`col-${column.column_name}`}
|
||
className="flex-1 cursor-pointer text-sm"
|
||
title={`타입: ${column.data_type} | NULL: ${column.is_nullable ? "Y" : "N"}`}
|
||
>
|
||
{column.column_name}
|
||
</label>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 선택된 컬럼 개수 표시 */}
|
||
<div className="mt-2 text-xs text-muted-foreground">
|
||
선택된 컬럼: {selectedColumns.length}개 / 전체: {fromColumns.length}개
|
||
</div>
|
||
|
||
{/* 빠른 매핑 버튼들 */}
|
||
{selectedColumns.length > 0 && toApiFields.length > 0 && (
|
||
<div className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3">
|
||
<div className="mb-2 text-sm font-medium text-emerald-800">빠른 매핑</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const mapping: Record<string, string> = {};
|
||
selectedColumns.forEach((col) => {
|
||
// 스마트 매핑 로직
|
||
const matchingApiField = toApiFields.find((apiField) => {
|
||
const colLower = col.toLowerCase();
|
||
const apiLower = apiField.toLowerCase();
|
||
|
||
// 정확한 매치
|
||
if (colLower === apiLower) return true;
|
||
|
||
// 언더스코어 무시 매치
|
||
if (colLower.replace(/_/g, "") === apiLower.replace(/_/g, "")) return true;
|
||
// 의미적 매핑
|
||
if (
|
||
(colLower.includes("created") || colLower.includes("reg")) &&
|
||
(apiLower.includes("date") || apiLower.includes("time"))
|
||
)
|
||
return true;
|
||
if (
|
||
(colLower.includes("updated") || colLower.includes("mod")) &&
|
||
(apiLower.includes("date") || apiLower.includes("time"))
|
||
)
|
||
return true;
|
||
if (colLower.includes("id") && apiLower.includes("id")) return true;
|
||
if (colLower.includes("name") && apiLower.includes("name")) return true;
|
||
if (colLower.includes("code") && apiLower.includes("code")) return true;
|
||
|
||
return false;
|
||
});
|
||
|
||
if (matchingApiField) {
|
||
mapping[col] = matchingApiField;
|
||
}
|
||
});
|
||
setDbToApiFieldMapping(mapping);
|
||
toast.success(`${Object.keys(mapping).length}개 컬럼이 자동 매핑되었습니다.`);
|
||
}}
|
||
className="rounded bg-primary px-3 py-1 text-xs text-white hover:bg-primary/90"
|
||
>
|
||
스마트 자동 매핑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setDbToApiFieldMapping({});
|
||
toast.success("매핑이 초기화되었습니다.");
|
||
}}
|
||
className="rounded bg-foreground/80 px-3 py-1 text-xs text-white hover:bg-foreground/90"
|
||
>
|
||
매핑 초기화
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 자동 생성된 JSON 미리보기 */}
|
||
{selectedColumns.length > 0 && (
|
||
<div className="mt-3 rounded-lg border border-primary/20 bg-primary/10 p-3">
|
||
<div className="mb-2 text-sm font-medium text-primary">자동 생성된 JSON 구조</div>
|
||
<pre className="overflow-x-auto font-mono text-xs text-primary">
|
||
{JSON.stringify(
|
||
selectedColumns.reduce(
|
||
(obj, col) => {
|
||
const apiField = dbToApiFieldMapping[col] || col; // 매핑된 API 필드명 또는 원본 컬럼명
|
||
obj[apiField] = `{{${col}}}`;
|
||
return obj;
|
||
},
|
||
{} as Record<string, string>,
|
||
),
|
||
null,
|
||
2,
|
||
)}
|
||
</pre>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const autoJson = JSON.stringify(
|
||
selectedColumns.reduce(
|
||
(obj, col) => {
|
||
const apiField = dbToApiFieldMapping[col] || col; // 매핑된 API 필드명 또는 원본 컬럼명
|
||
obj[apiField] = `{{${col}}}`;
|
||
return obj;
|
||
},
|
||
{} as Record<string, string>,
|
||
),
|
||
null,
|
||
2,
|
||
);
|
||
setToApiBody(autoJson);
|
||
toast.success("Request Body에 자동 생성된 JSON이 적용되었습니다.");
|
||
}}
|
||
className="mt-2 rounded bg-primary px-3 py-1 text-xs text-white hover:bg-primary/90"
|
||
>
|
||
Request Body에 적용
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* TO 설정 */}
|
||
<Card>
|
||
<CardHeader className="px-3 pb-2 pt-3">
|
||
<CardTitle className="flex items-center text-sm">
|
||
{batchType === "restapi-to-db" ? (
|
||
<>
|
||
<Database className="mr-1.5 h-4 w-4" />
|
||
TO: 데이터베이스 (대상)
|
||
</>
|
||
) : (
|
||
<>
|
||
<Globe className="mr-1.5 h-4 w-4" />
|
||
TO: REST API (대상)
|
||
</>
|
||
)}
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2 px-3 pb-3 pt-0">
|
||
{/* DB 설정 (REST API → DB) - 컴팩트 (FullHD 풀 활용) */}
|
||
{batchType === "restapi-to-db" && (
|
||
<div className="space-y-2">
|
||
{/* 1. 커넥션 + 테이블 — 한 줄 */}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="space-y-1">
|
||
<Label className="text-[11px]">DB 커넥션 *</Label>
|
||
<Select
|
||
value={
|
||
toConnection
|
||
? toConnection.type === "internal"
|
||
? "internal"
|
||
: toConnection.id?.toString() || ""
|
||
: ""
|
||
}
|
||
onValueChange={handleToConnectionChange}
|
||
>
|
||
<SelectTrigger className="h-7 text-xs">
|
||
<SelectValue placeholder="커넥션 선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{connections.map((connection, index) => (
|
||
<SelectItem
|
||
key={connection.id || `internal-${index}`}
|
||
value={connection.id ? connection.id.toString() : "internal"}
|
||
>
|
||
{connection.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className={`space-y-1 ${toTables.length === 0 ? "pointer-events-none opacity-50" : ""}`}>
|
||
<Label className="text-[11px]">테이블 *</Label>
|
||
<Select value={toTable || ""} onValueChange={handleToTableChange} disabled={toTables.length === 0}>
|
||
<SelectTrigger className="h-7 text-xs">
|
||
<SelectValue placeholder={toTables.length === 0 ? "먼저 커넥션 선택" : "테이블 선택"} />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{toTables.map((table: string) => (
|
||
<SelectItem key={table} value={table}>
|
||
{table.toUpperCase()}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 2. 저장 모드 + 충돌 기준 — 한 줄 */}
|
||
<div className={`grid grid-cols-[auto_1fr] items-end gap-2 ${!toTable ? "pointer-events-none opacity-50" : ""}`}>
|
||
<div className="space-y-1">
|
||
<Label className="text-[11px]">저장 모드</Label>
|
||
<div className="flex gap-1 rounded-md border p-0.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => { setSaveMode("INSERT"); setConflictKey(""); }}
|
||
disabled={!toTable}
|
||
className={`h-6 rounded px-2 text-[11px] transition ${saveMode === "INSERT" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"}`}
|
||
>INSERT</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSaveMode("UPSERT")}
|
||
disabled={!toTable}
|
||
className={`h-6 rounded px-2 text-[11px] transition ${saveMode === "UPSERT" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"}`}
|
||
>UPSERT</button>
|
||
</div>
|
||
</div>
|
||
<div className={`space-y-1 ${saveMode !== "UPSERT" ? "pointer-events-none opacity-50" : ""}`}>
|
||
<Label htmlFor="conflictKey" className="text-[11px]">충돌 기준 컬럼 (Conflict Key) {saveMode === "UPSERT" && <span className="text-destructive">*</span>}</Label>
|
||
<Select value={conflictKey} onValueChange={setConflictKey} disabled={saveMode !== "UPSERT"}>
|
||
<SelectTrigger className="h-7 text-xs">
|
||
<SelectValue placeholder={saveMode !== "UPSERT" ? "UPSERT 모드 선택" : "기준 컬럼 선택"} />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{toColumns.map((col) => (
|
||
<SelectItem key={col.column_name} value={col.column_name}>
|
||
{col.column_name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 3. 행 단위 제외 필터 — 특정 API 필드 값을 가진 행 동기화 제외 */}
|
||
<div className={`rounded-md border bg-muted/30 p-2 ${!toTable ? "pointer-events-none opacity-50" : ""}`}>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<Label className="text-[11px] font-medium">행 제외 필터</Label>
|
||
<p className="text-[10px] leading-tight text-muted-foreground">
|
||
지정한 API 필드 값에 해당하는 행을 동기화에서 제외합니다. (예: loginId = wace)
|
||
</p>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-6 px-2 text-[11px]"
|
||
onClick={() => setRowFilterRules((prev) => [...prev, { column: "", op: "eq", value: "" }])}
|
||
disabled={!toTable}
|
||
>+ 규칙 추가</Button>
|
||
</div>
|
||
{rowFilterRules.length > 0 && (
|
||
<div className="mt-1.5 space-y-1">
|
||
{rowFilterRules.map((rule, idx) => (
|
||
<div key={idx} className="grid grid-cols-[1fr_70px_1fr_24px] items-center gap-1">
|
||
<Input
|
||
value={rule.column}
|
||
onChange={(e) => setRowFilterRules((prev) => prev.map((r, i) => i === idx ? { ...r, column: e.target.value } : r))}
|
||
placeholder="API 필드명 (예: loginId)"
|
||
className="h-7 text-xs"
|
||
/>
|
||
<Select
|
||
value={rule.op}
|
||
onValueChange={(v) => setRowFilterRules((prev) => prev.map((r, i) => i === idx ? { ...r, op: v as "eq" | "neq" } : r))}
|
||
>
|
||
<SelectTrigger className="h-7 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="eq">같으면</SelectItem>
|
||
<SelectItem value="neq">다르면</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Input
|
||
value={rule.value}
|
||
onChange={(e) => setRowFilterRules((prev) => prev.map((r, i) => i === idx ? { ...r, value: e.target.value } : r))}
|
||
placeholder="제외 대상 값 (예: wace)"
|
||
className="h-7 text-xs"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setRowFilterRules((prev) => prev.filter((_, i) => i !== idx))}
|
||
className="flex h-7 w-6 items-center justify-center rounded text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||
aria-label="규칙 삭제"
|
||
>×</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* REST API 설정 (DB → REST API) */}
|
||
{batchType === "db-to-restapi" && (
|
||
<div className="space-y-4">
|
||
{/* API 서버 URL */}
|
||
<div>
|
||
<Label htmlFor="toApiUrl">API 서버 URL *</Label>
|
||
<Input
|
||
id="toApiUrl"
|
||
value={toApiUrl}
|
||
onChange={(e) => setToApiUrl(e.target.value)}
|
||
placeholder="https://api.example.com"
|
||
/>
|
||
</div>
|
||
|
||
{/* API 키 */}
|
||
<div>
|
||
<Label htmlFor="toApiKey">API 키 *</Label>
|
||
<Input
|
||
id="toApiKey"
|
||
value={toApiKey}
|
||
onChange={(e) => setToApiKey(e.target.value)}
|
||
placeholder="ak_your_api_key_here"
|
||
/>
|
||
</div>
|
||
|
||
{/* 엔드포인트 */}
|
||
<div>
|
||
<Label htmlFor="toEndpoint">엔드포인트 *</Label>
|
||
<Input
|
||
id="toEndpoint"
|
||
value={toEndpoint}
|
||
onChange={(e) => setToEndpoint(e.target.value)}
|
||
placeholder="/api/users"
|
||
/>
|
||
{(toApiMethod === "PUT" || toApiMethod === "DELETE") && (
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
실제 URL: {toEndpoint}/{urlPathColumn ? `{${urlPathColumn}}` : "{ID}"}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* HTTP 메서드 */}
|
||
<div>
|
||
<Label>HTTP 메서드</Label>
|
||
<Select value={toApiMethod} onValueChange={(value: any) => setToApiMethod(value)}>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="POST">POST (데이터 생성)</SelectItem>
|
||
<SelectItem value="PUT">PUT (데이터 업데이트)</SelectItem>
|
||
<SelectItem value="DELETE">DELETE (데이터 삭제)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* URL 경로 파라미터 설정 (PUT/DELETE용) */}
|
||
{(toApiMethod === "PUT" || toApiMethod === "DELETE") && (
|
||
<div>
|
||
<Label>URL 경로 파라미터 컬럼 *</Label>
|
||
<Select value={urlPathColumn} onValueChange={setUrlPathColumn}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="URL 경로에 사용할 컬럼을 선택하세요" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{selectedColumns.map((column) => (
|
||
<SelectItem key={column} value={column}>
|
||
{column} (예: /api/users/{`{${column}}`})
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
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-emerald-600 px-4 py-2 text-white hover:bg-green-700"
|
||
>
|
||
<Eye className="h-4 w-4" />
|
||
<span>API 필드 미리보기</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* TO API 필드 표시 */}
|
||
{toApiFields.length > 0 && (
|
||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3">
|
||
<div className="mb-2 text-sm font-medium text-emerald-800">
|
||
API 필드 목록 ({toApiFields.length}개)
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{toApiFields.map((field) => (
|
||
<span key={field} className="rounded bg-emerald-100 px-2 py-1 text-xs text-emerald-700">
|
||
{field}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Request Body 템플릿 */}
|
||
{(toApiMethod === "POST" || toApiMethod === "PUT") && (
|
||
<div>
|
||
<Label htmlFor="toApiBody">Request Body 템플릿 (JSON)</Label>
|
||
<textarea
|
||
id="toApiBody"
|
||
value={toApiBody}
|
||
onChange={(e) => setToApiBody(e.target.value)}
|
||
placeholder='{"id": "{{id}}", "name": "{{name}}", "email": "{{email}}"}'
|
||
className="h-24 w-full rounded-md border p-2 font-mono text-sm"
|
||
/>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
DB 컬럼 값을 {"{{컬럼명}}"} 형태로 매핑하세요. 예: {"{{user_id}}, {{user_name}}"}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* API 호출 정보 */}
|
||
{toApiUrl && toApiKey && toEndpoint && (
|
||
<div className="rounded-lg bg-muted p-3">
|
||
<div className="text-sm font-medium text-foreground">API 호출 정보</div>
|
||
<div className="mt-1 text-sm text-muted-foreground">
|
||
{toApiMethod} {toApiUrl}
|
||
{toEndpoint}
|
||
</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">Headers: X-API-Key: {toApiKey.substring(0, 10)}...</div>
|
||
{toApiBody && (
|
||
<div className="mt-1 text-xs text-primary">Body: {toApiBody.substring(0, 50)}...</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* API 데이터 미리보기 버튼 - FROM/TO 섹션 아래 */}
|
||
{batchType === "restapi-to-db" && (() => {
|
||
const useRegistered = selectedRestApiId !== "manual";
|
||
// 등록된 연결이면 toTable 만 필요, 직접 입력이면 URL/엔드포인트도 필요
|
||
const fromOk = useRegistered || (fromApiUrl && fromEndpoint);
|
||
const disabled = !fromOk || !toTable;
|
||
return (
|
||
<div className="flex justify-center">
|
||
<Button
|
||
onClick={previewRestApiData}
|
||
variant="outline"
|
||
disabled={disabled}
|
||
className="gap-2"
|
||
>
|
||
<RefreshCw className="h-4 w-4" />
|
||
데이터 불러오고 매핑하기
|
||
</Button>
|
||
{disabled && (
|
||
<p className="ml-4 flex items-center text-xs text-muted-foreground">
|
||
{useRegistered
|
||
? "TO: 데이터베이스의 커넥션·테이블을 선택하세요."
|
||
: "FROM 섹션과 TO 섹션의 필수 값을 모두 입력해야 합니다."}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* 매핑 UI - 배치 타입별 동적 렌더링 */}
|
||
{/* REST API → DB 매핑 */}
|
||
{batchType === "restapi-to-db" && fromApiFields.length > 0 && toColumns.length > 0 && (
|
||
<RestApiToDbMappingCard
|
||
fromApiFields={fromApiFields}
|
||
toColumns={toColumns}
|
||
fromApiData={fromApiData}
|
||
mappingList={mappingList}
|
||
setMappingList={setMappingList}
|
||
/>
|
||
)}
|
||
|
||
{/* DB → REST API 매핑 */}
|
||
{batchType === "db-to-restapi" && selectedColumns.length > 0 && toApiFields.length > 0 && (
|
||
<DbToRestApiMappingCard
|
||
fromColumns={fromColumns}
|
||
selectedColumns={selectedColumns}
|
||
toApiFields={toApiFields}
|
||
dbToApiFieldMapping={dbToApiFieldMapping}
|
||
setDbToApiFieldMapping={setDbToApiFieldMapping}
|
||
setToApiBody={setToApiBody}
|
||
/>
|
||
)}
|
||
|
||
{/* 하단 액션 버튼 — 헤더 우측에도 있지만, 스크롤 끝에서 다시 한 번 노출 */}
|
||
<div className="flex items-center justify-end gap-2 border-t pt-4">
|
||
<Button onClick={goBack} variant="outline" size="sm" className="h-8 gap-1 text-xs">취소</Button>
|
||
<Button onClick={loadConnections} variant="outline" size="sm" className="h-8 gap-1 text-xs">
|
||
<RefreshCw className="h-3.5 w-3.5" />
|
||
새로고침
|
||
</Button>
|
||
<Button onClick={handleSave} size="sm" className="h-8 gap-1 text-xs">
|
||
<Save className="h-3.5 w-3.5" />
|
||
{isEditMode ? "수정" : "저장"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 조건 변환 편집기 — API 필드값에 따라 다른 값으로 매핑
|
||
* 예: enrlFg 가 "J01" → "active", "J05" → "inactive", 나머지 → ""
|
||
*/
|
||
function ConditionalEditor({
|
||
mapping,
|
||
fromApiFields,
|
||
onApiFieldChange,
|
||
onConfigChange,
|
||
}: {
|
||
mapping: MappingItem;
|
||
fromApiFields: string[];
|
||
onApiFieldChange: (v: string) => void;
|
||
onConfigChange: (cfg: ConditionalConfig) => void;
|
||
}) {
|
||
const cfg = mapping.conditionalConfig || { rules: [{ when: "", then: "" }], default: "" };
|
||
const isApiFieldMissing = !mapping.apiField;
|
||
const updateRule = (idx: number, patch: Partial<ConditionalRule>) => {
|
||
const rules = cfg.rules.map((r, i) => (i === idx ? { ...r, ...patch } : r));
|
||
onConfigChange({ ...cfg, rules });
|
||
};
|
||
const addRule = () => onConfigChange({ ...cfg, rules: [...cfg.rules, { when: "", then: "" }] });
|
||
const removeRule = (idx: number) =>
|
||
onConfigChange({ ...cfg, rules: cfg.rules.filter((_, i) => i !== idx) });
|
||
return (
|
||
<div className="space-y-1.5 rounded border bg-muted/30 p-2">
|
||
<div className="space-y-0.5">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">평가 필드 <span className="text-destructive">*</span></span>
|
||
<Select value={mapping.apiField || "none"} onValueChange={(v) => onApiFieldChange(v === "none" ? "" : v)}>
|
||
<SelectTrigger className={cn(
|
||
"h-7 text-xs",
|
||
isApiFieldMissing && "border-destructive ring-1 ring-destructive/40",
|
||
)}>
|
||
<SelectValue placeholder="조건을 평가할 API 필드 선택 (필수)" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">선택 안함</SelectItem>
|
||
{/* 저장된 apiField 가 fromApiFields 옵션에 없을 때 동적 추가 (응답 미리보기 안 한 편집 모드 대응) */}
|
||
{mapping.apiField && !fromApiFields.includes(mapping.apiField) && (
|
||
<SelectItem value={mapping.apiField}>{mapping.apiField} (저장값)</SelectItem>
|
||
)}
|
||
{fromApiFields.map((f) => (
|
||
<SelectItem key={f} value={f}>{f}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<p className="pl-[60px] text-[10px] text-muted-foreground">
|
||
이 필드의 값에 따라 아래 규칙으로 변환됩니다 (예: status 컬럼에 <span className="font-mono">enrlFg</span> 의 값을 보고 J01→active 변환 시 평가 필드 = <span className="font-mono">enrlFg</span>)
|
||
</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
{cfg.rules.map((rule, idx) => (
|
||
<div key={idx} className="flex items-center gap-1">
|
||
<span className="shrink-0 text-[10px] text-muted-foreground">값이</span>
|
||
<Input
|
||
value={rule.when}
|
||
onChange={(e) => updateRule(idx, { when: e.target.value })}
|
||
placeholder="예: J01"
|
||
className="h-7 flex-1 text-xs"
|
||
/>
|
||
<span className="shrink-0 text-[10px] text-muted-foreground">→</span>
|
||
<Input
|
||
value={rule.then}
|
||
onChange={(e) => updateRule(idx, { then: e.target.value })}
|
||
placeholder="저장값"
|
||
className="h-7 flex-1 text-xs"
|
||
/>
|
||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeRule(idx)}>
|
||
<Trash2 className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
<Button variant="outline" size="sm" onClick={addRule} className="h-6 gap-1 px-2 text-[10px]">
|
||
<Plus className="h-3 w-3" />
|
||
규칙 추가
|
||
</Button>
|
||
</div>
|
||
<div className="flex items-center gap-1 border-t pt-1">
|
||
<span className="shrink-0 text-[10px] text-muted-foreground">매칭 없음(기본)</span>
|
||
<Input
|
||
value={cfg.default}
|
||
onChange={(e) => onConfigChange({ ...cfg, default: e.target.value })}
|
||
placeholder="예: 0 또는 빈값"
|
||
className="h-7 flex-1 text-xs"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
|
||
fromApiFields,
|
||
toColumns,
|
||
fromApiData,
|
||
mappingList,
|
||
setMappingList,
|
||
}: RestApiToDbMappingCardProps) {
|
||
// 샘플 JSON 문자열
|
||
const sampleJsonList = useMemo(
|
||
() => fromApiData.slice(0, 3).map((item) => JSON.stringify(item, null, 2)),
|
||
[fromApiData],
|
||
);
|
||
|
||
const firstSample = fromApiData[0] || null;
|
||
|
||
// 이미 매핑된 DB 컬럼들
|
||
const mappedDbColumns = useMemo(() => mappingList.map((m) => m.dbColumn).filter(Boolean), [mappingList]);
|
||
|
||
// 매핑 추가
|
||
const addMapping = () => {
|
||
const newId = `mapping-${Date.now()}`;
|
||
setMappingList((prev) => [
|
||
...prev,
|
||
{
|
||
id: newId,
|
||
dbColumn: "",
|
||
sourceType: "api",
|
||
apiField: "",
|
||
fixedValue: "",
|
||
},
|
||
]);
|
||
};
|
||
|
||
// 매핑 삭제
|
||
const removeMapping = (id: string) => {
|
||
setMappingList((prev) => prev.filter((m) => m.id !== id));
|
||
};
|
||
|
||
// 매핑 업데이트
|
||
const updateMapping = (id: string, updates: Partial<MappingItem>) => {
|
||
setMappingList((prev) => prev.map((m) => (m.id === id ? { ...m, ...updates } : m)));
|
||
};
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader className="px-3 pb-2 pt-3">
|
||
<CardTitle className="text-sm">컬럼 매핑 설정</CardTitle>
|
||
<CardDescription className="text-[11px]">DB 컬럼에 API 필드 또는 고정값을 매핑합니다.</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="px-3 pb-3 pt-0">
|
||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||
{/* 왼쪽: 샘플 데이터 */}
|
||
<div className="flex flex-col">
|
||
<div className="mb-2 flex h-7 items-center">
|
||
<h4 className="text-sm font-semibold">샘플 데이터 (최대 3개)</h4>
|
||
</div>
|
||
{sampleJsonList.length > 0 ? (
|
||
<div className="bg-muted/30 h-[360px] overflow-y-auto rounded-lg border p-3">
|
||
<div className="space-y-2">
|
||
{sampleJsonList.map((json, index) => (
|
||
<div key={index} className="bg-background rounded border p-2">
|
||
<pre className="font-mono text-xs whitespace-pre-wrap">{json}</pre>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex h-[360px] items-center justify-center rounded-lg border border-dashed">
|
||
<p className="text-muted-foreground text-sm">
|
||
API 데이터 미리보기를 실행하면 샘플 데이터가 표시됩니다.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 오른쪽: 매핑 영역 (스크롤) */}
|
||
<div className="flex flex-col">
|
||
<div className="mb-3 flex h-8 items-center justify-between">
|
||
<h4 className="text-sm font-semibold">매핑 설정</h4>
|
||
<Button variant="outline" size="sm" onClick={addMapping} className="h-8 gap-1">
|
||
<Plus className="h-4 w-4" />
|
||
매핑 추가
|
||
</Button>
|
||
</div>
|
||
|
||
{mappingList.length === 0 ? (
|
||
<div className="flex h-[360px] flex-col items-center justify-center rounded-lg border border-dashed text-center">
|
||
<p className="text-muted-foreground text-sm">매핑이 없습니다.</p>
|
||
<Button variant="link" onClick={addMapping} className="mt-2">
|
||
매핑 추가하기
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="bg-muted/30 h-[360px] space-y-3 overflow-y-auto rounded-lg border p-3">
|
||
{mappingList.map((mapping, index) => (
|
||
<div key={mapping.id} className="bg-background flex items-center gap-2 rounded-lg border p-3">
|
||
{/* 순서 표시 */}
|
||
<div className="bg-primary/10 text-primary flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium">
|
||
{index + 1}
|
||
</div>
|
||
|
||
{/* DB 컬럼 선택 (좌측 - TO) */}
|
||
<div className="w-36 shrink-0">
|
||
<Select
|
||
value={mapping.dbColumn || "none"}
|
||
onValueChange={(value) =>
|
||
updateMapping(mapping.id, { dbColumn: value === "none" ? "" : value })
|
||
}
|
||
>
|
||
<SelectTrigger className="h-9">
|
||
<SelectValue placeholder="DB 컬럼" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">선택 안함</SelectItem>
|
||
{toColumns.map((col) => {
|
||
const isUsed =
|
||
mappedDbColumns.includes(col.column_name) && mapping.dbColumn !== col.column_name;
|
||
return (
|
||
<SelectItem key={col.column_name} value={col.column_name} disabled={isUsed}>
|
||
<div className="flex items-center gap-2">
|
||
<span className={isUsed ? "text-muted-foreground" : ""}>{col.column_name}</span>
|
||
<span className="text-muted-foreground text-xs">({col.data_type})</span>
|
||
</div>
|
||
</SelectItem>
|
||
);
|
||
})}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 화살표 */}
|
||
<ArrowLeft className="text-muted-foreground h-4 w-4 shrink-0" />
|
||
|
||
{/* 소스 타입 선택 */}
|
||
<div className="w-28 shrink-0">
|
||
<Select
|
||
value={mapping.sourceType}
|
||
onValueChange={(value: "api" | "fixed" | "conditional") =>
|
||
updateMapping(mapping.id, {
|
||
sourceType: value,
|
||
// 모드 전환 시 입력값 정리
|
||
apiField:
|
||
value === "api" || value === "conditional"
|
||
? mapping.apiField
|
||
: "",
|
||
fixedValue: value === "fixed" ? mapping.fixedValue : "",
|
||
conditionalConfig:
|
||
value === "conditional"
|
||
? mapping.conditionalConfig || { rules: [{ when: "", then: "" }], default: "" }
|
||
: mapping.conditionalConfig,
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-9">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="api">API 필드</SelectItem>
|
||
<SelectItem value="fixed">고정값</SelectItem>
|
||
<SelectItem value="conditional">조건 변환</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* API 필드 선택 / 고정값 입력 / 조건 변환 (우측 - FROM) */}
|
||
<div className="min-w-0 flex-1">
|
||
{mapping.sourceType === "api" && (
|
||
<Select
|
||
value={mapping.apiField || "none"}
|
||
onValueChange={(value) =>
|
||
updateMapping(mapping.id, { apiField: value === "none" ? "" : value })
|
||
}
|
||
>
|
||
<SelectTrigger className="h-9">
|
||
<SelectValue placeholder="API 필드" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">선택 안함</SelectItem>
|
||
{fromApiFields.map((field) => (
|
||
<SelectItem key={field} value={field}>
|
||
<div className="flex items-center gap-2">
|
||
<span>{field}</span>
|
||
{firstSample && firstSample[field] !== undefined && (
|
||
<span className="text-muted-foreground text-xs">
|
||
(예: {String(firstSample[field]).substring(0, 15)}
|
||
{String(firstSample[field]).length > 15 ? "..." : ""})
|
||
</span>
|
||
)}
|
||
</div>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
)}
|
||
{mapping.sourceType === "fixed" && (
|
||
<Input
|
||
value={mapping.fixedValue}
|
||
onChange={(e) => updateMapping(mapping.id, { fixedValue: e.target.value })}
|
||
placeholder="고정값 입력"
|
||
className="h-9"
|
||
/>
|
||
)}
|
||
{mapping.sourceType === "conditional" && (
|
||
<ConditionalEditor
|
||
mapping={mapping}
|
||
fromApiFields={fromApiFields}
|
||
onApiFieldChange={(v) => updateMapping(mapping.id, { apiField: v })}
|
||
onConfigChange={(cfg) =>
|
||
updateMapping(mapping.id, { conditionalConfig: cfg })
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* 삭제 버튼 */}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => removeMapping(mapping.id)}
|
||
className="text-muted-foreground hover:text-destructive h-8 w-8 shrink-0"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
});
|
||
|
||
const DbToRestApiMappingCard = memo(function DbToRestApiMappingCard({
|
||
fromColumns,
|
||
selectedColumns,
|
||
toApiFields,
|
||
dbToApiFieldMapping,
|
||
setDbToApiFieldMapping,
|
||
setToApiBody,
|
||
}: DbToRestApiMappingCardProps) {
|
||
const selectedColumnObjects = useMemo(
|
||
() => fromColumns.filter((column) => selectedColumns.includes(column.column_name)),
|
||
[fromColumns, selectedColumns],
|
||
);
|
||
|
||
const autoJsonPreview = useMemo(() => {
|
||
if (selectedColumns.length === 0) {
|
||
return "";
|
||
}
|
||
const obj = selectedColumns.reduce(
|
||
(acc, col) => {
|
||
const apiField = dbToApiFieldMapping[col] || col;
|
||
acc[apiField] = `{{${col}}}`;
|
||
return acc;
|
||
},
|
||
{} as Record<string, string>,
|
||
);
|
||
return JSON.stringify(obj, null, 2);
|
||
}, [selectedColumns, dbToApiFieldMapping]);
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader className="px-3 pb-2 pt-3">
|
||
<CardTitle className="text-sm">DB 컬럼 → API 필드 매핑</CardTitle>
|
||
<CardDescription className="text-[11px]">
|
||
DB 컬럼 값을 REST API Request Body에 매핑하세요. Request Body 템플릿에서 {"{{컬럼명}}"} 형태로 사용됩니다.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="px-3 pb-3 pt-0">
|
||
<div className="max-h-96 space-y-2 overflow-y-auto rounded-lg border p-2">
|
||
{selectedColumnObjects.map((column) => (
|
||
<div key={column.column_name} className="flex items-center space-x-4 rounded-lg bg-muted p-3">
|
||
{/* DB 컬럼 정보 */}
|
||
<div className="flex-1">
|
||
<div className="text-sm font-medium">{column.column_name}</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
타입: {column.data_type} | NULL: {column.is_nullable ? "Y" : "N"}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 화살표 */}
|
||
<div className="text-muted-foreground/70">→</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-input px-3 py-2 text-sm focus:ring-2 focus:ring-ring focus:outline-none"
|
||
onChange={(e) => {
|
||
setDbToApiFieldMapping((prev) => ({
|
||
...prev,
|
||
[column.column_name]: e.target.value,
|
||
}));
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
{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-muted-foreground">실제 DB 값으로 치환됩니다</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{selectedColumns.length > 0 && (
|
||
<div className="mt-4 rounded-lg border border-primary/20 bg-primary/10 p-3">
|
||
<div className="text-sm font-medium text-primary">자동 생성된 JSON 구조</div>
|
||
<pre className="mt-1 overflow-x-auto font-mono text-xs text-primary">{autoJsonPreview}</pre>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setToApiBody(autoJsonPreview);
|
||
toast.success("Request Body에 자동 생성된 JSON이 적용되었습니다.");
|
||
}}
|
||
className="mt-2 rounded bg-primary px-3 py-1 text-xs text-white hover:bg-primary/90"
|
||
>
|
||
Request Body에 적용
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-4 rounded-lg border border-primary/20 bg-primary/10 p-3">
|
||
<div className="text-sm font-medium text-primary">매핑 사용 예시</div>
|
||
<div className="mt-1 font-mono text-xs text-primary">
|
||
{'{"id": "{{id}}", "name": "{{user_name}}", "email": "{{email}}"}'}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
});
|