[agent-pipeline] pipe-20260309112447-f5iu round-3

This commit is contained in:
DDD1542 2026-03-09 21:04:33 +09:00
parent e41df3b922
commit 159e7768bb
2 changed files with 287 additions and 242 deletions

View File

@ -3,10 +3,8 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Plus, Search, Edit, Trash2, TestTube, Filter } from "lucide-react"; import { Plus, Search, Edit, Trash2, TestTube } from "lucide-react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils"; import { showErrorToast } from "@/lib/utils/toastUtils";
import { import {
@ -29,9 +27,16 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
import { ScrollToTop } from "@/components/common/ScrollToTop";
// API 응답에 실제로 포함되는 필드를 위한 확장 타입
type ExternalCallConfigWithDate = ExternalCallConfig & {
created_date?: string;
};
export default function ExternalCallConfigsPage() { export default function ExternalCallConfigsPage() {
const [configs, setConfigs] = useState<ExternalCallConfig[]>([]); const [configs, setConfigs] = useState<ExternalCallConfigWithDate[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [filter, setFilter] = useState<ExternalCallConfigFilter>({ const [filter, setFilter] = useState<ExternalCallConfigFilter>({
@ -50,15 +55,17 @@ export default function ExternalCallConfigsPage() {
const fetchConfigs = async () => { const fetchConfigs = async () => {
try { try {
setLoading(true); setLoading(true);
const response = await ExternalCallConfigAPI.getConfigs({ const filterWithSearch: Record<string, string | undefined> = { ...filter };
...filter, const trimmed = searchQuery.trim();
search: searchQuery.trim() || undefined, if (trimmed) {
}); filterWithSearch.search = trimmed;
}
const response = await ExternalCallConfigAPI.getConfigs(filterWithSearch as ExternalCallConfigFilter);
if (response.success) { if (response.success) {
setConfigs(response.data || []); setConfigs((response.data || []) as ExternalCallConfigWithDate[]);
} else { } else {
showErrorToast("외부 호출 설정 조회에 실패했습니다", response.message, { showErrorToast("외부 호출 설정 조회에 실패했습니다", response.error, {
guidance: "네트워크 연결을 확인하고 다시 시도해 주세요.", guidance: "네트워크 연결을 확인하고 다시 시도해 주세요.",
}); });
} }
@ -72,9 +79,10 @@ export default function ExternalCallConfigsPage() {
} }
}; };
// 초기 로드 및 필터/검색 변경 시 재조회 // 초기 로드 및 필터 변경 시 재조회
useEffect(() => { useEffect(() => {
fetchConfigs(); fetchConfigs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filter]); }, [filter]);
// 검색 실행 // 검색 실행
@ -118,7 +126,7 @@ export default function ExternalCallConfigsPage() {
toast.success("외부 호출 설정이 삭제되었습니다."); toast.success("외부 호출 설정이 삭제되었습니다.");
fetchConfigs(); fetchConfigs();
} else { } else {
showErrorToast("외부 호출 설정 삭제에 실패했습니다", response.message, { showErrorToast("외부 호출 설정 삭제에 실패했습니다", response.error, {
guidance: "잠시 후 다시 시도해 주세요.", guidance: "잠시 후 다시 시도해 주세요.",
}); });
} }
@ -140,10 +148,10 @@ export default function ExternalCallConfigsPage() {
try { try {
const response = await ExternalCallConfigAPI.testConfig(config.id); const response = await ExternalCallConfigAPI.testConfig(config.id);
if (response.success && response.data?.success) { if (response.success) {
toast.success(`테스트 성공: ${response.data.message}`); toast.success(`테스트 성공: ${response.message || "정상"}`);
} else { } else {
toast.error(`테스트 실패: ${response.data?.message || response.message}`); toast.error(`테스트 실패: ${response.message || response.error || "알 수 없는 오류"}`);
} }
} catch (error) { } catch (error) {
console.error("외부 호출 설정 테스트 오류:", error); console.error("외부 호출 설정 테스트 오류:", error);
@ -171,244 +179,283 @@ export default function ExternalCallConfigsPage() {
return API_TYPE_OPTIONS.find((option) => option.value === apiType)?.label || apiType; return API_TYPE_OPTIONS.find((option) => option.value === apiType)?.label || apiType;
}; };
// ResponsiveDataView 컬럼 정의
const columns: RDVColumn<ExternalCallConfigWithDate>[] = [
{
key: "config_name",
label: "설정명",
render: (_v, row) => <span className="font-medium">{row.config_name}</span>,
},
{
key: "call_type",
label: "호출 타입",
width: "120px",
render: (_v, row) => <Badge variant="outline">{getCallTypeLabel(row.call_type)}</Badge>,
},
{
key: "api_type",
label: "API 타입",
width: "120px",
render: (_v, row) =>
row.api_type ? (
<Badge variant="secondary">{getApiTypeLabel(row.api_type)}</Badge>
) : (
<span className="text-muted-foreground">-</span>
),
},
{
key: "description",
label: "설명",
render: (_v, row) =>
row.description ? (
<span className="block max-w-xs truncate text-muted-foreground" title={row.description}>
{row.description}
</span>
) : (
<span className="text-muted-foreground">-</span>
),
},
{
key: "is_active",
label: "상태",
width: "80px",
render: (_v, row) => (
<Badge variant={row.is_active === "Y" ? "default" : "destructive"}>
{row.is_active === "Y" ? "활성" : "비활성"}
</Badge>
),
},
{
key: "created_date",
label: "생성일",
width: "120px",
render: (_v, row) =>
row.created_date ? new Date(row.created_date).toLocaleDateString() : "-",
},
];
// 모바일 카드 필드 정의
const cardFields: RDVCardField<ExternalCallConfigWithDate>[] = [
{
label: "호출 타입",
render: (c) => <Badge variant="outline">{getCallTypeLabel(c.call_type)}</Badge>,
},
{
label: "API 타입",
render: (c) =>
c.api_type ? (
<Badge variant="secondary">{getApiTypeLabel(c.api_type)}</Badge>
) : (
<span className="text-muted-foreground">-</span>
),
},
{
label: "설명",
render: (c) => (
<span className="max-w-[200px] truncate">{c.description || "-"}</span>
),
},
{
label: "생성일",
render: (c) =>
c.created_date ? new Date(c.created_date).toLocaleDateString() : "-",
},
];
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6"> <div className="space-y-6 p-4 sm:p-6">
{/* 페이지 헤더 */} {/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4"> <div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground">Discord, Slack, .</p> <p className="text-sm text-muted-foreground">Discord, Slack, .</p>
</div> </div>
{/* 검색 및 필터 영역 */} {/* 검색 및 필터 영역 (반응형) */}
<div className="space-y-4"> <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
{/* 첫 번째 줄: 검색 + 추가 버튼 */} <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="relative w-full sm:w-[300px]">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<div className="w-full sm:w-[320px]"> <Input
<div className="relative"> placeholder="설정 이름 또는 설명으로 검색..."
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> value={searchQuery}
<Input onChange={(e) => setSearchQuery(e.target.value)}
placeholder="설정 이름 또는 설명으로 검색..." onKeyPress={handleSearchKeyPress}
value={searchQuery} className="h-10 pl-10 text-sm"
onChange={(e) => setSearchQuery(e.target.value)} />
onKeyPress={handleSearchKeyPress}
className="h-10 pl-10 text-sm"
/>
</div>
</div>
<Button onClick={handleSearch} variant="outline" className="h-10 gap-2 text-sm font-medium">
<Search className="h-4 w-4" />
</Button>
</div> </div>
<Button onClick={handleAddConfig} className="h-10 gap-2 text-sm font-medium"> <Button onClick={handleSearch} variant="outline" className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" /> <Search className="h-4 w-4" />
</Button> </Button>
</div> </div>
<Button onClick={handleAddConfig} className="h-10 gap-2 text-sm font-medium">
{/* 두 번째 줄: 필터 */} <Plus className="h-4 w-4" />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Select </Button>
value={filter.call_type || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
call_type: value === "all" ? undefined : value,
}))
}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="호출 타입" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{CALL_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filter.api_type || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
api_type: value === "all" ? undefined : value,
}))
}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="API 타입" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{API_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filter.is_active || "Y"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
is_active: value,
}))
}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div> </div>
{/* 설정 목록 */} {/* 필터 영역 */}
<div className="rounded-lg border bg-card shadow-sm"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{loading ? ( <Select
// 로딩 상태 value={filter.call_type || "all"}
<div className="flex h-64 items-center justify-center"> onValueChange={(value) =>
<div className="text-sm text-muted-foreground"> ...</div> setFilter((prev) => ({
</div> ...prev,
) : configs.length === 0 ? ( call_type: value === "all" ? undefined : value,
// 빈 상태 }))
<div className="flex h-64 flex-col items-center justify-center"> }
<div className="flex flex-col items-center gap-2 text-center"> >
<p className="text-sm text-muted-foreground"> .</p> <SelectTrigger className="h-10">
<p className="text-xs text-muted-foreground"> .</p> <SelectValue placeholder="호출 타입" />
</div> </SelectTrigger>
</div> <SelectContent>
) : ( <SelectItem value="all"></SelectItem>
// 설정 테이블 목록 {CALL_TYPE_OPTIONS.map((option) => (
<Table> <SelectItem key={option.value} value={option.value}>
<TableHeader> {option.label}
<TableRow className="border-b bg-muted/50 hover:bg-muted/50"> </SelectItem>
<TableHead className="h-12 text-sm font-semibold"></TableHead> ))}
<TableHead className="h-12 text-sm font-semibold"> </TableHead> </SelectContent>
<TableHead className="h-12 text-sm font-semibold">API </TableHead> </Select>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead> <Select
<TableHead className="h-12 text-sm font-semibold"></TableHead> value={filter.api_type || "all"}
<TableHead className="h-12 text-center text-sm font-semibold"></TableHead> onValueChange={(value) =>
</TableRow> setFilter((prev) => ({
</TableHeader> ...prev,
<TableBody> api_type: value === "all" ? undefined : value,
{configs.map((config) => ( }))
<TableRow key={config.id} className="border-b transition-colors hover:bg-muted/50"> }
<TableCell className="h-16 text-sm font-medium">{config.config_name}</TableCell> >
<TableCell className="h-16 text-sm"> <SelectTrigger className="h-10">
<Badge variant="outline">{getCallTypeLabel(config.call_type)}</Badge> <SelectValue placeholder="API 타입" />
</TableCell> </SelectTrigger>
<TableCell className="h-16 text-sm"> <SelectContent>
{config.api_type ? ( <SelectItem value="all"></SelectItem>
<Badge variant="secondary">{getApiTypeLabel(config.api_type)}</Badge> {API_TYPE_OPTIONS.map((option) => (
) : ( <SelectItem key={option.value} value={option.value}>
<span className="text-muted-foreground">-</span> {option.label}
)} </SelectItem>
</TableCell> ))}
<TableCell className="h-16 text-sm"> </SelectContent>
<div className="max-w-xs"> </Select>
{config.description ? (
<span className="block truncate text-muted-foreground" title={config.description}> <Select
{config.description} value={filter.is_active || "Y"}
</span> onValueChange={(value) =>
) : ( setFilter((prev) => ({
<span className="text-muted-foreground">-</span> ...prev,
)} is_active: value,
</div> }))
</TableCell> }
<TableCell className="h-16 text-sm"> >
<Badge variant={config.is_active === "Y" ? "default" : "destructive"}> <SelectTrigger className="h-10">
{config.is_active === "Y" ? "활성" : "비활성"} <SelectValue placeholder="상태" />
</Badge> </SelectTrigger>
</TableCell> <SelectContent>
<TableCell className="h-16 text-sm text-muted-foreground"> {ACTIVE_STATUS_OPTIONS.map((option) => (
{config.created_date ? new Date(config.created_date).toLocaleDateString() : "-"} <SelectItem key={option.value} value={option.value}>
</TableCell> {option.label}
<TableCell className="h-16 text-sm"> </SelectItem>
<div className="flex justify-center gap-1"> ))}
<Button </SelectContent>
variant="ghost" </Select>
size="icon" </div>
className="h-8 w-8"
onClick={() => handleTestConfig(config)} {/* 설정 목록 (ResponsiveDataView) */}
title="테스트" <ResponsiveDataView<ExternalCallConfigWithDate>
> data={configs}
<TestTube className="h-4 w-4" /> columns={columns}
</Button> keyExtractor={(c) => String(c.id || c.config_name)}
<Button isLoading={loading}
variant="ghost" emptyMessage="등록된 외부 호출 설정이 없습니다."
size="icon" skeletonCount={5}
className="h-8 w-8" cardTitle={(c) => c.config_name}
onClick={() => handleEditConfig(config)} cardSubtitle={(c) => c.description || "설명 없음"}
title="편집" cardHeaderRight={(c) => (
> <Badge variant={c.is_active === "Y" ? "default" : "destructive"}>
<Edit className="h-4 w-4" /> {c.is_active === "Y" ? "활성" : "비활성"}
</Button> </Badge>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handleDeleteConfig(config)}
title="삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)} )}
</div> cardFields={cardFields}
renderActions={(c) => (
<>
<Button
variant="outline"
size="sm"
className="h-9 flex-1 gap-2 text-sm"
onClick={(e) => {
e.stopPropagation();
handleTestConfig(c);
}}
>
<TestTube className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="h-9 flex-1 gap-2 text-sm"
onClick={(e) => {
e.stopPropagation();
handleEditConfig(c);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 gap-2 text-sm"
onClick={(e) => {
e.stopPropagation();
handleDeleteConfig(c);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
actionsWidth="200px"
/>
{/* 외부 호출 설정 모달 */} {/* 외부 호출 설정 모달 */}
<ExternalCallConfigModal <ExternalCallConfigModal
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)} onClose={() => setIsModalOpen(false)}
onSave={handleModalSave} onSave={handleModalSave}
editingConfig={editingConfig} editingConfig={editingConfig}
/> />
{/* 삭제 확인 다이얼로그 */} {/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]"> <AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle> <AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm"> <AlertDialogDescription className="text-xs sm:text-sm">
"{configToDelete?.config_name}" ? &quot;{configToDelete?.config_name}&quot; ?
<br /> . <br /> .
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0"> <AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"> <AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={confirmDeleteConfig} onClick={confirmDeleteConfig}
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm" className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
> >
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div> </div>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div> </div>
); );
} }

View File

@ -33,7 +33,7 @@ import { cn } from "@/lib/utils";
import { formatErrorMessage } from "@/lib/utils/errorUtils"; import { formatErrorMessage } from "@/lib/utils/errorUtils";
import { tableManagementApi } from "@/lib/api/tableManagement"; import { tableManagementApi } from "@/lib/api/tableManagement";
import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; import { ExternalDbConnectionAPI, ExternalDbConnection } from "@/lib/api/externalDbConnection";
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection"; import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView"; import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
@ -56,9 +56,7 @@ export default function FlowManagementPage() {
const [openTableCombobox, setOpenTableCombobox] = useState(false); const [openTableCombobox, setOpenTableCombobox] = useState(false);
// 데이터 소스 타입: "internal" (내부 DB), "external_db_숫자" (외부 DB), "restapi_숫자" (REST API) // 데이터 소스 타입: "internal" (내부 DB), "external_db_숫자" (외부 DB), "restapi_숫자" (REST API)
const [selectedDbSource, setSelectedDbSource] = useState<string>("internal"); const [selectedDbSource, setSelectedDbSource] = useState<string>("internal");
const [externalConnections, setExternalConnections] = useState< const [externalConnections, setExternalConnections] = useState<ExternalDbConnection[]>([]);
Array<{ id: number; connection_name: string; db_type: string }>
>([]);
const [externalTableList, setExternalTableList] = useState<string[]>([]); const [externalTableList, setExternalTableList] = useState<string[]>([]);
const [loadingExternalTables, setLoadingExternalTables] = useState(false); const [loadingExternalTables, setLoadingExternalTables] = useState(false);
@ -255,7 +253,7 @@ export default function FlowManagementPage() {
.map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) => .map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) =>
typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name, typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name,
) )
.filter(Boolean); .filter((v): v is string => Boolean(v));
setMultiDbTableLists(prev => ({ ...prev, [connectionId]: tableNames })); setMultiDbTableLists(prev => ({ ...prev, [connectionId]: tableNames }));
} }
} catch (error) { } catch (error) {
@ -447,7 +445,7 @@ export default function FlowManagementPage() {
} }
console.log("✅ Calling createFlowDefinition with:", requestData); console.log("✅ Calling createFlowDefinition with:", requestData);
const response = await createFlowDefinition(requestData as Parameters<typeof createFlowDefinition>[0]); const response = await createFlowDefinition(requestData as unknown as Parameters<typeof createFlowDefinition>[0]);
if (response.success && response.data) { if (response.success && response.data) {
toast({ toast({
title: "생성 완료", title: "생성 완료",