Files
invyone/frontend/app/(main)/admin/automaticMng/exconList/page.tsx
T
hjjeong d61777ab5f fix(admin): 외부커넥션 mapper varchar 캐스팅 + 외부커넥션/배치관리 UI 정돈
- mapper/externalDbConnection.xml: WHERE ID = #{id} 5곳 + ID != #{exclude_id} 1곳에 ::varchar 캐스팅 추가
  (EXTERNAL_DB_CONNECTIONS.ID 가 V001 마이그레이션으로 VARCHAR 인데 long 바인딩되어 character varying = bigint 비교 불가로 500 발생하던 것을 해결)
- exconList: 페이지 overflow-hidden + Tabs/TabsContent 가 flex 컨테이너, ResponsiveDataView scrollContainer 활성화로 테이블 안에서만 sticky header + 자체 스크롤
- exconList/RestApiConnectionList: text-3xl→text-lg/text-sm→text-xs/h-10→h-8 등 컴팩트 폰트로 통일 (배치관리/플로우관리와 톤 매칭)
- RestApiConnectionList: Table divClassName 으로 wrapper 자체에 스크롤 위임 + sticky TableHeader 적용
- ResponsiveDataView: compact 모드일 때 폰트/셀패딩/카드 폰트도 함께 축소, scrollContainer 모드에서 @3xl:block 이 flex 를 덮어쓰던 우선순위 충돌 해결, sticky header 알파 제거
- batchmngList: Pagination 컴포넌트 적용 (RPS batchmngList 참고, 페이지당 10/20/50/100 선택), 컨테이너를 h-full min-h-0 overflow-hidden + 리스트만 자체 스크롤로 변경

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:38:23 +09:00

450 lines
18 KiB
TypeScript

