580 lines
21 KiB
TypeScript
580 lines
21 KiB
TypeScript
"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 { Trash2, Plus, ArrowRight, Save, RefreshCw } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
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 [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 [loading, setLoading] = useState(false);
|
|
|
|
// 초기 데이터 로드
|
|
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;
|
|
}
|
|
|
|
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);
|
|
|
|
} catch (error) {
|
|
console.error("배치 설정 저장 오류:", error);
|
|
toast.error("배치 설정 저장에 실패했습니다.");
|
|
} 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;
|
|
} else {
|
|
return mapping.to_connection_type === connectionType &&
|
|
mapping.to_connection_id === connectionId &&
|
|
mapping.to_table_name === tableName &&
|
|
mapping.to_column_name === columnName;
|
|
}
|
|
});
|
|
};
|
|
|
|
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>
|
|
|
|
{/* 기본 설정 섹션 */}
|
|
<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>
|
|
<Input
|
|
id="batchName"
|
|
value={batchName}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setBatchName(e.target.value)}
|
|
placeholder="예: 인사정보 동기화 배치"
|
|
/>
|
|
</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}
|
|
/>
|
|
</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}
|
|
>
|
|
<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>
|
|
)}
|
|
</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>
|
|
</div>
|
|
))}
|
|
</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>
|
|
</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>
|
|
</div>
|
|
);
|
|
}
|