Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs
2025-09-19 15:23:35 +09:00
9 changed files with 1190 additions and 401 deletions

View File

@@ -1,43 +1,61 @@
"use client";
import React, { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Eye, EyeOff, ChevronDown, ChevronRight } from "lucide-react";
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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Database, ChevronDown, ChevronRight, Eye, EyeOff } from "lucide-react";
import { toast } from "sonner";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import {
ExternalDbConnectionAPI,
ExternalDbConnection,
DB_TYPE_OPTIONS,
DB_TYPE_DEFAULTS,
ConnectionTestRequest,
ConnectionTestResult,
} from "@/lib/api/externalDbConnection";
interface ExternalDbConnectionModalProps {
isOpen: boolean;
onClose: () => void;
onSave: () => void;
editingConnection?: ExternalDbConnection | null;
connection?: ExternalDbConnection;
supportedDbTypes: Array<{ value: string; label: string }>;
}
export function ExternalDbConnectionModal({
// 기본 포트 설정
const DEFAULT_PORTS: Record<string, number> = {
mysql: 3306,
postgresql: 5432,
oracle: 1521,
mssql: 1433,
sqlite: 0, // SQLite는 파일 기반이므로 포트 없음
};
export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps> = ({
isOpen,
onClose,
onSave,
editingConnection,
}: ExternalDbConnectionModalProps) {
const [formData, setFormData] = useState<Partial<ExternalDbConnection>>({
connection,
supportedDbTypes,
}) => {
const { toast } = useToast();
// 상태 관리
const [loading, setSaving] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [testingConnection, setTestingConnection] = useState(false);
const [testResult, setTestResult] = useState<ConnectionTestResult | null>(null);
// 폼 데이터
const [formData, setFormData] = useState<ExternalDbConnection>({
connection_name: "",
description: "",
db_type: "mysql",
host: "",
port: 3306,
db_type: "postgresql",
host: "localhost",
port: DEFAULT_PORTS.postgresql,
database_name: "",
username: "",
password: "",
@@ -50,143 +68,244 @@ export function ExternalDbConnectionModal({
is_active: "Y",
});
const [loading, setLoading] = useState(false);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// 편집 모드인지 확인
const isEditMode = !!connection;
// 편집 모드일기존 데이터 로드
// 연결 정보가 변경될 데이터 업데이트
useEffect(() => {
if (isOpen) {
if (editingConnection) {
setFormData({
...editingConnection,
password: "", // 보안상 비밀번호는 빈 값으로 시작
});
setShowAdvancedSettings(true); // 편집 시 고급 설정 펼치기
} else {
// 새 연결 생성 시 기본값 설정
setFormData({
connection_name: "",
description: "",
db_type: "mysql",
host: "",
port: 3306,
database_name: "",
username: "",
password: "",
connection_timeout: 30,
query_timeout: 60,
max_connections: 10,
ssl_enabled: "N",
ssl_cert_path: "",
company_code: "*",
is_active: "Y",
});
setShowAdvancedSettings(false);
}
setShowPassword(false);
if (connection) {
setFormData({
...connection,
// 편집 시에는 비밀번호를 빈 문자열로 설정 (보안상 기존 비밀번호는 보여주지 않음)
password: "",
});
} else {
// 새 연결 생성 시 기본값 설정
setFormData({
connection_name: "",
description: "",
db_type: "postgresql",
host: "localhost",
port: DEFAULT_PORTS.postgresql,
database_name: "",
username: "",
password: "",
connection_timeout: 30,
query_timeout: 60,
max_connections: 10,
ssl_enabled: "N",
ssl_cert_path: "",
company_code: "*",
is_active: "Y",
});
}
}, [isOpen, editingConnection]);
}, [connection]);
// DB 타입 변경 시 기본 포트 설정
const handleDbTypeChange = (dbType: string) => {
const defaultPort = DB_TYPE_DEFAULTS[dbType as keyof typeof DB_TYPE_DEFAULTS]?.port || 3306;
setFormData((prev) => ({
...prev,
setFormData({
...formData,
db_type: dbType as ExternalDbConnection["db_type"],
port: defaultPort,
}));
port: DEFAULT_PORTS[dbType] || 5432,
});
};
// 폼 데이터 변경 핸들러
// 입력값 변경 처리
const handleInputChange = (field: keyof ExternalDbConnection, value: string | number) => {
setFormData((prev) => ({
...prev,
setFormData({
...formData,
[field]: value,
}));
});
};
// 저장
const handleSave = async () => {
// 필수 필드 검증
if (
!formData.connection_name ||
!formData.db_type ||
!formData.host ||
!formData.port ||
!formData.database_name ||
!formData.username
) {
toast.error("필수 필드를 모두 입력해주세요.");
// 폼 검증
const validateForm = (): boolean => {
if (!formData.connection_name.trim()) {
toast({
title: "검증 오류",
description: "연결명을 입력해주세요.",
variant: "destructive",
});
return false;
}
if (!formData.host.trim()) {
toast({
title: "검증 오류",
description: "호스트를 입력해주세요.",
variant: "destructive",
});
return false;
}
if (!formData.database_name.trim()) {
toast({
title: "검증 오류",
description: "데이터베이스명을 입력해주세요.",
variant: "destructive",
});
return false;
}
if (!formData.username.trim()) {
toast({
title: "검증 오류",
description: "사용자명을 입력해주세요.",
variant: "destructive",
});
return false;
}
if (!isEditMode && !formData.password.trim()) {
toast({
title: "검증 오류",
description: "비밀번호를 입력해주세요.",
variant: "destructive",
});
return false;
}
return true;
};
// 연결 테스트 처리
const handleTestConnection = async () => {
// 기본 필수 필드 검증
if (!formData.host.trim()) {
toast({
title: "검증 오류",
description: "호스트를 입력해주세요.",
variant: "destructive",
});
return;
}
// 편집 모드에서 비밀번호가 비어있으면 기존 비밀번호 유지
if (editingConnection && !formData.password) {
formData.password = "***ENCRYPTED***"; // 서버에서 기존 비밀번호 유지하도록 표시
} else if (!editingConnection && !formData.password) {
toast.error("새 연결 생성 시 비밀번호는 필수입니다.");
if (!formData.database_name.trim()) {
toast({
title: "검증 오류",
description: "데이터베이스명을 입력해주세요.",
variant: "destructive",
});
return;
}
if (!formData.username.trim()) {
toast({
title: "검증 오류",
description: "사용자명을 입력해주세요.",
variant: "destructive",
});
return;
}
if (!formData.password.trim()) {
toast({
title: "검증 오류",
description: "비밀번호를 입력해주세요.",
variant: "destructive",
});
return;
}
try {
setLoading(true);
setTestingConnection(true);
setTestResult(null);
const connectionData = {
...formData,
port: Number(formData.port),
connection_timeout: Number(formData.connection_timeout),
query_timeout: Number(formData.query_timeout),
max_connections: Number(formData.max_connections),
} as ExternalDbConnection;
const testData: ConnectionTestRequest = {
db_type: formData.db_type,
host: formData.host,
port: formData.port,
database_name: formData.database_name,
username: formData.username,
password: formData.password,
connection_timeout: formData.connection_timeout,
ssl_enabled: formData.ssl_enabled,
};
let response;
if (editingConnection?.id) {
response = await ExternalDbConnectionAPI.updateConnection(editingConnection.id, connectionData);
const result = await ExternalDbConnectionAPI.testConnection(testData);
setTestResult(result);
if (result.success) {
toast({
title: "연결 테스트 성공",
description: result.message,
});
} else {
response = await ExternalDbConnectionAPI.createConnection(connectionData);
}
if (response.success) {
toast.success(editingConnection ? "연결이 수정되었습니다." : "연결이 생성되었습니다.");
onSave();
} else {
toast.error(response.message || "저장 중 오류가 발생했습니다.");
toast({
title: "연결 테스트 실패",
description: result.message,
variant: "destructive",
});
}
} catch (error) {
console.error("저장 오류:", error);
toast.error("저장 중 오류가 발생했습니다.");
console.error("연결 테스트 오류:", error);
const errorResult: ConnectionTestResult = {
success: false,
message: "연결 테스트 중 오류가 발생했습니다.",
error: {
code: "TEST_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류",
},
};
setTestResult(errorResult);
toast({
title: "연결 테스트 오류",
description: errorResult.message,
variant: "destructive",
});
} finally {
setLoading(false);
setTestingConnection(false);
}
};
// 취소
const handleCancel = () => {
onClose();
};
// 저장 처리
const handleSave = async () => {
if (!validateForm()) return;
// 저장 버튼 비활성화 조건
const isSaveDisabled = () => {
return (
loading ||
!formData.connection_name ||
!formData.host ||
!formData.port ||
!formData.database_name ||
!formData.username ||
(!editingConnection && !formData.password)
);
try {
setSaving(true);
// 편집 모드에서 비밀번호가 비어있으면 기존 비밀번호 유지
let dataToSave = { ...formData };
if (isEditMode && !dataToSave.password.trim()) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { password, ...dataWithoutPassword } = dataToSave;
dataToSave = dataWithoutPassword as ExternalDbConnection;
}
if (isEditMode && connection?.id) {
await ExternalDbConnectionAPI.updateConnection(connection.id, dataToSave);
toast({
title: "성공",
description: "연결 정보가 수정되었습니다.",
});
} else {
await ExternalDbConnectionAPI.createConnection(dataToSave);
toast({
title: "성공",
description: "새 연결이 생성되었습니다.",
});
}
onSave();
} catch (error) {
console.error("연결 저장 오류:", error);
toast({
title: "오류",
description: error instanceof Error ? error.message : "연결 저장에 실패했습니다.",
variant: "destructive",
});
} finally {
setSaving(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={handleCancel}>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
{editingConnection ? "외부 DB 연결 수정" : "새 외부 DB 연결 추가"}
</DialogTitle>
<DialogTitle>{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}</DialogTitle>
</DialogHeader>
<div className="space-y-6">
@@ -199,21 +318,22 @@ export function ExternalDbConnectionModal({
<Label htmlFor="connection_name"> *</Label>
<Input
id="connection_name"
value={formData.connection_name || ""}
value={formData.connection_name}
onChange={(e) => handleInputChange("connection_name", e.target.value)}
placeholder="예: 영업팀 MySQL"
placeholder="예: 운영 DB"
/>
</div>
<div>
<Label htmlFor="db_type">DB *</Label>
<Select value={formData.db_type || "mysql"} onValueChange={handleDbTypeChange}>
<Select value={formData.db_type} onValueChange={handleDbTypeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DB_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{supportedDbTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
@@ -237,25 +357,25 @@ export function ExternalDbConnectionModal({
<div className="space-y-4">
<h3 className="text-lg font-medium"> </h3>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<Label htmlFor="host"> *</Label>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="host"> *</Label>
<Input
id="host"
value={formData.host || ""}
value={formData.host}
onChange={(e) => handleInputChange("host", e.target.value)}
placeholder="예: localhost, db.company.com"
placeholder="localhost"
/>
</div>
<div>
<Label htmlFor="port"> *</Label>
<Input
id="port"
type="number"
value={formData.port || ""}
value={formData.port}
onChange={(e) => handleInputChange("port", parseInt(e.target.value) || 0)}
min={1}
max={65535}
placeholder="5432"
/>
</div>
</div>
@@ -264,9 +384,9 @@ export function ExternalDbConnectionModal({
<Label htmlFor="database_name"> *</Label>
<Input
id="database_name"
value={formData.database_name || ""}
value={formData.database_name}
onChange={(e) => handleInputChange("database_name", e.target.value)}
placeholder="예: sales_db, production"
placeholder="database_name"
/>
</div>
@@ -275,88 +395,164 @@ export function ExternalDbConnectionModal({
<Label htmlFor="username"> *</Label>
<Input
id="username"
value={formData.username || ""}
value={formData.username}
onChange={(e) => handleInputChange("username", e.target.value)}
placeholder="DB 사용자명"
placeholder="username"
/>
</div>
<div>
<Label htmlFor="password"> {!editingConnection && "*"}</Label>
<Label htmlFor="password"> {isEditMode ? "(변경 시에만 입력)" : "*"}</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={formData.password || ""}
value={formData.password}
onChange={(e) => handleInputChange("password", e.target.value)}
placeholder={editingConnection ? "변경하려면 입력하세요" : "DB 비밀번호"}
placeholder={isEditMode ? "변경하지 않으려면 비워두세요" : "password"}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-1/2 right-2 h-8 w-8 -translate-y-1/2 p-0"
className="absolute top-0 right-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
</div>
{/* 고급 설정 (접기/펼치기) */}
<Collapsible open={showAdvancedSettings} onOpenChange={setShowAdvancedSettings}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="flex w-full justify-between p-0">
<h3 className="text-lg font-medium"> </h3>
{showAdvancedSettings ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="connection_timeout"> ()</Label>
<Input
id="connection_timeout"
type="number"
value={formData.connection_timeout || 30}
onChange={(e) => handleInputChange("connection_timeout", parseInt(e.target.value) || 30)}
min={1}
max={300}
/>
</div>
<div>
<Label htmlFor="query_timeout"> ()</Label>
<Input
id="query_timeout"
type="number"
value={formData.query_timeout || 60}
onChange={(e) => handleInputChange("query_timeout", parseInt(e.target.value) || 60)}
min={1}
max={3600}
/>
</div>
<div>
<Label htmlFor="max_connections"> </Label>
<Input
id="max_connections"
type="number"
value={formData.max_connections || 10}
onChange={(e) => handleInputChange("max_connections", parseInt(e.target.value) || 10)}
min={1}
max={100}
/>
</div>
{/* 연결 테스트 */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={
testingConnection ||
!formData.host ||
!formData.database_name ||
!formData.username ||
!formData.password
}
className="w-32"
>
{testingConnection ? "테스트 중..." : "연결 테스트"}
</Button>
{testingConnection && <div className="text-sm text-gray-500"> ...</div>}
</div>
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Switch
id="ssl_enabled"
checked={formData.ssl_enabled === "Y"}
onCheckedChange={(checked) => handleInputChange("ssl_enabled", checked ? "Y" : "N")}
/>
<Label htmlFor="ssl_enabled">SSL </Label>
{/* 테스트 결과 표시 */}
{testResult && (
<div
className={`rounded-md border p-3 text-sm ${
testResult.success
? "border-green-200 bg-green-50 text-green-800"
: "border-red-200 bg-red-50 text-red-800"
}`}
>
<div className="font-medium">{testResult.success ? "✅ 연결 성공" : "❌ 연결 실패"}</div>
<div className="mt-1">{testResult.message}</div>
{testResult.success && testResult.details && (
<div className="mt-2 space-y-1 text-xs">
{testResult.details.response_time && <div> : {testResult.details.response_time}ms</div>}
{testResult.details.server_version && <div> : {testResult.details.server_version}</div>}
{testResult.details.database_size && (
<div> : {testResult.details.database_size}</div>
)}
</div>
)}
{!testResult.success && testResult.error && (
<div className="mt-2 text-xs">
<div> : {testResult.error.code}</div>
{testResult.error.details && <div className="mt-1 text-red-600">{testResult.error.details}</div>}
</div>
)}
</div>
)}
</div>
</div>
{/* 고급 설정 */}
<div className="space-y-4">
<Button
type="button"
variant="ghost"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex h-auto items-center gap-2 p-0 font-medium"
>
{showAdvanced ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
{showAdvanced && (
<div className="space-y-4 border-l-2 border-gray-200 pl-6">
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="connection_timeout"> ()</Label>
<Input
id="connection_timeout"
type="number"
value={formData.connection_timeout || 30}
onChange={(e) => handleInputChange("connection_timeout", parseInt(e.target.value) || 30)}
/>
</div>
<div>
<Label htmlFor="query_timeout"> ()</Label>
<Input
id="query_timeout"
type="number"
value={formData.query_timeout || 60}
onChange={(e) => handleInputChange("query_timeout", parseInt(e.target.value) || 60)}
/>
</div>
<div>
<Label htmlFor="max_connections"> </Label>
<Input
id="max_connections"
type="number"
value={formData.max_connections || 10}
onChange={(e) => handleInputChange("max_connections", parseInt(e.target.value) || 10)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="ssl_enabled">SSL </Label>
<Select
value={formData.ssl_enabled || "N"}
onValueChange={(value) => handleInputChange("ssl_enabled", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"> </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="is_active"> </Label>
<Select value={formData.is_active} onValueChange={(value) => handleInputChange("is_active", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{formData.ssl_enabled === "Y" && (
@@ -366,67 +562,24 @@ export function ExternalDbConnectionModal({
id="ssl_cert_path"
value={formData.ssl_cert_path || ""}
onChange={(e) => handleInputChange("ssl_cert_path", e.target.value)}
placeholder="/path/to/certificate.pem"
placeholder="/path/to/ssl/cert.pem"
/>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="company_code"> </Label>
<Input
id="company_code"
value={formData.company_code || "*"}
onChange={(e) => handleInputChange("company_code", e.target.value)}
placeholder="회사 코드 (기본: *)"
/>
</div>
<div>
<Label htmlFor="is_active"></Label>
<Select
value={formData.is_active || "Y"}
onValueChange={(value) => handleInputChange("is_active", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* 편집 모드일 때 정보 표시 */}
{editingConnection && (
<div className="bg-muted rounded-lg p-4 text-sm">
<div className="mb-2 flex items-center gap-2">
<Badge variant="secondary"> </Badge>
<span className="text-muted-foreground">
:{" "}
{editingConnection.created_date ? new Date(editingConnection.created_date).toLocaleString() : "-"}
</span>
</div>
<p className="text-muted-foreground">
. .
</p>
</div>
)}
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel} disabled={loading}>
<Button variant="outline" onClick={onClose} disabled={loading}>
</Button>
<Button onClick={handleSave} disabled={isSaveDisabled()}>
{loading ? "저장 중..." : editingConnection ? "수정" : "생성"}
<Button onClick={handleSave} disabled={loading}>
{loading ? "저장 중..." : isEditMode ? "수정" : "생성"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
};