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

457 lines
17 KiB
TypeScript

"use client";
import React, { useState, useEffect } from "react";
import { Plus, Search, Pencil, Trash2, Database, Terminal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast";
import {
ExternalDbConnectionAPI,
ExternalDbConnection,
ExternalDbConnectionFilter,
ConnectionTestRequest,
} from "@/lib/api/externalDbConnection";
import { ExternalDbConnectionModal } from "@/components/admin/ExternalDbConnectionModal";
import { SqlQueryModal } from "@/components/admin/SqlQueryModal";
// DB 타입 매핑
const DB_TYPE_LABELS: Record<string, string> = {
mysql: "MySQL",
postgresql: "PostgreSQL",
oracle: "Oracle",
mssql: "SQL Server",
sqlite: "SQLite",
};
// 활성 상태 옵션
const ACTIVE_STATUS_OPTIONS = [
{ value: "ALL", label: "전체" },
{ value: "Y", label: "활성" },
{ value: "N", label: "비활성" },
];
export default function ExternalConnectionsPage() {
const { toast } = useToast();
// 상태 관리
const [connections, setConnections] = useState<ExternalDbConnection[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [dbTypeFilter, setDbTypeFilter] = useState("ALL");
const [activeStatusFilter, setActiveStatusFilter] = useState("ALL");
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingConnection, setEditingConnection] = useState<ExternalDbConnection | undefined>();
const [supportedDbTypes, setSupportedDbTypes] = useState<Array<{ value: string; label: string }>>([]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [connectionToDelete, setConnectionToDelete] = useState<ExternalDbConnection | null>(null);
const [testingConnections, setTestingConnections] = useState<Set<number>>(new Set());
const [testResults, setTestResults] = useState<Map<number, boolean>>(new Map());
const [sqlModalOpen, setSqlModalOpen] = useState(false);
const [selectedConnection, setSelectedConnection] = useState<ExternalDbConnection | null>(null);
// 데이터 로딩
const loadConnections = async () => {
try {
setLoading(true);
const filter: ExternalDbConnectionFilter = {
search: searchTerm.trim() || undefined,
db_type: dbTypeFilter === "ALL" ? undefined : dbTypeFilter,
is_active: activeStatusFilter === "ALL" ? undefined : activeStatusFilter,
};
const data = await ExternalDbConnectionAPI.getConnections(filter);
setConnections(data);
} catch (error) {
console.error("연결 목록 로딩 오류:", error);
toast({
title: "오류",
description: "연결 목록을 불러오는데 실패했습니다.",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
// 지원되는 DB 타입 로딩
const loadSupportedDbTypes = async () => {
try {
const types = await ExternalDbConnectionAPI.getSupportedTypes();
setSupportedDbTypes([{ value: "ALL", label: "전체" }, ...types]);
} catch (error) {
console.error("지원 DB 타입 로딩 오류:", error);
// 실패 시 기본값 사용
setSupportedDbTypes([
{ value: "ALL", label: "전체" },
{ value: "mysql", label: "MySQL" },
{ value: "postgresql", label: "PostgreSQL" },
{ value: "oracle", label: "Oracle" },
{ value: "mssql", label: "SQL Server" },
{ value: "sqlite", label: "SQLite" },
]);
}
};
// 초기 데이터 로딩
useEffect(() => {
loadConnections();
loadSupportedDbTypes();
}, []);
// 필터 변경 시 데이터 재로딩
useEffect(() => {
loadConnections();
}, [searchTerm, dbTypeFilter, activeStatusFilter]);
// 새 연결 추가
const handleAddConnection = () => {
setEditingConnection(undefined);
setIsModalOpen(true);
};
// 연결 편집
const handleEditConnection = (connection: ExternalDbConnection) => {
setEditingConnection(connection);
setIsModalOpen(true);
};
// 연결 삭제 확인 다이얼로그 열기
const handleDeleteConnection = (connection: ExternalDbConnection) => {
setConnectionToDelete(connection);
setDeleteDialogOpen(true);
};
// 연결 삭제 실행
const confirmDeleteConnection = async () => {
if (!connectionToDelete?.id) return;
try {
await ExternalDbConnectionAPI.deleteConnection(connectionToDelete.id);
toast({
title: "성공",
description: "연결이 삭제되었습니다.",
});
loadConnections();
} catch (error) {
console.error("연결 삭제 오류:", error);
toast({
title: "오류",
description: error instanceof Error ? error.message : "연결 삭제에 실패했습니다.",
variant: "destructive",
});
} finally {
setDeleteDialogOpen(false);
setConnectionToDelete(null);
}
};
// 연결 삭제 취소
const cancelDeleteConnection = () => {
setDeleteDialogOpen(false);
setConnectionToDelete(null);
};
// 연결 테스트
const handleTestConnection = async (connection: ExternalDbConnection) => {
if (!connection.id) return;
setTestingConnections((prev) => new Set(prev).add(connection.id!));
try {
const result = await ExternalDbConnectionAPI.testConnection(connection.id);
setTestResults((prev) => new Map(prev).set(connection.id!, result.success));
if (result.success) {
toast({
title: "연결 성공",
description: `${connection.connection_name} 연결이 성공했습니다.`,
});
} else {
toast({
title: "연결 실패",
description: result.message || `${connection.connection_name} 연결에 실패했습니다.`,
variant: "destructive",
});
}
} catch (error) {
console.error("연결 테스트 오류:", error);
setTestResults((prev) => new Map(prev).set(connection.id!, false));
toast({
title: "연결 테스트 오류",
description: "연결 테스트 중 오류가 발생했습니다.",
variant: "destructive",
});
} finally {
setTestingConnections((prev) => {
const newSet = new Set(prev);
newSet.delete(connection.id!);
return newSet;
});
}
};
// 모달 저장 처리
const handleModalSave = () => {
setIsModalOpen(false);
setEditingConnection(undefined);
loadConnections();
};
// 모달 취소 처리
const handleModalCancel = () => {
setIsModalOpen(false);
setEditingConnection(undefined);
};
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
</div>
{/* 검색 및 필터 */}
<Card className="mb-6 shadow-sm">
<CardContent className="pt-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-4 md:flex-row md:items-center">
{/* 검색 */}
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="연결명 또는 설명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-64 pl-10"
/>
</div>
{/* DB 타입 필터 */}
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
<SelectTrigger className="w-40">
<SelectValue placeholder="DB 타입" />
</SelectTrigger>
<SelectContent>
{supportedDbTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 활성 상태 필터 */}
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
<SelectTrigger className="w-32">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 추가 버튼 */}
<Button onClick={handleAddConnection} className="shrink-0">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 연결 목록 */}
{loading ? (
<div className="flex h-64 items-center justify-center">
<div className="text-gray-500"> ...</div>
</div>
) : connections.length === 0 ? (
<Card className="shadow-sm">
<CardContent className="pt-6">
<div className="py-8 text-center text-gray-500">
<Database className="mx-auto mb-4 h-12 w-12 text-gray-400" />
<p className="mb-2 text-lg font-medium"> </p>
<p className="mb-4 text-sm text-gray-400"> .</p>
<Button onClick={handleAddConnection}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
) : (
<Card className="shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]"></TableHead>
<TableHead className="w-[120px]">DB </TableHead>
<TableHead className="w-[200px]">호스트:포트</TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]"> </TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connections.map((connection) => (
<TableRow key={connection.id} className="hover:bg-gray-50">
<TableCell>
<div className="font-medium">{connection.connection_name}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{DB_TYPE_LABELS[connection.db_type] || connection.db_type}
</Badge>
</TableCell>
<TableCell className="font-mono text-sm">
{connection.host}:{connection.port}
</TableCell>
<TableCell className="font-mono text-sm">{connection.database_name}</TableCell>
<TableCell className="font-mono text-sm">{connection.username}</TableCell>
<TableCell>
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"} className="text-xs">
{connection.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="text-sm">
{connection.created_date ? new Date(connection.created_date).toLocaleDateString() : "N/A"}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleTestConnection(connection)}
disabled={testingConnections.has(connection.id!)}
className="h-7 px-2 text-xs"
>
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
</Button>
{testResults.has(connection.id!) && (
<Badge
variant={testResults.get(connection.id!) ? "default" : "destructive"}
className="text-xs text-white"
>
{testResults.get(connection.id!) ? "성공" : "실패"}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
console.log("SQL 쿼리 실행 버튼 클릭 - connection:", connection);
setSelectedConnection(connection);
setSqlModalOpen(true);
}}
className="h-8 w-8 p-0"
title="SQL 쿼리 실행"
>
<Terminal className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditConnection(connection)}
className="h-8 w-8 p-0"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteConnection(connection)}
className="h-8 w-8 p-0 text-red-600 hover:bg-red-50 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* 연결 설정 모달 */}
{isModalOpen && (
<ExternalDbConnectionModal
isOpen={isModalOpen}
onClose={handleModalCancel}
onSave={handleModalSave}
connection={editingConnection}
supportedDbTypes={supportedDbTypes.filter((type) => type.value !== "ALL")}
/>
)}
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
"{connectionToDelete?.connection_name}" ?
<br />
<span className="font-medium text-red-600"> .</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={cancelDeleteConnection}></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteConnection}
className="bg-red-600 text-white hover:bg-red-700 focus:ring-red-600"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* SQL 쿼리 모달 */}
{selectedConnection && (
<SqlQueryModal
isOpen={sqlModalOpen}
onClose={() => {
setSqlModalOpen(false);
setSelectedConnection(null);
}}
connectionId={selectedConnection.id!}
connectionName={selectedConnection.connection_name}
/>
)}
</div>
</div>
);
}