- Replaced existing toast error messages with the new `showErrorToast` utility across multiple components, improving consistency in error reporting. - Updated error messages to provide more specific guidance for users, enhancing the overall user experience during error scenarios. - Ensured that all relevant error handling in batch management, external call configurations, cascading management, and screen management components now utilizes the new utility for better maintainability.
425 lines
16 KiB
TypeScript
425 lines
16 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
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 { Switch } from "@/components/ui/switch";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { toast } from "sonner";
|
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
|
import { BatchAPI, BatchJob, BatchConfig } from "@/lib/api/batch";
|
|
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
|
|
|
// BatchJobModal에서 사용하던 config_json 구조 확장
|
|
interface RestApiConfigJson {
|
|
sourceConnectionId?: number;
|
|
targetConnectionId?: number;
|
|
targetTable?: string;
|
|
// REST API 관련 설정
|
|
apiUrl?: string;
|
|
apiKey?: string;
|
|
endpoint?: string;
|
|
httpMethod?: string;
|
|
apiBody?: string; // POST 요청용 Body
|
|
// 매핑 정보 등
|
|
mappings?: any[];
|
|
}
|
|
|
|
interface AdvancedBatchModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSave: () => void;
|
|
job?: BatchJob | null;
|
|
initialType?: "rest_to_db" | "db_to_rest"; // 초기 진입 시 타입 지정
|
|
}
|
|
|
|
export default function AdvancedBatchModal({
|
|
isOpen,
|
|
onClose,
|
|
onSave,
|
|
job,
|
|
initialType = "rest_to_db",
|
|
}: AdvancedBatchModalProps) {
|
|
// 기본 BatchJob 정보 관리
|
|
const [formData, setFormData] = useState<Partial<BatchJob>>({
|
|
job_name: "",
|
|
description: "",
|
|
job_type: initialType === "rest_to_db" ? "rest_to_db" : "db_to_rest",
|
|
schedule_cron: "",
|
|
is_active: "Y",
|
|
config_json: {},
|
|
});
|
|
|
|
// 상세 설정 (config_json 내부 값) 관리
|
|
const [configData, setConfigData] = useState<RestApiConfigJson>({
|
|
httpMethod: "GET", // 기본값
|
|
apiBody: "",
|
|
});
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [connections, setConnections] = useState<any[]>([]); // 내부/외부 DB 연결 목록
|
|
const [targetTables, setTargetTables] = useState<string[]>([]); // 대상 테이블 목록 (DB가 타겟일 때)
|
|
const [schedulePresets, setSchedulePresets] = useState<Array<{ value: string; label: string }>>([]);
|
|
|
|
// 모달 열릴 때 초기화
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
loadConnections();
|
|
loadSchedulePresets();
|
|
|
|
if (job) {
|
|
// 수정 모드
|
|
setFormData({
|
|
...job,
|
|
config_json: job.config_json || {},
|
|
});
|
|
// 기존 config_json 내용을 상태로 복원
|
|
const savedConfig = job.config_json as RestApiConfigJson;
|
|
setConfigData({
|
|
...savedConfig,
|
|
httpMethod: savedConfig.httpMethod || "GET",
|
|
apiBody: savedConfig.apiBody || "",
|
|
});
|
|
|
|
// 타겟 연결이 있으면 테이블 목록 로드
|
|
if (savedConfig.targetConnectionId) {
|
|
loadTables(savedConfig.targetConnectionId);
|
|
}
|
|
} else {
|
|
// 생성 모드
|
|
setFormData({
|
|
job_name: "",
|
|
description: "",
|
|
job_type: initialType === "rest_to_db" ? "rest_to_db" : "db_to_rest", // props로 받은 타입 우선
|
|
schedule_cron: "",
|
|
is_active: "Y",
|
|
config_json: {},
|
|
});
|
|
setConfigData({
|
|
httpMethod: "GET",
|
|
apiBody: "",
|
|
});
|
|
}
|
|
}
|
|
}, [isOpen, job, initialType]);
|
|
|
|
const loadConnections = async () => {
|
|
try {
|
|
// 외부 DB 연결 목록 조회 (내부 DB 포함)
|
|
const list = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
|
|
setConnections(list);
|
|
} catch (error) {
|
|
console.error("연결 목록 조회 오류:", error);
|
|
showErrorToast("연결 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
|
|
}
|
|
};
|
|
|
|
const loadTables = async (connectionId: number) => {
|
|
try {
|
|
const result = await ExternalDbConnectionAPI.getTables(connectionId);
|
|
if (result.success && result.data) {
|
|
setTargetTables(result.data);
|
|
}
|
|
} catch (error) {
|
|
console.error("테이블 목록 조회 오류:", error);
|
|
}
|
|
};
|
|
|
|
const loadSchedulePresets = async () => {
|
|
try {
|
|
const presets = await BatchAPI.getSchedulePresets();
|
|
setSchedulePresets(presets);
|
|
} catch (error) {
|
|
console.error("스케줄 프리셋 조회 오류:", error);
|
|
}
|
|
};
|
|
|
|
// 폼 제출 핸들러
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!formData.job_name) {
|
|
toast.error("배치명을 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
// REST API URL 필수 체크
|
|
if (!configData.apiUrl) {
|
|
toast.error("API 서버 URL을 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
// 타겟 DB 연결 필수 체크 (REST -> DB 인 경우)
|
|
if (formData.job_type === "rest_to_db" && !configData.targetConnectionId) {
|
|
toast.error("데이터를 저장할 대상 DB 연결을 선택해주세요.");
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
// 최종 저장할 데이터 조립
|
|
const finalJobData = {
|
|
...formData,
|
|
config_json: {
|
|
...configData,
|
|
// 추가적인 메타데이터가 필요하다면 여기에 포함
|
|
},
|
|
};
|
|
|
|
if (job?.id) {
|
|
await BatchAPI.updateBatchJob(job.id, finalJobData);
|
|
toast.success("배치 작업이 수정되었습니다.");
|
|
} else {
|
|
await BatchAPI.createBatchJob(finalJobData as BatchJob);
|
|
toast.success("배치 작업이 생성되었습니다.");
|
|
}
|
|
onSave();
|
|
onClose();
|
|
} catch (error) {
|
|
console.error("배치 저장 오류:", error);
|
|
showErrorToast("배치 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-hidden">
|
|
<DialogHeader>
|
|
<DialogTitle>고급 배치 생성</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6 py-2">
|
|
{/* 1. 기본 정보 섹션 */}
|
|
<div className="space-y-4 border rounded-md p-4 bg-slate-50">
|
|
<h3 className="text-sm font-semibold text-slate-900">기본 정보</h3>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label className="text-xs">배치 타입 *</Label>
|
|
<div className="mt-1 p-2 bg-white border rounded text-sm font-medium text-slate-600">
|
|
{formData.job_type === "rest_to_db" ? "🌐 REST API → 💾 DB" : "💾 DB → 🌐 REST API"}
|
|
</div>
|
|
<p className="text-[10px] text-slate-400 mt-1">
|
|
{formData.job_type === "rest_to_db"
|
|
? "REST API에서 데이터를 가져와 데이터베이스에 저장합니다."
|
|
: "데이터베이스의 데이터를 REST API로 전송합니다."}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="schedule_cron" className="text-xs">실행 스케줄 *</Label>
|
|
<div className="flex gap-2 mt-1">
|
|
<Input
|
|
id="schedule_cron"
|
|
value={formData.schedule_cron || ""}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))}
|
|
placeholder="예: 0 12 * * *"
|
|
className="text-sm"
|
|
/>
|
|
<Select onValueChange={(val) => setFormData(prev => ({ ...prev, schedule_cron: val }))}>
|
|
<SelectTrigger className="w-[100px]">
|
|
<SelectValue placeholder="프리셋" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{schedulePresets.map(p => (
|
|
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sm:col-span-2">
|
|
<Label htmlFor="job_name" className="text-xs">배치명 *</Label>
|
|
<Input
|
|
id="job_name"
|
|
value={formData.job_name || ""}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, job_name: e.target.value }))}
|
|
placeholder="배치명을 입력하세요"
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
|
|
<div className="sm:col-span-2">
|
|
<Label htmlFor="description" className="text-xs">설명</Label>
|
|
<Textarea
|
|
id="description"
|
|
value={formData.description || ""}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
|
placeholder="배치에 대한 설명을 입력하세요"
|
|
className="mt-1 min-h-[60px]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 2. REST API 설정 섹션 (Source) */}
|
|
<div className="space-y-4 border rounded-md p-4 bg-white">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-lg">🌐</span>
|
|
<h3 className="text-sm font-semibold text-slate-900">
|
|
{formData.job_type === "rest_to_db" ? "FROM: REST API (소스)" : "TO: REST API (대상)"}
|
|
</h3>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="sm:col-span-2">
|
|
<Label htmlFor="api_url" className="text-xs">API 서버 URL *</Label>
|
|
<Input
|
|
id="api_url"
|
|
value={configData.apiUrl || ""}
|
|
onChange={(e) => setConfigData(prev => ({ ...prev, apiUrl: e.target.value }))}
|
|
placeholder="https://api.example.com"
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
|
|
<div className="sm:col-span-2">
|
|
<Label htmlFor="api_key" className="text-xs">API 키 (선택)</Label>
|
|
<Input
|
|
id="api_key"
|
|
type="password"
|
|
value={configData.apiKey || ""}
|
|
onChange={(e) => setConfigData(prev => ({ ...prev, apiKey: e.target.value }))}
|
|
placeholder="인증에 필요한 API Key가 있다면 입력하세요"
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="endpoint" className="text-xs">엔드포인트 *</Label>
|
|
<Input
|
|
id="endpoint"
|
|
value={configData.endpoint || ""}
|
|
onChange={(e) => setConfigData(prev => ({ ...prev, endpoint: e.target.value }))}
|
|
placeholder="/api/token"
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="http_method" className="text-xs">HTTP 메서드</Label>
|
|
<Select
|
|
value={configData.httpMethod || "GET"}
|
|
onValueChange={(val) => setConfigData(prev => ({ ...prev, httpMethod: val }))}
|
|
>
|
|
<SelectTrigger className="mt-1">
|
|
<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>
|
|
|
|
{/* POST/PUT 일 때 Body 입력창 노출 */}
|
|
{(configData.httpMethod === "POST" || configData.httpMethod === "PUT") && (
|
|
<div className="sm:col-span-2 animate-in fade-in slide-in-from-top-2 duration-200">
|
|
<Label htmlFor="api_body" className="text-xs">Request Body (JSON)</Label>
|
|
<Textarea
|
|
id="api_body"
|
|
value={configData.apiBody || ""}
|
|
onChange={(e) => setConfigData(prev => ({ ...prev, apiBody: e.target.value }))}
|
|
placeholder='{"username": "myuser", "password": "mypassword"}'
|
|
className="mt-1 font-mono text-xs min-h-[100px]"
|
|
/>
|
|
<p className="text-[10px] text-slate-500 mt-1">
|
|
* 토큰 발급 요청 시 인증 정보를 JSON 형식으로 입력하세요.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 3. 데이터베이스 설정 섹션 (Target) */}
|
|
<div className="space-y-4 border rounded-md p-4 bg-white">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-lg">💾</span>
|
|
<h3 className="text-sm font-semibold text-slate-900">
|
|
{formData.job_type === "rest_to_db" ? "TO: 데이터베이스 (대상)" : "FROM: 데이터베이스 (소스)"}
|
|
</h3>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label className="text-xs">데이터베이스 커넥션 선택</Label>
|
|
<Select
|
|
value={configData.targetConnectionId?.toString() || ""}
|
|
onValueChange={(val) => {
|
|
const connId = parseInt(val);
|
|
setConfigData(prev => ({ ...prev, targetConnectionId: connId }));
|
|
loadTables(connId); // 테이블 목록 로드
|
|
}}
|
|
>
|
|
<SelectTrigger className="mt-1">
|
|
<SelectValue placeholder="커넥션을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{connections.map(conn => (
|
|
<SelectItem key={conn.id} value={conn.id.toString()}>
|
|
{conn.connection_name || conn.name} ({conn.db_type})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs">테이블 선택</Label>
|
|
<Select
|
|
value={configData.targetTable || ""}
|
|
onValueChange={(val) => setConfigData(prev => ({ ...prev, targetTable: val }))}
|
|
disabled={!configData.targetConnectionId}
|
|
>
|
|
<SelectTrigger className="mt-1">
|
|
<SelectValue placeholder="테이블을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{targetTables.length > 0 ? (
|
|
targetTables.map(table => (
|
|
<SelectItem key={table} value={table}>{table}</SelectItem>
|
|
))
|
|
) : (
|
|
<div className="p-2 text-xs text-center text-slate-400">테이블 없음</div>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={onClose}>
|
|
취소
|
|
</Button>
|
|
<Button type="submit" disabled={isLoading}>
|
|
{isLoading ? "저장 중..." : "저장"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
|