ERP-node/frontend/app/(main)/admin/batchmng/page.tsx

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