|
|
|
|
@ -6,7 +6,10 @@ 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 { Search, X } from "lucide-react";
|
|
|
|
|
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 } from "lucide-react";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
|
|
|
|
import { ScreenDefinition } from "@/types/screen";
|
|
|
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
|
|
|
@ -28,6 +31,13 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
|
const [tableSearchTerm, setTableSearchTerm] = useState("");
|
|
|
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
|
|
|
// 외부 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);
|
|
|
|
|
// 화면 코드 자동 생성
|
|
|
|
|
const generateCode = async () => {
|
|
|
|
|
try {
|
|
|
|
|
@ -39,6 +49,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 내부 DB 테이블 목록 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open) return;
|
|
|
|
|
let abort = false;
|
|
|
|
|
@ -48,7 +59,6 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|
|
|
|
if (abort) return;
|
|
|
|
|
setTables(list.map((t) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName })));
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// console.error("테이블 목록 조회 실패", e);
|
|
|
|
|
setTables([]);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
@ -58,6 +68,83 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|
|
|
|
};
|
|
|
|
|
}, [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]);
|
|
|
|
|
|
|
|
|
|
// 외부 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) {
|
|
|
|
|
@ -69,7 +156,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|
|
|
|
return screenName.trim().length > 0 && screenCode.trim().length > 0 && tableName.trim().length > 0;
|
|
|
|
|
}, [screenName, screenCode, tableName]);
|
|
|
|
|
|
|
|
|
|
// 테이블 필터링
|
|
|
|
|
// 테이블 필터링 (내부 DB용)
|
|
|
|
|
const filteredTables = useMemo(() => {
|
|
|
|
|
if (!tableSearchTerm) return tables;
|
|
|
|
|
const searchLower = tableSearchTerm.toLowerCase();
|
|
|
|
|
@ -79,11 +166,20 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|
|
|
|
);
|
|
|
|
|
}, [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 || "*";
|
|
|
|
|
|
|
|
|
|
// DB 소스 정보 추가
|
|
|
|
|
const created = await screenApi.createScreen({
|
|
|
|
|
screenName: screenName.trim(),
|
|
|
|
|
screenCode: screenCode.trim(),
|
|
|
|
|
@ -91,6 +187,8 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|
|
|
|
companyCode,
|
|
|
|
|
description: description.trim() || undefined,
|
|
|
|
|
createdBy: (user as any)?.userId,
|
|
|
|
|
dbSourceType: selectedDbSource === "internal" ? "internal" : "external",
|
|
|
|
|
dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource),
|
|
|
|
|
} as any);
|
|
|
|
|
|
|
|
|
|
// 날짜 필드 보정
|
|
|
|
|
@ -106,8 +204,8 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|
|
|
|
setScreenCode("");
|
|
|
|
|
setTableName("");
|
|
|
|
|
setDescription("");
|
|
|
|
|
setSelectedDbSource("internal");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// console.error("화면 생성 실패", e);
|
|
|
|
|
// 필요 시 토스트 추가 가능
|
|
|
|
|
} finally {
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
@ -136,14 +234,90 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|
|
|
|
className="cursor-not-allowed bg-gray-50"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 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>
|
|
|
|
|
|
|
|
|
|
{/* 테이블 선택 */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="tableName">테이블</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={tableName}
|
|
|
|
|
onValueChange={setTableName}
|
|
|
|
|
disabled={loadingExternalTables}
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
if (open) {
|
|
|
|
|
// Select가 열릴 때 검색창에 포커스
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
searchInputRef.current?.focus();
|
|
|
|
|
}, 100);
|
|
|
|
|
@ -151,17 +325,11 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="w-full">
|
|
|
|
|
<SelectValue placeholder="테이블을 선택하세요" />
|
|
|
|
|
<SelectValue placeholder={loadingExternalTables ? "로딩 중..." : "테이블을 선택하세요"} />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent className="max-h-80">
|
|
|
|
|
{/* 검색 입력 필드 */}
|
|
|
|
|
<div
|
|
|
|
|
className="sticky top-0 z-10 border-b bg-white p-2"
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
// 이 div 내에서 발생하는 모든 키 이벤트를 차단
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<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
|
|
|
|
|
@ -174,10 +342,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setTableSearchTerm(e.target.value);
|
|
|
|
|
}}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
// 이벤트가 Select로 전파되지 않도록 완전 차단
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
}}
|
|
|
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
onFocus={(e) => e.stopPropagation()}
|
|
|
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
|
|
|
@ -200,14 +365,28 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|
|
|
|
|
|
|
|
|
{/* 테이블 옵션들 */}
|
|
|
|
|
<div className="max-h-60 overflow-y-auto">
|
|
|
|
|
{filteredTables.length === 0 ? (
|
|
|
|
|
{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>
|
|
|
|
|
) : (
|
|
|
|
|
filteredTables.map((table) => (
|
|
|
|
|
<SelectItem key={table.tableName} value={table.tableName}>
|
|
|
|
|
{table.displayName} ({table.tableName})
|
|
|
|
|
filteredExternalTables.map((tableName) => (
|
|
|
|
|
<SelectItem key={tableName} value={tableName}>
|
|
|
|
|
{tableName}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
@ -215,6 +394,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="description">설명</Label>
|
|
|
|
|
<Input id="description" value={description} onChange={(e) => setDescription(e.target.value)} />
|
|
|
|
|
|