feat: 배치 관리 시스템 구현
✨ 주요 기능: - 배치 설정 관리 (생성/수정/삭제/실행) - 배치 실행 로그 관리 및 모니터링 - 배치 스케줄러 자동 실행 (cron 기반) - 외부 DB 연결을 통한 데이터 동기화 - Oracle, MSSQL, MariaDB 커넥터 지원 🔧 백엔드 구현: - BatchManagementController: 배치 설정 CRUD - BatchExecutionLogController: 실행 로그 관리 - BatchSchedulerService: 자동 스케줄링 - BatchExternalDbService: 외부 DB 연동 - 배치 관련 테이블 스키마 추가 🎨 프론트엔드 구현: - 배치 관리 대시보드 UI - 배치 생성/수정 폼 - 실행 로그 모니터링 화면 - 수동 실행 및 상태 관리 🛡️ 안전성: - 기존 시스템과 독립적 구현 - 트랜잭션 기반 안전한 데이터 처리 - 에러 핸들링 및 로깅 강화
This commit is contained in:
544
frontend/app/(main)/admin/batchmng/create/page.tsx
Normal file
544
frontend/app/(main)/admin/batchmng/create/page.tsx
Normal file
@@ -0,0 +1,544 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ArrowLeft, Save, RefreshCw, ArrowRight, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
BatchAPI,
|
||||
BatchMapping,
|
||||
ConnectionInfo,
|
||||
ColumnInfo,
|
||||
BatchMappingRequest,
|
||||
} from "@/lib/api/batch";
|
||||
|
||||
export default function BatchCreatePage() {
|
||||
const router = useRouter();
|
||||
|
||||
// 기본 정보
|
||||
const [batchName, setBatchName] = useState("");
|
||||
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// 커넥션 및 데이터
|
||||
const [connections, setConnections] = useState<ConnectionInfo[]>([]);
|
||||
const [fromConnection, setFromConnection] = useState<ConnectionInfo | null>(null);
|
||||
const [toConnection, setToConnection] = useState<ConnectionInfo | null>(null);
|
||||
const [fromTables, setFromTables] = useState<string[]>([]);
|
||||
const [toTables, setToTables] = useState<string[]>([]);
|
||||
const [fromTable, setFromTable] = useState("");
|
||||
const [toTable, setToTable] = useState("");
|
||||
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
||||
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
||||
|
||||
// 매핑 상태
|
||||
const [selectedFromColumn, setSelectedFromColumn] = useState<ColumnInfo | null>(null);
|
||||
const [mappings, setMappings] = useState<BatchMapping[]>([]);
|
||||
|
||||
// 로딩 상태
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingConnections, setLoadingConnections] = useState(false);
|
||||
|
||||
// 커넥션 목록 로드
|
||||
useEffect(() => {
|
||||
loadConnections();
|
||||
}, []);
|
||||
|
||||
const loadConnections = async () => {
|
||||
setLoadingConnections(true);
|
||||
try {
|
||||
const data = await BatchAPI.getConnections();
|
||||
setConnections(Array.isArray(data) ? data : []);
|
||||
} catch (error) {
|
||||
console.error("커넥션 로드 실패:", error);
|
||||
toast.error("커넥션 목록을 불러오는데 실패했습니다.");
|
||||
setConnections([]);
|
||||
} finally {
|
||||
setLoadingConnections(false);
|
||||
}
|
||||
};
|
||||
|
||||
// FROM 커넥션 변경
|
||||
const handleFromConnectionChange = async (connectionId: string) => {
|
||||
if (connectionId === 'unknown') return;
|
||||
|
||||
const connection = connections.find(conn => {
|
||||
if (conn.type === 'internal') {
|
||||
return connectionId === 'internal';
|
||||
}
|
||||
return conn.id ? conn.id.toString() === connectionId : false;
|
||||
});
|
||||
|
||||
if (!connection) return;
|
||||
|
||||
setFromConnection(connection);
|
||||
setFromTable("");
|
||||
setFromTables([]);
|
||||
setFromColumns([]);
|
||||
setSelectedFromColumn(null);
|
||||
|
||||
try {
|
||||
const tables = await BatchAPI.getTablesFromConnection(connection.type, connection.id);
|
||||
setFromTables(Array.isArray(tables) ? tables : []);
|
||||
} catch (error) {
|
||||
console.error("FROM 테이블 목록 로드 실패:", error);
|
||||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// TO 커넥션 변경
|
||||
const handleToConnectionChange = async (connectionId: string) => {
|
||||
if (connectionId === 'unknown') return;
|
||||
|
||||
const connection = connections.find(conn => {
|
||||
if (conn.type === 'internal') {
|
||||
return connectionId === 'internal';
|
||||
}
|
||||
return conn.id ? conn.id.toString() === connectionId : false;
|
||||
});
|
||||
|
||||
if (!connection) return;
|
||||
|
||||
setToConnection(connection);
|
||||
setToTable("");
|
||||
setToTables([]);
|
||||
setToColumns([]);
|
||||
|
||||
try {
|
||||
const tables = await BatchAPI.getTablesFromConnection(connection.type, connection.id);
|
||||
setToTables(Array.isArray(tables) ? tables : []);
|
||||
} catch (error) {
|
||||
console.error("TO 테이블 목록 로드 실패:", error);
|
||||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// FROM 테이블 변경
|
||||
const handleFromTableChange = async (tableName: string) => {
|
||||
setFromTable(tableName);
|
||||
setFromColumns([]);
|
||||
setSelectedFromColumn(null);
|
||||
|
||||
if (!fromConnection || !tableName) return;
|
||||
|
||||
try {
|
||||
const columns = await BatchAPI.getTableColumns(fromConnection.type, fromConnection.id, tableName);
|
||||
setFromColumns(Array.isArray(columns) ? columns : []);
|
||||
} catch (error) {
|
||||
console.error("FROM 컬럼 목록 로드 실패:", error);
|
||||
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// TO 테이블 변경
|
||||
const handleToTableChange = async (tableName: string) => {
|
||||
setToTable(tableName);
|
||||
setToColumns([]);
|
||||
|
||||
if (!toConnection || !tableName) return;
|
||||
|
||||
try {
|
||||
const columns = await BatchAPI.getTableColumns(toConnection.type, toConnection.id, tableName);
|
||||
setToColumns(Array.isArray(columns) ? columns : []);
|
||||
} catch (error) {
|
||||
console.error("TO 컬럼 목록 로드 실패:", error);
|
||||
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// FROM 컬럼 선택
|
||||
const handleFromColumnClick = (column: ColumnInfo) => {
|
||||
setSelectedFromColumn(column);
|
||||
toast.info(`FROM 컬럼 선택됨: ${column.column_name}`);
|
||||
};
|
||||
|
||||
// TO 컬럼 선택 (매핑 생성)
|
||||
const handleToColumnClick = (toColumn: ColumnInfo) => {
|
||||
if (!selectedFromColumn || !fromConnection || !toConnection) {
|
||||
toast.error("먼저 FROM 컬럼을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// n:1 매핑 검사
|
||||
const toKey = `${toConnection.type}:${toConnection.id || 'internal'}:${toTable}:${toColumn.column_name}`;
|
||||
const existingMapping = mappings.find(mapping => {
|
||||
const existingToKey = `${mapping.to_connection_type}:${mapping.to_connection_id || 'internal'}:${mapping.to_table_name}:${mapping.to_column_name}`;
|
||||
return existingToKey === toKey;
|
||||
});
|
||||
|
||||
if (existingMapping) {
|
||||
toast.error("동일한 TO 컬럼에 중복 매핑할 수 없습니다. (n:1 매핑 방지)");
|
||||
return;
|
||||
}
|
||||
|
||||
const newMapping: BatchMapping = {
|
||||
from_connection_type: fromConnection.type,
|
||||
from_connection_id: fromConnection.id || null,
|
||||
from_table_name: fromTable,
|
||||
from_column_name: selectedFromColumn.column_name,
|
||||
from_column_type: selectedFromColumn.data_type || '',
|
||||
to_connection_type: toConnection.type,
|
||||
to_connection_id: toConnection.id || null,
|
||||
to_table_name: toTable,
|
||||
to_column_name: toColumn.column_name,
|
||||
to_column_type: toColumn.data_type || '',
|
||||
mapping_order: mappings.length + 1,
|
||||
};
|
||||
|
||||
setMappings([...mappings, newMapping]);
|
||||
setSelectedFromColumn(null);
|
||||
toast.success(`매핑 생성: ${selectedFromColumn.column_name} → ${toColumn.column_name}`);
|
||||
};
|
||||
|
||||
// 매핑 삭제
|
||||
const removeMapping = (index: number) => {
|
||||
const newMappings = mappings.filter((_, i) => i !== index);
|
||||
const reorderedMappings = newMappings.map((mapping, i) => ({
|
||||
...mapping,
|
||||
mapping_order: i + 1
|
||||
}));
|
||||
setMappings(reorderedMappings);
|
||||
toast.success("매핑이 삭제되었습니다.");
|
||||
};
|
||||
|
||||
// 배치 설정 저장
|
||||
const saveBatchConfig = async () => {
|
||||
if (!batchName.trim()) {
|
||||
toast.error("배치명을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cronSchedule.trim()) {
|
||||
toast.error("실행 스케줄을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappings.length === 0) {
|
||||
toast.error("최소 하나 이상의 매핑을 추가해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const request = {
|
||||
batchName: batchName,
|
||||
description: description || undefined,
|
||||
cronSchedule: cronSchedule,
|
||||
mappings: mappings,
|
||||
isActive: true
|
||||
};
|
||||
|
||||
await BatchAPI.createBatchConfig(request);
|
||||
toast.success("배치 설정이 성공적으로 저장되었습니다!");
|
||||
|
||||
// 목록 페이지로 이동
|
||||
router.push("/admin/batchmng");
|
||||
} catch (error) {
|
||||
console.error("배치 설정 저장 실패:", error);
|
||||
toast.error("배치 설정 저장에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/admin/batchmng")}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span>목록으로</span>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">배치관리 매핑 시스템</h1>
|
||||
<p className="text-muted-foreground">새로운 배치 매핑을 생성합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batchName">배치명 *</Label>
|
||||
<Input
|
||||
id="batchName"
|
||||
value={batchName}
|
||||
onChange={(e) => setBatchName(e.target.value)}
|
||||
placeholder="배치명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cronSchedule">실행 스케줄 (Cron) *</Label>
|
||||
<Input
|
||||
id="cronSchedule"
|
||||
value={cronSchedule}
|
||||
onChange={(e) => setCronSchedule(e.target.value)}
|
||||
placeholder="0 12 * * * (매일 12시)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="배치에 대한 설명을 입력하세요"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 매핑 설정 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* FROM 섹션 */}
|
||||
<Card className="border-green-200">
|
||||
<CardHeader className="bg-green-50">
|
||||
<CardTitle className="text-green-700">FROM (원본 데이터베이스)</CardTitle>
|
||||
<p className="text-sm text-green-600">
|
||||
1단계: 커넥션을 선택하세요 → 2단계: 테이블을 선택하세요 → 3단계: 컬럼을 클릭해서 매핑하세요
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>커넥션 선택</Label>
|
||||
<Select
|
||||
value={fromConnection?.type === 'internal' ? 'internal' : fromConnection?.id?.toString() || ""}
|
||||
onValueChange={handleFromConnectionChange}
|
||||
disabled={loadingConnections}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.isArray(connections) && connections.map((conn) => (
|
||||
<SelectItem
|
||||
key={conn.type === 'internal' ? 'internal' : conn.id?.toString() || conn.name}
|
||||
value={conn.type === 'internal' ? 'internal' : conn.id?.toString() || 'unknown'}
|
||||
>
|
||||
{conn.name} ({conn.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 선택</Label>
|
||||
<Select
|
||||
value={fromTable}
|
||||
onValueChange={handleFromTableChange}
|
||||
disabled={!fromConnection}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fromTables.map((table) => (
|
||||
<SelectItem key={table} value={table}>
|
||||
{table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* FROM 컬럼 목록 */}
|
||||
{fromTable && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-blue-600 font-semibold">{fromTable} 테이블</Label>
|
||||
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2">
|
||||
{fromColumns.map((column) => (
|
||||
<div
|
||||
key={column.column_name}
|
||||
onClick={() => handleFromColumnClick(column)}
|
||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedFromColumn?.column_name === column.column_name
|
||||
? 'bg-green-100 border-green-300'
|
||||
: 'hover:bg-gray-50 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{column.column_name}</div>
|
||||
<div className="text-sm text-gray-500">{column.data_type}</div>
|
||||
</div>
|
||||
))}
|
||||
{fromColumns.length === 0 && fromTable && (
|
||||
<div className="text-center text-gray-500 py-4">
|
||||
컬럼을 불러오는 중...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* TO 섹션 */}
|
||||
<Card className="border-red-200">
|
||||
<CardHeader className="bg-red-50">
|
||||
<CardTitle className="text-red-700">TO (대상 데이터베이스)</CardTitle>
|
||||
<p className="text-sm text-red-600">
|
||||
FROM에서 컬럼을 선택한 후, 여기서 대상 컬럼을 클릭하면 매핑됩니다
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>커넥션 선택</Label>
|
||||
<Select
|
||||
value={toConnection?.type === 'internal' ? 'internal' : toConnection?.id?.toString() || ""}
|
||||
onValueChange={handleToConnectionChange}
|
||||
disabled={loadingConnections}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.isArray(connections) && connections.map((conn) => (
|
||||
<SelectItem
|
||||
key={conn.type === 'internal' ? 'internal' : conn.id?.toString() || conn.name}
|
||||
value={conn.type === 'internal' ? 'internal' : conn.id?.toString() || 'unknown'}
|
||||
>
|
||||
{conn.name} ({conn.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 선택</Label>
|
||||
<Select
|
||||
value={toTable}
|
||||
onValueChange={handleToTableChange}
|
||||
disabled={!toConnection}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{toTables.map((table) => (
|
||||
<SelectItem key={table} value={table}>
|
||||
{table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* TO 컬럼 목록 */}
|
||||
{toTable && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-blue-600 font-semibold">{toTable} 테이블</Label>
|
||||
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2">
|
||||
{toColumns.map((column) => (
|
||||
<div
|
||||
key={column.column_name}
|
||||
onClick={() => handleToColumnClick(column)}
|
||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedFromColumn
|
||||
? 'hover:bg-red-50 border-gray-200'
|
||||
: 'bg-gray-100 border-gray-300 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{column.column_name}</div>
|
||||
<div className="text-sm text-gray-500">{column.data_type}</div>
|
||||
</div>
|
||||
))}
|
||||
{toColumns.length === 0 && toTable && (
|
||||
<div className="text-center text-gray-500 py-4">
|
||||
컬럼을 불러오는 중...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 매핑 현황 */}
|
||||
{mappings.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>컬럼 매핑 현황 ({mappings.length}개)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{mappings.map((mapping, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-4 border rounded-lg bg-yellow-50">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{mapping.from_table_name}.{mapping.from_column_name}
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
{mapping.from_column_type}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{mapping.to_table_name}.{mapping.to_column_name}
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
{mapping.to_column_type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeMapping(index)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/admin/batchmng")}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveBatchConfig}
|
||||
disabled={loading || mappings.length === 0}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
<span>{loading ? "저장 중..." : "배치 매핑 저장"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,576 +4,353 @@ import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Trash2, Plus, ArrowRight, Save, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Play,
|
||||
Pause,
|
||||
Edit,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Database,
|
||||
ArrowRight
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
BatchAPI,
|
||||
BatchConfig,
|
||||
BatchMapping,
|
||||
ConnectionInfo,
|
||||
ColumnInfo,
|
||||
BatchMappingRequest,
|
||||
} from "@/lib/api/batch";
|
||||
|
||||
interface MappingState {
|
||||
from: {
|
||||
connection: ConnectionInfo | null;
|
||||
table: string;
|
||||
column: ColumnInfo | null;
|
||||
} | null;
|
||||
to: {
|
||||
connection: ConnectionInfo | null;
|
||||
table: string;
|
||||
column: ColumnInfo | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function BatchManagementPage() {
|
||||
// 기본 상태
|
||||
const [batchName, setBatchName] = useState("");
|
||||
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
|
||||
const [description, setDescription] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
// 커넥션 및 테이블 데이터
|
||||
const [connections, setConnections] = useState<ConnectionInfo[]>([]);
|
||||
const [fromTables, setFromTables] = useState<string[]>([]);
|
||||
const [toTables, setToTables] = useState<string[]>([]);
|
||||
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
||||
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
||||
|
||||
// 선택된 상태
|
||||
const [fromConnection, setFromConnection] = useState<ConnectionInfo | null>(null);
|
||||
const [toConnection, setToConnection] = useState<ConnectionInfo | null>(null);
|
||||
const [fromTable, setFromTable] = useState("");
|
||||
const [toTable, setToTable] = useState("");
|
||||
const [selectedFromColumn, setSelectedFromColumn] = useState<ColumnInfo | null>(null);
|
||||
|
||||
// 매핑 상태
|
||||
const [mappings, setMappings] = useState<BatchMapping[]>([]);
|
||||
// 상태 관리
|
||||
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
|
||||
|
||||
// 초기 데이터 로드
|
||||
// 페이지 로드 시 배치 목록 조회
|
||||
useEffect(() => {
|
||||
loadConnections();
|
||||
}, []);
|
||||
|
||||
// 커넥션 목록 로드
|
||||
const loadConnections = async () => {
|
||||
try {
|
||||
const data = await BatchAPI.getAvailableConnections();
|
||||
setConnections(data);
|
||||
} catch (error) {
|
||||
console.error("커넥션 목록 로드 오류:", error);
|
||||
toast.error("커넥션 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// FROM 커넥션 변경 시 테이블 로드
|
||||
const handleFromConnectionChange = async (connectionId: string) => {
|
||||
const connection = connections.find((c: ConnectionInfo) =>
|
||||
c.type === 'internal' ? c.type === connectionId : c.id?.toString() === connectionId
|
||||
);
|
||||
|
||||
if (!connection) return;
|
||||
|
||||
setFromConnection(connection);
|
||||
setFromTable("");
|
||||
setFromColumns([]);
|
||||
setSelectedFromColumn(null);
|
||||
|
||||
try {
|
||||
const tables = await BatchAPI.getTablesFromConnection(
|
||||
connection.type,
|
||||
connection.id
|
||||
);
|
||||
setFromTables(tables);
|
||||
} catch (error) {
|
||||
console.error("FROM 테이블 목록 로드 오류:", error);
|
||||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// TO 커넥션 변경 시 테이블 로드
|
||||
const handleToConnectionChange = async (connectionId: string) => {
|
||||
const connection = connections.find((c: ConnectionInfo) =>
|
||||
c.type === 'internal' ? c.type === connectionId : c.id?.toString() === connectionId
|
||||
);
|
||||
|
||||
if (!connection) return;
|
||||
|
||||
setToConnection(connection);
|
||||
setToTable("");
|
||||
setToColumns([]);
|
||||
|
||||
try {
|
||||
const tables = await BatchAPI.getTablesFromConnection(
|
||||
connection.type,
|
||||
connection.id
|
||||
);
|
||||
setToTables(tables);
|
||||
} catch (error) {
|
||||
console.error("TO 테이블 목록 로드 오류:", error);
|
||||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// FROM 테이블 변경 시 컬럼 로드
|
||||
const handleFromTableChange = async (tableName: string) => {
|
||||
if (!fromConnection) return;
|
||||
|
||||
setFromTable(tableName);
|
||||
setSelectedFromColumn(null);
|
||||
|
||||
try {
|
||||
const columns = await BatchAPI.getTableColumns(
|
||||
fromConnection.type,
|
||||
tableName,
|
||||
fromConnection.id
|
||||
);
|
||||
setFromColumns(columns);
|
||||
} catch (error) {
|
||||
console.error("FROM 컬럼 목록 로드 오류:", error);
|
||||
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// TO 테이블 변경 시 컬럼 로드
|
||||
const handleToTableChange = async (tableName: string) => {
|
||||
if (!toConnection) return;
|
||||
|
||||
setToTable(tableName);
|
||||
|
||||
try {
|
||||
const columns = await BatchAPI.getTableColumns(
|
||||
toConnection.type,
|
||||
tableName,
|
||||
toConnection.id
|
||||
);
|
||||
setToColumns(columns);
|
||||
} catch (error) {
|
||||
console.error("TO 컬럼 목록 로드 오류:", error);
|
||||
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// FROM 컬럼 선택
|
||||
const handleFromColumnClick = (column: ColumnInfo) => {
|
||||
setSelectedFromColumn(column);
|
||||
};
|
||||
|
||||
// TO 컬럼 클릭으로 매핑 생성
|
||||
const handleToColumnClick = (column: ColumnInfo) => {
|
||||
if (!selectedFromColumn || !fromConnection || !toConnection) {
|
||||
toast.error("먼저 FROM 컬럼을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// n:1 매핑 검사 (같은 TO 컬럼에 여러 FROM이 매핑되는 것 방지)
|
||||
const existingToMapping = mappings.find((m: BatchMapping) =>
|
||||
m.to_connection_type === toConnection.type &&
|
||||
m.to_connection_id === toConnection.id &&
|
||||
m.to_table_name === toTable &&
|
||||
m.to_column_name === column.column_name
|
||||
);
|
||||
|
||||
if (existingToMapping) {
|
||||
toast.error("해당 TO 컬럼에는 이미 매핑이 존재합니다. n:1 매핑은 허용되지 않습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 새 매핑 생성
|
||||
const newMapping: BatchMapping = {
|
||||
from_connection_type: fromConnection.type,
|
||||
from_connection_id: fromConnection.id,
|
||||
from_table_name: fromTable,
|
||||
from_column_name: selectedFromColumn.column_name,
|
||||
from_column_type: selectedFromColumn.data_type,
|
||||
to_connection_type: toConnection.type,
|
||||
to_connection_id: toConnection.id,
|
||||
to_table_name: toTable,
|
||||
to_column_name: column.column_name,
|
||||
to_column_type: column.data_type,
|
||||
mapping_order: mappings.length + 1,
|
||||
};
|
||||
|
||||
setMappings([...mappings, newMapping]);
|
||||
setSelectedFromColumn(null);
|
||||
toast.success("매핑이 추가되었습니다.");
|
||||
};
|
||||
|
||||
// 매핑 삭제
|
||||
const removeMapping = (index: number) => {
|
||||
const newMappings = mappings.filter((_: BatchMapping, i: number) => i !== index);
|
||||
setMappings(newMappings);
|
||||
toast.success("매핑이 삭제되었습니다.");
|
||||
};
|
||||
|
||||
// 배치 설정 저장
|
||||
const saveBatchConfig = async () => {
|
||||
if (!batchName.trim()) {
|
||||
toast.error("배치명을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cronSchedule.trim()) {
|
||||
toast.error("실행주기를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappings.length === 0) {
|
||||
toast.error("최소 하나 이상의 컬럼 매핑을 설정해주세요.");
|
||||
return;
|
||||
}
|
||||
loadBatchConfigs();
|
||||
}, [currentPage, searchTerm]);
|
||||
|
||||
// 배치 설정 목록 조회
|
||||
const loadBatchConfigs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const request: BatchMappingRequest = {
|
||||
batch_name: batchName,
|
||||
description: description || undefined,
|
||||
cron_schedule: cronSchedule,
|
||||
mappings: mappings,
|
||||
};
|
||||
|
||||
await BatchAPI.createBatchConfig(request);
|
||||
toast.success("배치 설정이 성공적으로 저장되었습니다!");
|
||||
|
||||
// 폼 초기화
|
||||
setBatchName("");
|
||||
setDescription("");
|
||||
setCronSchedule("0 12 * * *");
|
||||
setMappings([]);
|
||||
setFromConnection(null);
|
||||
setToConnection(null);
|
||||
setFromTable("");
|
||||
setToTable("");
|
||||
setFromTables([]);
|
||||
setToTables([]);
|
||||
setFromColumns([]);
|
||||
setToColumns([]);
|
||||
setSelectedFromColumn(null);
|
||||
const response = await BatchAPI.getBatchConfigs({
|
||||
page: currentPage,
|
||||
limit: 10,
|
||||
search: searchTerm || undefined,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
setBatchConfigs(response.data);
|
||||
if (response.pagination) {
|
||||
setTotalPages(response.pagination.totalPages);
|
||||
}
|
||||
} else {
|
||||
setBatchConfigs([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 설정 저장 오류:", error);
|
||||
toast.error("배치 설정 저장에 실패했습니다.");
|
||||
console.error("배치 목록 조회 실패:", error);
|
||||
toast.error("배치 목록을 불러오는데 실패했습니다.");
|
||||
setBatchConfigs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 컬럼이 매핑되었는지 확인
|
||||
const isColumnMapped = (
|
||||
connectionType: 'internal' | 'external',
|
||||
connectionId: number | undefined,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
side: 'from' | 'to'
|
||||
) => {
|
||||
return mappings.some((mapping: BatchMapping) => {
|
||||
if (side === 'from') {
|
||||
return mapping.from_connection_type === connectionType &&
|
||||
mapping.from_connection_id === connectionId &&
|
||||
mapping.from_table_name === tableName &&
|
||||
mapping.from_column_name === columnName;
|
||||
// 배치 수동 실행
|
||||
const executeBatch = async (batchId: number) => {
|
||||
setExecutingBatch(batchId);
|
||||
try {
|
||||
const response = await BatchAPI.executeBatchConfig(batchId);
|
||||
if (response.success) {
|
||||
toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`);
|
||||
} else {
|
||||
return mapping.to_connection_type === connectionType &&
|
||||
mapping.to_connection_id === connectionId &&
|
||||
mapping.to_table_name === tableName &&
|
||||
mapping.to_column_name === columnName;
|
||||
toast.error("배치 실행에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 실행 실패:", error);
|
||||
toast.error("배치 실행 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setExecutingBatch(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 배치 활성화/비활성화 토글
|
||||
const toggleBatchStatus = async (batchId: number, currentStatus: string) => {
|
||||
try {
|
||||
const newStatus = currentStatus === 'Y' ? 'N' : 'Y';
|
||||
await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus });
|
||||
toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`);
|
||||
loadBatchConfigs(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error("배치 상태 변경 실패:", error);
|
||||
toast.error("배치 상태 변경에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 배치 삭제
|
||||
const deleteBatch = async (batchId: number, batchName: string) => {
|
||||
if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await BatchAPI.deleteBatchConfig(batchId);
|
||||
toast.success("배치가 삭제되었습니다.");
|
||||
loadBatchConfigs(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error("배치 삭제 실패:", error);
|
||||
toast.error("배치 삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 검색 처리
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1); // 검색 시 첫 페이지로 이동
|
||||
};
|
||||
|
||||
// 매핑 정보 요약 생성
|
||||
const getMappingSummary = (mappings: BatchMapping[]) => {
|
||||
if (!mappings || mappings.length === 0) {
|
||||
return "매핑 없음";
|
||||
}
|
||||
|
||||
const tableGroups = new Map<string, number>();
|
||||
mappings.forEach(mapping => {
|
||||
const key = `${mapping.from_table_name} → ${mapping.to_table_name}`;
|
||||
tableGroups.set(key, (tableGroups.get(key) || 0) + 1);
|
||||
});
|
||||
|
||||
const summaries = Array.from(tableGroups.entries()).map(([key, count]) =>
|
||||
`${key} (${count}개 컬럼)`
|
||||
);
|
||||
|
||||
return summaries.join(", ");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
배치관리 매핑 시스템
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">배치 관리</h1>
|
||||
<p className="text-muted-foreground">데이터베이스 간 배치 작업을 관리합니다.</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push("/admin/batchmng/create")}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>배치 추가</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 기본 설정 섹션 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="bg-gray-50">
|
||||
<CardTitle>기본 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="batchName">배치명 *</Label>
|
||||
{/* 검색 및 필터 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
id="batchName"
|
||||
value={batchName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setBatchName(e.target.value)}
|
||||
placeholder="예: 인사정보 동기화 배치"
|
||||
placeholder="배치명 또는 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="cronSchedule">실행주기 (크론탭 형식) *</Label>
|
||||
<Input
|
||||
id="cronSchedule"
|
||||
value={cronSchedule}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCronSchedule(e.target.value)}
|
||||
placeholder="예: 0 12 * * * (매일 12시)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">비고</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value)}
|
||||
placeholder="배치에 대한 설명을 입력하세요..."
|
||||
rows={3}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadBatchConfigs}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
<span>새로고침</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 매핑 설정 섹션 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
{/* FROM 섹션 */}
|
||||
<Card>
|
||||
<CardHeader className="bg-green-500 text-white">
|
||||
<CardTitle>FROM (원본 데이터베이스)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded p-3 mb-4 text-sm text-blue-800">
|
||||
1단계: 커넥션 선택 → 2단계: 테이블 선택 → 3단계: 컬럼 클릭하여 선택
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>커넥션 선택</Label>
|
||||
<Select onValueChange={handleFromConnectionChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.map((conn: ConnectionInfo) => (
|
||||
<SelectItem
|
||||
key={conn.type === 'internal' ? 'internal' : conn.id}
|
||||
value={conn.type === 'internal' ? 'internal' : conn.id!.toString()}
|
||||
>
|
||||
{conn.name} ({conn.db_type?.toUpperCase()})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>테이블 선택</Label>
|
||||
<Select
|
||||
value={fromTable}
|
||||
onValueChange={handleFromTableChange}
|
||||
disabled={!fromConnection}
|
||||
{/* 배치 목록 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>배치 목록 ({batchConfigs.length}개)</span>
|
||||
{loading && <RefreshCw className="h-4 w-4 animate-spin" />}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{batchConfigs.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">배치가 없습니다</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<Button
|
||||
onClick={() => router.push("/admin/batchmng/create")}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={fromConnection ? "테이블을 선택하세요" : "먼저 커넥션을 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fromTables.map((table: string) => (
|
||||
<SelectItem key={table} value={table}>
|
||||
{table.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{fromTable && fromColumns.length > 0 && (
|
||||
<div>
|
||||
<div className="bg-gray-50 border rounded p-4">
|
||||
<h4 className="font-semibold text-blue-600 mb-3">
|
||||
{fromTable.toUpperCase()} 테이블
|
||||
</h4>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{fromColumns.map((column: ColumnInfo) => (
|
||||
<div
|
||||
key={column.column_name}
|
||||
className={`p-3 border-2 rounded cursor-pointer transition-all ${
|
||||
selectedFromColumn?.column_name === column.column_name
|
||||
? 'border-blue-500 bg-blue-50 font-semibold'
|
||||
: isColumnMapped(
|
||||
fromConnection!.type,
|
||||
fromConnection!.id,
|
||||
fromTable,
|
||||
column.column_name,
|
||||
'from'
|
||||
)
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-sm'
|
||||
}`}
|
||||
onClick={() => handleFromColumnClick(column)}
|
||||
>
|
||||
<div>{column.column_name}</div>
|
||||
<div className="text-xs text-gray-500 italic">
|
||||
{column.data_type}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>첫 번째 배치 추가</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* TO 섹션 */}
|
||||
<Card>
|
||||
<CardHeader className="bg-red-500 text-white">
|
||||
<CardTitle>TO (대상 데이터베이스)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4 text-sm text-yellow-800">
|
||||
FROM에서 컬럼을 선택한 후, 여기서 대상 컬럼을 클릭하면 매핑됩니다
|
||||
</div>
|
||||
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>커넥션 선택</Label>
|
||||
<Select onValueChange={handleToConnectionChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.map((conn: ConnectionInfo) => (
|
||||
<SelectItem
|
||||
key={conn.type === 'internal' ? 'internal' : conn.id}
|
||||
value={conn.type === 'internal' ? 'internal' : conn.id!.toString()}
|
||||
>
|
||||
{conn.name} ({conn.db_type?.toUpperCase()})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>테이블 선택</Label>
|
||||
<Select
|
||||
value={toTable}
|
||||
onValueChange={handleToTableChange}
|
||||
disabled={!toConnection}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={toConnection ? "테이블을 선택하세요" : "먼저 커넥션을 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{toTables.map((table: string) => (
|
||||
<SelectItem key={table} value={table}>
|
||||
{table.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{toTable && toColumns.length > 0 && (
|
||||
<div>
|
||||
<div className="bg-gray-50 border rounded p-4">
|
||||
<h4 className="font-semibold text-red-600 mb-3">
|
||||
{toTable.toUpperCase()} 테이블
|
||||
</h4>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{toColumns.map((column: ColumnInfo) => (
|
||||
<div
|
||||
key={column.column_name}
|
||||
className={`p-3 border-2 rounded cursor-pointer transition-all ${
|
||||
isColumnMapped(
|
||||
toConnection!.type,
|
||||
toConnection!.id,
|
||||
toTable,
|
||||
column.column_name,
|
||||
'to'
|
||||
)
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-gray-200 bg-white hover:border-red-300 hover:shadow-sm'
|
||||
}`}
|
||||
onClick={() => handleToColumnClick(column)}
|
||||
>
|
||||
<div>{column.column_name}</div>
|
||||
<div className="text-xs text-gray-500 italic">
|
||||
{column.data_type}
|
||||
</div>
|
||||
{batchConfigs.map((batch) => (
|
||||
<div key={batch.id} className="border rounded-lg p-6 space-y-4">
|
||||
{/* 배치 기본 정보 */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="text-lg font-semibold">{batch.batch_name}</h3>
|
||||
<Badge variant={batch.is_active === 'Y' ? 'default' : 'secondary'}>
|
||||
{batch.is_active === 'Y' ? '활성' : '비활성'}
|
||||
</Badge>
|
||||
</div>
|
||||
{batch.description && (
|
||||
<p className="text-muted-foreground">{batch.description}</p>
|
||||
)}
|
||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{batch.cron_schedule}</span>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
생성일: {new Date(batch.created_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => executeBatch(batch.id)}
|
||||
disabled={executingBatch === batch.id}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
{executingBatch === batch.id ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
<span>실행</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleBatchStatus(batch.id, batch.is_active)}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
{batch.is_active === 'Y' ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
<span>{batch.is_active === 'Y' ? '비활성화' : '활성화'}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/admin/batchmng/edit/${batch.id}`)}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span>수정</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => deleteBatch(batch.id, batch.batch_name)}
|
||||
className="flex items-center space-x-1 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>삭제</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 매핑 현황 섹션 */}
|
||||
{mappings.length > 0 && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="bg-yellow-100 border-b">
|
||||
<CardTitle className="text-lg">컬럼 매핑 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-3">
|
||||
{mappings.map((mapping: BatchMapping, index: number) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 border rounded bg-gray-50">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm">
|
||||
<Badge className="mr-2 border">FROM</Badge>
|
||||
{mapping.from_table_name}.{mapping.from_column_name}
|
||||
<span className="text-gray-500 ml-1">({mapping.from_column_type})</span>
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm">
|
||||
<Badge className="mr-2 border">TO</Badge>
|
||||
{mapping.to_table_name}.{mapping.to_column_name}
|
||||
<span className="text-gray-500 ml-1">({mapping.to_column_type})</span>
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removeMapping(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* 매핑 정보 */}
|
||||
{batch.batch_mappings && batch.batch_mappings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
매핑 정보 ({batch.batch_mappings.length}개)
|
||||
</h4>
|
||||
<div className="text-sm">
|
||||
{getMappingSummary(batch.batch_mappings)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<Button
|
||||
onClick={saveBatchConfig}
|
||||
disabled={loading}
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white py-3 text-lg font-semibold"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-5 w-5 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-5 w-5" />
|
||||
배치 매핑 저장
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={currentPage === pageNum ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user