배치관리 중간커밋
This commit is contained in:
579
frontend/app/(main)/admin/batchmng/page.tsx
Normal file
579
frontend/app/(main)/admin/batchmng/page.tsx
Normal file
@@ -0,0 +1,579 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user