508 lines
17 KiB
TypeScript
508 lines
17 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 {
|
|
BatchManagementAPI,
|
|
BatchConnectionInfo,
|
|
BatchColumnInfo,
|
|
} from "@/lib/api/batchManagement";
|
|
|
|
interface MappingState {
|
|
from: {
|
|
connection: BatchConnectionInfo | null;
|
|
table: string;
|
|
column: BatchColumnInfo | null;
|
|
} | null;
|
|
to: {
|
|
connection: BatchConnectionInfo | null;
|
|
table: string;
|
|
column: BatchColumnInfo | null;
|
|
} | null;
|
|
}
|
|
|
|
export default function BatchManagementNewPage() {
|
|
// 기본 상태
|
|
const [batchName, setBatchName] = useState("");
|
|
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
|
|
const [description, setDescription] = useState("");
|
|
|
|
// 커넥션 및 테이블 데이터
|
|
const [connections, setConnections] = useState<BatchConnectionInfo[]>([]);
|
|
const [fromTables, setFromTables] = useState<string[]>([]);
|
|
const [toTables, setToTables] = useState<string[]>([]);
|
|
const [fromColumns, setFromColumns] = useState<BatchColumnInfo[]>([]);
|
|
const [toColumns, setToColumns] = useState<BatchColumnInfo[]>([]);
|
|
|
|
// 선택된 상태
|
|
const [fromConnection, setFromConnection] = useState<BatchConnectionInfo | null>(null);
|
|
const [toConnection, setToConnection] = useState<BatchConnectionInfo | null>(null);
|
|
const [fromTable, setFromTable] = useState("");
|
|
const [toTable, setToTable] = useState("");
|
|
const [selectedFromColumn, setSelectedFromColumn] = useState<BatchColumnInfo | null>(null);
|
|
|
|
// 매핑 상태
|
|
const [mappings, setMappings] = useState<MappingState[]>([]);
|
|
|
|
// 초기 데이터 로드
|
|
useEffect(() => {
|
|
loadConnections();
|
|
}, []);
|
|
|
|
// 커넥션 목록 로드
|
|
const loadConnections = async () => {
|
|
try {
|
|
const data = await BatchManagementAPI.getAvailableConnections();
|
|
setConnections(Array.isArray(data) ? data : []);
|
|
} catch (error) {
|
|
console.error("커넥션 목록 로드 오류:", error);
|
|
toast.error("커넥션 목록을 불러오는데 실패했습니다.");
|
|
setConnections([]); // 오류 시 빈 배열로 설정
|
|
}
|
|
};
|
|
|
|
// FROM 커넥션 변경 시 테이블 로드
|
|
const handleFromConnectionChange = async (connectionId: string) => {
|
|
if (connectionId === 'unknown') return;
|
|
|
|
const connection = connections.find((c: BatchConnectionInfo) =>
|
|
c.type === 'internal' ? c.type === connectionId : c.id?.toString() === connectionId
|
|
);
|
|
|
|
if (!connection) return;
|
|
|
|
setFromConnection(connection);
|
|
setFromTable("");
|
|
setFromColumns([]);
|
|
setSelectedFromColumn(null);
|
|
|
|
try {
|
|
const tables = await BatchManagementAPI.getTablesFromConnection(
|
|
connection.type,
|
|
connection.id
|
|
);
|
|
setFromTables(Array.isArray(tables) ? tables : []);
|
|
} catch (error) {
|
|
console.error("FROM 테이블 목록 로드 오류:", error);
|
|
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
|
setFromTables([]); // 오류 시 빈 배열로 설정
|
|
}
|
|
};
|
|
|
|
// TO 커넥션 변경 시 테이블 로드
|
|
const handleToConnectionChange = async (connectionId: string) => {
|
|
if (connectionId === 'unknown') return;
|
|
|
|
const connection = connections.find((c: BatchConnectionInfo) =>
|
|
c.type === 'internal' ? c.type === connectionId : c.id?.toString() === connectionId
|
|
);
|
|
|
|
if (!connection) return;
|
|
|
|
setToConnection(connection);
|
|
setToTable("");
|
|
setToColumns([]);
|
|
|
|
try {
|
|
const tables = await BatchManagementAPI.getTablesFromConnection(
|
|
connection.type,
|
|
connection.id
|
|
);
|
|
setToTables(Array.isArray(tables) ? tables : []);
|
|
} catch (error) {
|
|
console.error("TO 테이블 목록 로드 오류:", error);
|
|
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
|
setToTables([]); // 오류 시 빈 배열로 설정
|
|
}
|
|
};
|
|
|
|
// FROM 테이블 변경 시 컬럼 로드
|
|
const handleFromTableChange = async (tableName: string) => {
|
|
if (!fromConnection) return;
|
|
|
|
setFromTable(tableName);
|
|
setSelectedFromColumn(null);
|
|
|
|
try {
|
|
const columns = await BatchManagementAPI.getTableColumns(
|
|
fromConnection.type,
|
|
tableName,
|
|
fromConnection.id
|
|
);
|
|
setFromColumns(Array.isArray(columns) ? columns : []);
|
|
} catch (error) {
|
|
console.error("FROM 컬럼 목록 로드 오류:", error);
|
|
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
|
setFromColumns([]); // 오류 시 빈 배열로 설정
|
|
}
|
|
};
|
|
|
|
// TO 테이블 변경 시 컬럼 로드
|
|
const handleToTableChange = async (tableName: string) => {
|
|
if (!toConnection) return;
|
|
|
|
console.log("TO 테이블 변경:", {
|
|
tableName,
|
|
connectionType: toConnection.type,
|
|
connectionId: toConnection.id
|
|
});
|
|
|
|
setToTable(tableName);
|
|
|
|
try {
|
|
const columns = await BatchManagementAPI.getTableColumns(
|
|
toConnection.type,
|
|
tableName,
|
|
toConnection.id
|
|
);
|
|
console.log("TO 컬럼 목록 로드 성공:", columns);
|
|
setToColumns(Array.isArray(columns) ? columns : []);
|
|
} catch (error) {
|
|
console.error("TO 컬럼 목록 로드 오류:", error);
|
|
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
|
setToColumns([]); // 오류 시 빈 배열로 설정
|
|
}
|
|
};
|
|
|
|
// FROM 컬럼 클릭
|
|
const handleFromColumnClick = (column: BatchColumnInfo) => {
|
|
setSelectedFromColumn(column);
|
|
};
|
|
|
|
// TO 컬럼 클릭 (매핑 생성)
|
|
const handleToColumnClick = (column: BatchColumnInfo) => {
|
|
if (!selectedFromColumn || !fromConnection || !toConnection) {
|
|
toast.error("FROM 컬럼을 먼저 선택해주세요.");
|
|
return;
|
|
}
|
|
|
|
// N:1 매핑 방지 (여러 FROM 컬럼이 같은 TO 컬럼에 매핑되는 것 방지)
|
|
const isAlreadyMapped = mappings.some(mapping =>
|
|
mapping.to?.connection?.type === toConnection.type &&
|
|
mapping.to?.connection?.id === toConnection.id &&
|
|
mapping.to?.table === toTable &&
|
|
mapping.to?.column?.column_name === column.column_name
|
|
);
|
|
|
|
if (isAlreadyMapped) {
|
|
toast.error("이미 매핑된 TO 컬럼입니다. N:1 매핑은 허용되지 않습니다.");
|
|
return;
|
|
}
|
|
|
|
// 새 매핑 추가
|
|
const newMapping: MappingState = {
|
|
from: {
|
|
connection: fromConnection,
|
|
table: fromTable,
|
|
column: selectedFromColumn
|
|
},
|
|
to: {
|
|
connection: toConnection,
|
|
table: toTable,
|
|
column: column
|
|
}
|
|
};
|
|
|
|
setMappings([...mappings, newMapping]);
|
|
setSelectedFromColumn(null);
|
|
toast.success("매핑이 추가되었습니다.");
|
|
};
|
|
|
|
// 매핑 삭제
|
|
const removeMapping = (index: number) => {
|
|
setMappings(mappings.filter((_, i) => i !== index));
|
|
toast.success("매핑이 삭제되었습니다.");
|
|
};
|
|
|
|
// 컬럼이 이미 매핑되었는지 확인
|
|
const isColumnMapped = (
|
|
connectionType: 'internal' | 'external',
|
|
connectionId: number | undefined,
|
|
tableName: string,
|
|
columnName: string
|
|
): boolean => {
|
|
return mappings.some(mapping =>
|
|
mapping.to?.connection?.type === connectionType &&
|
|
mapping.to?.connection?.id === connectionId &&
|
|
mapping.to?.table === tableName &&
|
|
mapping.to?.column?.column_name === columnName
|
|
);
|
|
};
|
|
|
|
// 배치 설정 저장
|
|
const handleSave = () => {
|
|
if (!batchName.trim()) {
|
|
toast.error("배치명을 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
if (mappings.length === 0) {
|
|
toast.error("최소 하나의 매핑을 설정해주세요.");
|
|
return;
|
|
}
|
|
|
|
// TODO: 실제 저장 로직 구현
|
|
console.log("배치 설정 저장:", {
|
|
batchName,
|
|
cronSchedule,
|
|
description,
|
|
mappings
|
|
});
|
|
|
|
toast.success("배치 설정이 저장되었습니다.");
|
|
};
|
|
|
|
return (
|
|
<div className="container mx-auto p-6 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-3xl font-bold">배치관리 시스템 (새 버전)</h1>
|
|
<div className="flex gap-2">
|
|
<Button onClick={loadConnections} variant="outline">
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
새로고침
|
|
</Button>
|
|
<Button onClick={handleSave}>
|
|
<Save className="w-4 h-4 mr-2" />
|
|
저장
|
|
</Button>
|
|
</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>
|
|
<Label htmlFor="batchName">배치명 *</Label>
|
|
<Input
|
|
id="batchName"
|
|
value={batchName}
|
|
onChange={(e) => setBatchName(e.target.value)}
|
|
placeholder="배치명을 입력하세요"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="cronSchedule">실행 스케줄 *</Label>
|
|
<Input
|
|
id="cronSchedule"
|
|
value={cronSchedule}
|
|
onChange={(e) => setCronSchedule(e.target.value)}
|
|
placeholder="0 12 * * *"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="description">설명</Label>
|
|
<Textarea
|
|
id="description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="배치에 대한 설명을 입력하세요"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* FROM 섹션 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<ArrowRight className="w-5 h-5" />
|
|
FROM (소스)
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* FROM 커넥션 선택 */}
|
|
<div>
|
|
<Label>커넥션 선택</Label>
|
|
<Select onValueChange={handleFromConnectionChange}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="커넥션을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Array.isArray(connections) && connections.map((conn: BatchConnectionInfo) => (
|
|
<SelectItem
|
|
key={conn.type === 'internal' ? 'internal' : conn.id || 'unknown'}
|
|
value={conn.type === 'internal' ? 'internal' : (conn.id ? conn.id.toString() : 'unknown')}
|
|
>
|
|
{conn.name} ({conn.db_type?.toUpperCase()})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* FROM 테이블 선택 */}
|
|
{fromConnection && (
|
|
<div>
|
|
<Label>테이블 선택</Label>
|
|
<Select onValueChange={handleFromTableChange}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="테이블을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Array.isArray(fromTables) && fromTables.map((table: string) => (
|
|
<SelectItem key={table} value={table}>
|
|
{table.toUpperCase()}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* FROM 컬럼 목록 */}
|
|
{fromTable && (
|
|
<div>
|
|
<Label>컬럼 선택 (클릭하여 선택)</Label>
|
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
|
{Array.isArray(fromColumns) && fromColumns.map((column: BatchColumnInfo) => (
|
|
<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'
|
|
: 'hover:bg-gray-100'
|
|
}`}
|
|
onClick={() => handleFromColumnClick(column)}
|
|
>
|
|
<p className="font-medium">{column.column_name}</p>
|
|
<p className="text-sm text-gray-500">{column.data_type}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* TO 섹션 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<ArrowRight className="w-5 h-5" />
|
|
TO (대상)
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* TO 커넥션 선택 */}
|
|
<div>
|
|
<Label>커넥션 선택</Label>
|
|
<Select onValueChange={handleToConnectionChange}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="커넥션을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Array.isArray(connections) && connections.map((conn: BatchConnectionInfo) => (
|
|
<SelectItem
|
|
key={conn.type === 'internal' ? 'internal' : conn.id || 'unknown'}
|
|
value={conn.type === 'internal' ? 'internal' : (conn.id ? conn.id.toString() : 'unknown')}
|
|
>
|
|
{conn.name} ({conn.db_type?.toUpperCase()})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* TO 테이블 선택 */}
|
|
{toConnection && (
|
|
<div>
|
|
<Label>테이블 선택</Label>
|
|
<Select onValueChange={handleToTableChange}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="테이블을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Array.isArray(toTables) && toTables.map((table: string) => (
|
|
<SelectItem key={table} value={table}>
|
|
{table.toUpperCase()}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* TO 컬럼 목록 */}
|
|
{toTable && (
|
|
<div>
|
|
<Label>컬럼 선택 (클릭하여 매핑)</Label>
|
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
|
{Array.isArray(toColumns) && toColumns.map((column: BatchColumnInfo) => (
|
|
<div
|
|
key={column.column_name}
|
|
className={`p-3 border-2 rounded cursor-pointer transition-all ${
|
|
isColumnMapped(
|
|
toConnection!.type,
|
|
toConnection!.id,
|
|
toTable,
|
|
column.column_name
|
|
)
|
|
? 'bg-red-100 text-red-700 cursor-not-allowed opacity-60'
|
|
: selectedFromColumn
|
|
? 'hover:bg-red-50'
|
|
: 'cursor-not-allowed opacity-60'
|
|
}`}
|
|
onClick={() => handleToColumnClick(column)}
|
|
>
|
|
<p className="font-medium">{column.column_name}</p>
|
|
<p className="text-sm text-gray-500">{column.data_type}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 매핑 목록 */}
|
|
{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">
|
|
<div className="flex items-center gap-4">
|
|
<Badge className="border">
|
|
{mapping.from?.connection?.name}
|
|
</Badge>
|
|
<span className="font-medium">{mapping.from?.table}.{mapping.from?.column?.column_name}</span>
|
|
<ArrowRight className="w-4 h-4" />
|
|
<Badge className="border">
|
|
{mapping.to?.connection?.name}
|
|
</Badge>
|
|
<span className="font-medium">{mapping.to?.table}.{mapping.to?.column?.column_name}</span>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => removeMapping(index)}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|