Files
wace_rps/frontend/app/(main)/admin/batch-management-new/page.tsx
T
chpark 575098698c 배치 편집 conditional 매핑 평가 필드 UX 개선
- 평가 필드 미선택 시 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>
2026-05-08 18:24:12 +09:00

2739 lines
124 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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> J01active = <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>
);
});