ERP-node/frontend/components/screen/CreateScreenModal.tsx

613 lines
26 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState, useRef } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Search, X, Check, ChevronsUpDown, Database, Globe } from "lucide-react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { screenApi, tableTypeApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
import { useAuth } from "@/hooks/useAuth";
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
interface CreateScreenModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated?: (screen: ScreenDefinition) => void;
}
export default function CreateScreenModal({ open, onOpenChange, onCreated }: CreateScreenModalProps) {
const { user } = useAuth();
const [screenName, setScreenName] = useState("");
const [screenCode, setScreenCode] = useState("");
const [tableName, setTableName] = useState("");
const [description, setDescription] = useState("");
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [submitting, setSubmitting] = useState(false);
const [tableSearchTerm, setTableSearchTerm] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
// 데이터 소스 타입 (database: 데이터베이스, restapi: REST API)
const [dataSourceType, setDataSourceType] = useState<"database" | "restapi">("database");
// 외부 DB 연결 관련 상태
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal");
const [externalConnections, setExternalConnections] = useState<any[]>([]);
const [externalTableList, setExternalTableList] = useState<string[]>([]);
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
const [openDbSourceCombobox, setOpenDbSourceCombobox] = useState(false);
// REST API 연결 관련 상태
const [restApiConnections, setRestApiConnections] = useState<ExternalRestApiConnection[]>([]);
const [selectedRestApiId, setSelectedRestApiId] = useState<number | null>(null);
const [openRestApiCombobox, setOpenRestApiCombobox] = useState(false);
const [restApiEndpoint, setRestApiEndpoint] = useState("");
const [restApiJsonPath, setRestApiJsonPath] = useState("data"); // 응답에서 데이터 추출 경로
// 화면 코드 자동 생성
const generateCode = async () => {
try {
const companyCode = (user as any)?.company_code || (user as any)?.companyCode || "*";
const generatedCode = await screenApi.generateScreenCode(companyCode);
setScreenCode(generatedCode);
} catch (e) {
// console.error("화면 코드 생성 실패", e);
}
};
// 내부 DB 테이블 목록 로드
useEffect(() => {
if (!open) return;
let abort = false;
const loadTables = async () => {
try {
const list = await tableTypeApi.getTables();
if (abort) return;
setTables(list.map((t) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName })));
} catch (e) {
setTables([]);
}
};
loadTables();
return () => {
abort = true;
};
}, [open]);
// 외부 DB 연결 목록 로드
useEffect(() => {
if (!open) return;
const loadConnections = async () => {
try {
const token = localStorage.getItem("authToken");
if (!token) {
console.warn("No auth token found");
return;
}
const response = await fetch("/api/external-db-connections/control/active", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response && response.ok) {
const data = await response.json();
if (data.success && data.data) {
const filtered = data.data.filter(
(conn: any) => !conn.connection_name.includes("메인") && !conn.connection_name.includes("현재 시스템"),
);
setExternalConnections(filtered);
}
}
} catch (error) {
console.error("Failed to load external connections:", error);
setExternalConnections([]);
}
};
loadConnections();
}, [open]);
// REST API 연결 목록 로드
useEffect(() => {
if (!open) return;
const loadRestApiConnections = async () => {
try {
const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" });
setRestApiConnections(connections);
} catch (error) {
console.error("Failed to load REST API connections:", error);
setRestApiConnections([]);
}
};
loadRestApiConnections();
}, [open]);
// 외부 DB 테이블 목록 로드
useEffect(() => {
if (selectedDbSource === "internal" || !selectedDbSource) {
setExternalTableList([]);
return;
}
const loadExternalTables = async () => {
try {
setLoadingExternalTables(true);
const token = localStorage.getItem("authToken");
const response = await fetch(`/api/multi-connection/connections/${selectedDbSource}/tables`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response && response.ok) {
const data = await response.json();
if (data.success && data.data) {
const tables = Array.isArray(data.data) ? data.data : [];
const tableNames = tables
.map((t: any) => (typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name))
.filter(Boolean);
setExternalTableList(tableNames);
} else {
setExternalTableList([]);
}
} else {
setExternalTableList([]);
}
} catch (error) {
console.error("외부 DB 테이블 목록 조회 오류:", error);
setExternalTableList([]);
} finally {
setLoadingExternalTables(false);
}
};
loadExternalTables();
}, [selectedDbSource]);
// 모달이 열릴 때 자동으로 화면 코드 생성
useEffect(() => {
if (open && !screenCode) {
generateCode();
}
}, [open, screenCode]);
// 테이블 선택은 선택 사항 - 컴포넌트별로 테이블을 설정할 수 있음
const isValid = useMemo(() => {
const baseValid = screenName.trim().length > 0 && screenCode.trim().length > 0;
if (dataSourceType === "database") {
// 테이블 선택은 선택 사항 (비워두면 컴포넌트별로 테이블 설정)
return baseValid;
} else {
// REST API: 연결 선택 필수
return baseValid && selectedRestApiId !== null;
}
}, [screenName, screenCode, dataSourceType, selectedRestApiId]);
// 테이블 필터링 (내부 DB용)
const filteredTables = useMemo(() => {
if (!tableSearchTerm) return tables;
const searchLower = tableSearchTerm.toLowerCase();
return tables.filter(
(table) =>
table.displayName.toLowerCase().includes(searchLower) || table.tableName.toLowerCase().includes(searchLower),
);
}, [tables, tableSearchTerm]);
// 외부 DB 테이블 필터링
const filteredExternalTables = useMemo(() => {
if (!tableSearchTerm) return externalTableList;
const searchLower = tableSearchTerm.toLowerCase();
return externalTableList.filter((table) => table.toLowerCase().includes(searchLower));
}, [externalTableList, tableSearchTerm]);
const handleSubmit = async () => {
if (!isValid || submitting) return;
try {
setSubmitting(true);
const companyCode = (user as any)?.company_code || (user as any)?.companyCode || "*";
// 데이터 소스 타입에 따라 다른 정보 전달
const createData: any = {
screenName: screenName.trim(),
screenCode: screenCode.trim(),
companyCode,
description: description.trim() || undefined,
createdBy: (user as any)?.userId,
dataSourceType: dataSourceType,
};
if (dataSourceType === "database") {
// 데이터베이스 소스 - 테이블 선택은 선택 사항
createData.tableName = tableName.trim() || null; // 비어있으면 null
createData.dbSourceType = selectedDbSource === "internal" ? "internal" : "external";
createData.dbConnectionId = selectedDbSource === "internal" ? undefined : Number(selectedDbSource);
} else {
// REST API 소스
createData.tableName = `_restapi_${selectedRestApiId}`; // REST API용 가상 테이블명
createData.restApiConnectionId = selectedRestApiId;
createData.restApiEndpoint = restApiEndpoint.trim() || undefined;
createData.restApiJsonPath = restApiJsonPath.trim() || "data";
}
const created = await screenApi.createScreen(createData);
// 날짜 필드 보정
const mapped: ScreenDefinition = {
...created,
createdDate: created.createdDate ? new Date(created.createdDate as any) : new Date(),
updatedDate: created.updatedDate ? new Date(created.updatedDate as any) : new Date(),
} as ScreenDefinition;
onCreated?.(mapped);
onOpenChange(false);
// 폼 초기화
setScreenName("");
setScreenCode("");
setTableName("");
setDescription("");
setSelectedDbSource("internal");
setDataSourceType("database");
setSelectedRestApiId(null);
setRestApiEndpoint("");
setRestApiJsonPath("data");
} catch (e) {
// 필요 시 토스트 추가 가능
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="screenName"></Label>
<Input id="screenName" value={screenName} onChange={(e) => setScreenName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="화면 설명을 입력하세요 (모달에 표시됨)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="screenCode"> </Label>
<Input
id="screenCode"
value={screenCode}
readOnly
placeholder="자동 생성됩니다..."
className="cursor-not-allowed bg-gray-50"
/>
</div>
{/* 데이터 소스 타입 선택 */}
<div className="space-y-2">
<Label> </Label>
<div className="flex gap-2">
<Button
type="button"
variant={dataSourceType === "database" ? "default" : "outline"}
className="flex-1"
onClick={() => {
setDataSourceType("database");
setSelectedRestApiId(null);
}}
>
<Database className="mr-2 h-4 w-4" />
</Button>
<Button
type="button"
variant={dataSourceType === "restapi" ? "default" : "outline"}
className="flex-1"
onClick={() => {
setDataSourceType("restapi");
setTableName("");
setSelectedDbSource("internal");
}}
>
<Globe className="mr-2 h-4 w-4" />
REST API
</Button>
</div>
</div>
{/* 데이터베이스 소스 설정 */}
{dataSourceType === "database" && (
<>
{/* DB 소스 선택 */}
<div className="space-y-2">
<Label htmlFor="dbSource"> </Label>
<Popover open={openDbSourceCombobox} onOpenChange={setOpenDbSourceCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openDbSourceCombobox}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
<Database className="h-4 w-4" />
{selectedDbSource === "internal"
? "내부 데이터베이스"
: externalConnections.find((conn) => conn.id === selectedDbSource)?.connection_name ||
"선택하세요"}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="데이터베이스 검색..." />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
<CommandItem
value="internal"
onSelect={() => {
setSelectedDbSource("internal");
setTableName("");
setTableSearchTerm("");
setOpenDbSourceCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedDbSource === "internal" ? "opacity-100" : "opacity-0")}
/>
<Database className="mr-2 h-4 w-4 text-blue-500" />
<div className="flex flex-col">
<span className="font-medium"> </span>
<span className="text-xs text-gray-500">PostgreSQL ( )</span>
</div>
</CommandItem>
{externalConnections.map((conn: any) => (
<CommandItem
key={conn.id}
value={`${conn.connection_name} ${conn.db_type}`}
onSelect={() => {
setSelectedDbSource(conn.id);
setTableName("");
setTableSearchTerm("");
setOpenDbSourceCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedDbSource === conn.id ? "opacity-100" : "opacity-0")}
/>
<Database className="mr-2 h-4 w-4 text-green-500" />
<div className="flex flex-col">
<span className="font-medium">{conn.connection_name}</span>
<span className="text-xs text-gray-500">{conn.db_type?.toUpperCase()}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-gray-500"> </p>
</div>
</>
)}
{/* REST API 소스 설정 */}
{dataSourceType === "restapi" && (
<>
{/* REST API 연결 선택 */}
<div className="space-y-2">
<Label htmlFor="restApiConnection">REST API *</Label>
<Popover open={openRestApiCombobox} onOpenChange={setOpenRestApiCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openRestApiCombobox}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
<Globe className="h-4 w-4" />
{selectedRestApiId
? restApiConnections.find((conn) => conn.id === selectedRestApiId)?.connection_name ||
"선택하세요"
: "REST API 연결을 선택하세요"}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="REST API 검색..." />
<CommandList>
<CommandEmpty> REST API .</CommandEmpty>
<CommandGroup>
{restApiConnections.map((conn) => (
<CommandItem
key={conn.id}
value={`${conn.connection_name} ${conn.base_url}`}
onSelect={() => {
setSelectedRestApiId(conn.id!);
setRestApiEndpoint(conn.endpoint_path || "");
setOpenRestApiCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedRestApiId === conn.id ? "opacity-100" : "opacity-0")}
/>
<Globe className="mr-2 h-4 w-4 text-purple-500" />
<div className="flex flex-col">
<span className="font-medium">{conn.connection_name}</span>
<span className="text-xs text-gray-500 truncate max-w-[300px]">{conn.base_url}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-gray-500">
REST API .
<Link href="/admin/externalRestApi" className="ml-1 text-primary hover:underline">
</Link>
</p>
</div>
{/* 엔드포인트 경로 */}
<div className="space-y-2">
<Label htmlFor="restApiEndpoint"> </Label>
<Input
id="restApiEndpoint"
value={restApiEndpoint}
onChange={(e) => setRestApiEndpoint(e.target.value)}
placeholder="/api/data 또는 /users"
/>
<p className="text-xs text-gray-500"> URL ()</p>
</div>
{/* JSON Path */}
<div className="space-y-2">
<Label htmlFor="restApiJsonPath"> (JSON Path)</Label>
<Input
id="restApiJsonPath"
value={restApiJsonPath}
onChange={(e) => setRestApiJsonPath(e.target.value)}
placeholder="data 또는 result.items"
/>
<p className="text-xs text-gray-500">API (: data, result.items)</p>
</div>
</>
)}
{/* 테이블 선택 (데이터베이스 모드일 때만) */}
{dataSourceType === "database" && (
<div className="space-y-2">
<Label htmlFor="tableName"> ()</Label>
<p className="text-muted-foreground text-xs">
.
</p>
<Select
value={tableName}
onValueChange={setTableName}
disabled={loadingExternalTables}
onOpenChange={(open) => {
if (open) {
setTimeout(() => {
searchInputRef.current?.focus();
}, 100);
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={loadingExternalTables ? "로딩 중..." : "(선택 사항) 기본 테이블 선택"} />
</SelectTrigger>
<SelectContent className="max-h-80">
{/* 검색 입력 필드 */}
<div className="sticky top-0 z-10 border-b bg-white p-2" onKeyDown={(e) => e.stopPropagation()}>
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<input
ref={searchInputRef}
type="text"
placeholder="테이블명으로 검색..."
value={tableSearchTerm}
autoFocus
onChange={(e) => {
e.stopPropagation();
setTableSearchTerm(e.target.value);
}}
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onFocus={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-8 w-full rounded-md border px-3 py-2 pr-8 pl-10 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
{tableSearchTerm && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setTableSearchTerm("");
}}
className="hover:text-muted-foreground absolute top-1/2 right-2 h-4 w-4 -translate-y-1/2 transform text-gray-400"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
{/* 테이블 옵션들 */}
<div className="max-h-60 overflow-y-auto">
{selectedDbSource === "internal" ? (
// 내부 DB 테이블 목록
filteredTables.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-gray-500">
{tableSearchTerm ? `"${tableSearchTerm}"에 대한 검색 결과가 없습니다` : "테이블이 없습니다"}
</div>
) : (
filteredTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName} ({table.tableName})
</SelectItem>
))
)
) : // 외부 DB 테이블 목록
filteredExternalTables.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-gray-500">
{tableSearchTerm ? `"${tableSearchTerm}"에 대한 검색 결과가 없습니다` : "테이블이 없습니다"}
</div>
) : (
filteredExternalTables.map((tableName) => (
<SelectItem key={tableName} value={tableName}>
{tableName}
</SelectItem>
))
)}
</div>
</SelectContent>
</Select>
</div>
)}
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
</Button>
<Button onClick={handleSubmit} disabled={!isValid || submitting} variant="default">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}