"use client";
import React, { useState, useEffect } from "react";
import { Plus, Search, Pencil, Trash2, Database, Terminal, Globe } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast";
import {
ExternalDbConnectionAPI,
ExternalDbConnection,
ExternalDbConnectionFilter,
} from "@/lib/api/externalDbConnection";
import { ExternalDbConnectionModal } from "@/components/admin/ExternalDbConnectionModal";
import { SqlQueryModal } from "@/components/admin/SqlQueryModal";
import { RestApiConnectionList } from "@/components/admin/RestApiConnectionList";
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
import { ScrollToTop } from "@/components/common/ScrollToTop";
type ConnectionTabType = "database" | "rest-api";
// 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 [activeTab, setActiveTab] = useState<ConnectionTabType>("database");
// 상태 관리
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);
};
// 테이블 컬럼 정의
const columns: RDVColumn<ExternalDbConnection>[] = [
{ key: "connection_name", label: "연결명",
render: (v) => <span className="font-medium">{v}</span> },
{ key: "company_code", label: "회사", width: "100px",
render: (_v, row) => (row as any).company_name || row.company_code },
{ key: "db_type", label: "DB 타입", width: "120px",
render: (v) => <Badge variant="outline">{DB_TYPE_LABELS[v] || v}</Badge> },
{ key: "host", label: "호스트:포트", width: "180px", hideOnMobile: true,
render: (_v, row) => <span className="font-mono">{row.host}:{row.port}</span> },
{ key: "database_name", label: "데이터베이스", width: "140px", hideOnMobile: true,
render: (v) => <span className="font-mono">{v}</span> },
{ key: "username", label: "사용자", width: "100px", hideOnMobile: true,
render: (v) => <span className="font-mono">{v}</span> },
{ key: "is_active", label: "상태", width: "80px",
render: (v) => (
<Badge variant={v === "Y" ? "default" : "secondary"}>
{v === "Y" ? "활성" : "비활성"}
</Badge>
) },
{ key: "created_date", label: "생성일", width: "100px", hideOnMobile: true,
render: (v) => (
<span className="text-muted-foreground">
{v ? new Date(v).toLocaleDateString() : "N/A"}
</span>
) },
{ key: "id", label: "연결 테스트", width: "150px", hideOnMobile: true,
render: (_v, row) => (
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleTestConnection(row); }}
disabled={testingConnections.has(row.id!)}
className="h-7 px-2 text-xs">
{testingConnections.has(row.id!) ? "테스트 중..." : "테스트"}
</Button>
{testResults.has(row.id!) && (
<Badge variant={testResults.get(row.id!) ? "default" : "destructive"} className="text-[10px]">
{testResults.get(row.id!) ? "성공" : "실패"}
</Badge>
)}
</div>
) },
];
// 모바일 카드 필드 정의
const cardFields: RDVCardField<ExternalDbConnection>[] = [
{ label: "DB 타입",
render: (c) => <Badge variant="outline">{DB_TYPE_LABELS[c.db_type] || c.db_type}</Badge> },
{ label: "호스트",
render: (c) => <span className="font-mono text-xs">{c.host}:{c.port}</span> },
{ label: "데이터베이스",
render: (c) => <span className="font-mono text-xs">{c.database_name}</span> },
{ label: "상태",
render: (c) => (
<Badge variant={c.is_active === "Y" ? "default" : "secondary"}>
{c.is_active === "Y" ? "활성" : "비활성"}
</Badge>
) },
];
return (
<div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col gap-4 px-4 py-4 sm:px-6">
{/* 페이지 헤더 */}
<div className="shrink-0 space-y-0.5 border-b pb-3">
<h1 className="text-lg font-bold tracking-tight"> </h1>
<p className="text-xs text-muted-foreground"> REST API </p>
</div>
{/* 탭 */}
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)} className="flex min-h-0 flex-1 flex-col gap-3">
<TabsList className="grid h-8 w-full max-w-[320px] shrink-0 grid-cols-2">
<TabsTrigger value="database" className="flex items-center gap-1.5 text-xs">
<Database className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="rest-api" className="flex items-center gap-1.5 text-xs">
<Globe className="h-3.5 w-3.5" />
REST API
</TabsTrigger>
</TabsList>
{/* 데이터베이스 연결 탭 */}
<TabsContent value="database" className="mt-0 flex min-h-0 flex-1 flex-col gap-3">
{/* 검색 및 필터 */}
<div className="flex shrink-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-[260px]">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="연결명 또는 설명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-8 pl-9 text-xs"
/>
</div>
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
<SelectTrigger className="h-8 w-full text-xs sm:w-[140px]">
<SelectValue placeholder="DB 타입" />
</SelectTrigger>
<SelectContent>
{supportedDbTypes.map((type) => (
<SelectItem key={type.value} value={type.value} className="text-xs">
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
<SelectTrigger className="h-8 w-full text-xs sm:w-[110px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-xs">
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleAddConnection} size="sm" className="h-8 gap-1 text-xs font-medium">
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{/* 연결 목록 - ResponsiveDataView */}
<ResponsiveDataView
data={connections}
columns={columns}
keyExtractor={(c) => String(c.id || c.connection_name)}
isLoading={loading}
emptyMessage="등록된 연결이 없습니다"
skeletonCount={5}
compact
scrollContainer
cardTitle={(c) => c.connection_name}
cardSubtitle={(c) => <span className="font-mono text-xs">{c.host}:{c.port}/{c.database_name}</span>}
cardHeaderRight={(c) => (
<Badge variant={c.is_active === "Y" ? "default" : "secondary"} className="text-[10px]">
{c.is_active === "Y" ? "활성" : "비활성"}
</Badge>
)}
cardFields={cardFields}
renderActions={(c) => (
<>
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleTestConnection(c); }}
disabled={testingConnections.has(c.id!)}
className="h-7 flex-1 gap-1 text-xs">
{testingConnections.has(c.id!) ? "테스트 중..." : "테스트"}
</Button>
<Button variant="outline" size="sm"
onClick={(e) => {
e.stopPropagation();
setSelectedConnection(c);
setSqlModalOpen(true);
}}
className="h-7 flex-1 gap-1 text-xs">
<Terminal className="h-3.5 w-3.5" />
SQL
</Button>
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleEditConnection(c); }}
className="h-7 flex-1 gap-1 text-xs">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleDeleteConnection(c); }}
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-7 flex-1 gap-1 text-xs">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
actionsLabel="작업"
actionsWidth="180px"
/>
{/* 연결 설정 모달 */}
{isModalOpen && (
<ExternalDbConnectionModal
isOpen={isModalOpen}
onClose={handleModalCancel}
onSave={handleModalSave}
connection={editingConnection}
supportedDbTypes={supportedDbTypes.filter((type) => type.value !== "ALL")}
/>
)}
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
&ldquo;{connectionToDelete?.connection_name}&rdquo; ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel
onClick={cancelDeleteConnection}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteConnection}
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* SQL 쿼리 모달 */}
{selectedConnection && (
<SqlQueryModal
isOpen={sqlModalOpen}
onClose={() => {
setSqlModalOpen(false);
setSelectedConnection(null);
}}
connectionId={selectedConnection.id!}
connectionName={selectedConnection.connection_name}
/>
)}
</TabsContent>
{/* REST API 연결 탭 */}
<TabsContent value="rest-api" className="mt-0 flex min-h-0 flex-1 flex-col gap-3">
<RestApiConnectionList />
</TabsContent>
</Tabs>
</div>
<ScrollToTop />
</div>
);
}