feat: 배치 관리 시스템 구현

 주요 기능:
- 배치 설정 관리 (생성/수정/삭제/실행)
- 배치 실행 로그 관리 및 모니터링
- 배치 스케줄러 자동 실행 (cron 기반)
- 외부 DB 연결을 통한 데이터 동기화
- Oracle, MSSQL, MariaDB 커넥터 지원

🔧 백엔드 구현:
- BatchManagementController: 배치 설정 CRUD
- BatchExecutionLogController: 실행 로그 관리
- BatchSchedulerService: 자동 스케줄링
- BatchExternalDbService: 외부 DB 연동
- 배치 관련 테이블 스키마 추가

🎨 프론트엔드 구현:
- 배치 관리 대시보드 UI
- 배치 생성/수정 폼
- 실행 로그 모니터링 화면
- 수동 실행 및 상태 관리

🛡️ 안전성:
- 기존 시스템과 독립적 구현
- 트랜잭션 기반 안전한 데이터 처리
- 에러 핸들링 및 로깅 강화
This commit is contained in:
2025-09-25 11:04:16 +09:00
parent 4abf5b31c0
commit 949aab0b73
33 changed files with 8549 additions and 961 deletions

View 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>
);
}

View File

@@ -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>
);
}
}