[agent-pipeline] pipe-20260309112447-f5iu round-2
This commit is contained in:
parent
074abfcdb0
commit
e41df3b922
|
|
@ -4,10 +4,8 @@ import React, { useState, useEffect } from "react";
|
||||||
import { Plus, Search, Pencil, Trash2, Database, Terminal, Globe } from "lucide-react";
|
import { Plus, Search, Pencil, Trash2, Database, Terminal, Globe } from "lucide-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 } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -24,11 +22,12 @@ import {
|
||||||
ExternalDbConnectionAPI,
|
ExternalDbConnectionAPI,
|
||||||
ExternalDbConnection,
|
ExternalDbConnection,
|
||||||
ExternalDbConnectionFilter,
|
ExternalDbConnectionFilter,
|
||||||
ConnectionTestRequest,
|
|
||||||
} from "@/lib/api/externalDbConnection";
|
} from "@/lib/api/externalDbConnection";
|
||||||
import { ExternalDbConnectionModal } from "@/components/admin/ExternalDbConnectionModal";
|
import { ExternalDbConnectionModal } from "@/components/admin/ExternalDbConnectionModal";
|
||||||
import { SqlQueryModal } from "@/components/admin/SqlQueryModal";
|
import { SqlQueryModal } from "@/components/admin/SqlQueryModal";
|
||||||
import { RestApiConnectionList } from "@/components/admin/RestApiConnectionList";
|
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";
|
type ConnectionTabType = "database" | "rest-api";
|
||||||
|
|
||||||
|
|
@ -102,7 +101,6 @@ export default function ExternalConnectionsPage() {
|
||||||
setSupportedDbTypes([{ value: "ALL", label: "전체" }, ...types]);
|
setSupportedDbTypes([{ value: "ALL", label: "전체" }, ...types]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("지원 DB 타입 로딩 오류:", error);
|
console.error("지원 DB 타입 로딩 오류:", error);
|
||||||
// 실패 시 기본값 사용
|
|
||||||
setSupportedDbTypes([
|
setSupportedDbTypes([
|
||||||
{ value: "ALL", label: "전체" },
|
{ value: "ALL", label: "전체" },
|
||||||
{ value: "mysql", label: "MySQL" },
|
{ value: "mysql", label: "MySQL" },
|
||||||
|
|
@ -114,45 +112,36 @@ export default function ExternalConnectionsPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 초기 데이터 로딩
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConnections();
|
loadConnections();
|
||||||
loadSupportedDbTypes();
|
loadSupportedDbTypes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 필터 변경 시 데이터 재로딩
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConnections();
|
loadConnections();
|
||||||
}, [searchTerm, dbTypeFilter, activeStatusFilter]);
|
}, [searchTerm, dbTypeFilter, activeStatusFilter]);
|
||||||
|
|
||||||
// 새 연결 추가
|
|
||||||
const handleAddConnection = () => {
|
const handleAddConnection = () => {
|
||||||
setEditingConnection(undefined);
|
setEditingConnection(undefined);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 연결 편집
|
|
||||||
const handleEditConnection = (connection: ExternalDbConnection) => {
|
const handleEditConnection = (connection: ExternalDbConnection) => {
|
||||||
setEditingConnection(connection);
|
setEditingConnection(connection);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 연결 삭제 확인 다이얼로그 열기
|
|
||||||
const handleDeleteConnection = (connection: ExternalDbConnection) => {
|
const handleDeleteConnection = (connection: ExternalDbConnection) => {
|
||||||
setConnectionToDelete(connection);
|
setConnectionToDelete(connection);
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 연결 삭제 실행
|
|
||||||
const confirmDeleteConnection = async () => {
|
const confirmDeleteConnection = async () => {
|
||||||
if (!connectionToDelete?.id) return;
|
if (!connectionToDelete?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ExternalDbConnectionAPI.deleteConnection(connectionToDelete.id);
|
await ExternalDbConnectionAPI.deleteConnection(connectionToDelete.id);
|
||||||
toast({
|
toast({ title: "성공", description: "연결이 삭제되었습니다." });
|
||||||
title: "성공",
|
|
||||||
description: "연결이 삭제되었습니다.",
|
|
||||||
});
|
|
||||||
loadConnections();
|
loadConnections();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("연결 삭제 오류:", error);
|
console.error("연결 삭제 오류:", error);
|
||||||
|
|
@ -167,13 +156,11 @@ export default function ExternalConnectionsPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 연결 삭제 취소
|
|
||||||
const cancelDeleteConnection = () => {
|
const cancelDeleteConnection = () => {
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setConnectionToDelete(null);
|
setConnectionToDelete(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 연결 테스트
|
|
||||||
const handleTestConnection = async (connection: ExternalDbConnection) => {
|
const handleTestConnection = async (connection: ExternalDbConnection) => {
|
||||||
if (!connection.id) return;
|
if (!connection.id) return;
|
||||||
|
|
||||||
|
|
@ -181,14 +168,10 @@ export default function ExternalConnectionsPage() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await ExternalDbConnectionAPI.testConnection(connection.id);
|
const result = await ExternalDbConnectionAPI.testConnection(connection.id);
|
||||||
|
|
||||||
setTestResults((prev) => new Map(prev).set(connection.id!, result.success));
|
setTestResults((prev) => new Map(prev).set(connection.id!, result.success));
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast({
|
toast({ title: "연결 성공", description: `${connection.connection_name} 연결이 성공했습니다.` });
|
||||||
title: "연결 성공",
|
|
||||||
description: `${connection.connection_name} 연결이 성공했습니다.`,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: "연결 실패",
|
title: "연결 실패",
|
||||||
|
|
@ -199,11 +182,7 @@ export default function ExternalConnectionsPage() {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("연결 테스트 오류:", error);
|
console.error("연결 테스트 오류:", error);
|
||||||
setTestResults((prev) => new Map(prev).set(connection.id!, false));
|
setTestResults((prev) => new Map(prev).set(connection.id!, false));
|
||||||
toast({
|
toast({ title: "연결 테스트 오류", description: "연결 테스트 중 오류가 발생했습니다.", variant: "destructive" });
|
||||||
title: "연결 테스트 오류",
|
|
||||||
description: "연결 테스트 중 오류가 발생했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setTestingConnections((prev) => {
|
setTestingConnections((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
|
|
@ -213,19 +192,77 @@ export default function ExternalConnectionsPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 모달 저장 처리
|
|
||||||
const handleModalSave = () => {
|
const handleModalSave = () => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setEditingConnection(undefined);
|
setEditingConnection(undefined);
|
||||||
loadConnections();
|
loadConnections();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 모달 취소 처리
|
|
||||||
const handleModalCancel = () => {
|
const handleModalCancel = () => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setEditingConnection(undefined);
|
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-2">
|
||||||
|
<Button variant="outline" size="sm"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleTestConnection(row); }}
|
||||||
|
disabled={testingConnections.has(row.id!)}
|
||||||
|
className="h-9 text-sm">
|
||||||
|
{testingConnections.has(row.id!) ? "테스트 중..." : "테스트"}
|
||||||
|
</Button>
|
||||||
|
{testResults.has(row.id!) && (
|
||||||
|
<Badge variant={testResults.get(row.id!) ? "default" : "destructive"}>
|
||||||
|
{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 (
|
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-6">
|
||||||
|
|
@ -237,7 +274,7 @@ export default function ExternalConnectionsPage() {
|
||||||
|
|
||||||
{/* 탭 */}
|
{/* 탭 */}
|
||||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)}>
|
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)}>
|
||||||
<TabsList className="grid w-[400px] grid-cols-2">
|
<TabsList className="grid w-full max-w-[400px] grid-cols-2">
|
||||||
<TabsTrigger value="database" className="flex items-center gap-2">
|
<TabsTrigger value="database" className="flex items-center gap-2">
|
||||||
<Database className="h-4 w-4" />
|
<Database className="h-4 w-4" />
|
||||||
데이터베이스 연결
|
데이터베이스 연결
|
||||||
|
|
@ -252,8 +289,7 @@ export default function ExternalConnectionsPage() {
|
||||||
<TabsContent value="database" className="space-y-6">
|
<TabsContent value="database" className="space-y-6">
|
||||||
{/* 검색 및 필터 */}
|
{/* 검색 및 필터 */}
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
{/* 검색 */}
|
|
||||||
<div className="relative w-full sm:w-[300px]">
|
<div className="relative w-full sm:w-[300px]">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -263,8 +299,6 @@ export default function ExternalConnectionsPage() {
|
||||||
className="h-10 pl-10 text-sm"
|
className="h-10 pl-10 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* DB 타입 필터 */}
|
|
||||||
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
|
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
|
||||||
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||||
<SelectValue placeholder="DB 타입" />
|
<SelectValue placeholder="DB 타입" />
|
||||||
|
|
@ -277,8 +311,6 @@ export default function ExternalConnectionsPage() {
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* 활성 상태 필터 */}
|
|
||||||
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
||||||
<SelectTrigger className="h-10 w-full sm:w-[120px]">
|
<SelectTrigger className="h-10 w-full sm:w-[120px]">
|
||||||
<SelectValue placeholder="상태" />
|
<SelectValue placeholder="상태" />
|
||||||
|
|
@ -292,126 +324,63 @@ export default function ExternalConnectionsPage() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 추가 버튼 */}
|
|
||||||
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
새 연결 추가
|
새 연결 추가
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 연결 목록 */}
|
{/* 연결 목록 - ResponsiveDataView */}
|
||||||
{loading ? (
|
<ResponsiveDataView
|
||||||
<div className="flex h-64 items-center justify-center bg-card">
|
data={connections}
|
||||||
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
columns={columns}
|
||||||
</div>
|
keyExtractor={(c) => String(c.id || c.connection_name)}
|
||||||
) : connections.length === 0 ? (
|
isLoading={loading}
|
||||||
<div className="flex h-64 flex-col items-center justify-center bg-card">
|
emptyMessage="등록된 연결이 없습니다"
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
skeletonCount={5}
|
||||||
<p className="text-sm text-muted-foreground">등록된 연결이 없습니다</p>
|
cardTitle={(c) => c.connection_name}
|
||||||
</div>
|
cardSubtitle={(c) => <span className="font-mono text-xs">{c.host}:{c.port}/{c.database_name}</span>}
|
||||||
</div>
|
cardHeaderRight={(c) => (
|
||||||
) : (
|
<Badge variant={c.is_active === "Y" ? "default" : "secondary"}>
|
||||||
<div className="bg-card">
|
{c.is_active === "Y" ? "활성" : "비활성"}
|
||||||
<Table>
|
</Badge>
|
||||||
<TableHeader>
|
)}
|
||||||
<TableRow className="bg-background">
|
cardFields={cardFields}
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">연결명</TableHead>
|
renderActions={(c) => (
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">회사</TableHead>
|
<>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">DB 타입</TableHead>
|
<Button variant="outline" size="sm"
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">호스트:포트</TableHead>
|
onClick={(e) => { e.stopPropagation(); handleTestConnection(c); }}
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">데이터베이스</TableHead>
|
disabled={testingConnections.has(c.id!)}
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">사용자</TableHead>
|
className="h-9 flex-1 gap-2 text-sm">
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">상태</TableHead>
|
{testingConnections.has(c.id!) ? "테스트 중..." : "테스트"}
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">생성일</TableHead>
|
</Button>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">연결 테스트</TableHead>
|
<Button variant="outline" size="sm"
|
||||||
<TableHead className="h-12 px-6 py-3 text-right text-sm font-semibold">작업</TableHead>
|
onClick={(e) => {
|
||||||
</TableRow>
|
e.stopPropagation();
|
||||||
</TableHeader>
|
setSelectedConnection(c);
|
||||||
<TableBody>
|
setSqlModalOpen(true);
|
||||||
{connections.map((connection) => (
|
}}
|
||||||
<TableRow key={connection.id} className="bg-background transition-colors hover:bg-muted/50">
|
className="h-9 flex-1 gap-2 text-sm">
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<Terminal className="h-4 w-4" />
|
||||||
<div className="font-medium">{connection.connection_name}</div>
|
SQL
|
||||||
</TableCell>
|
</Button>
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<Button variant="outline" size="sm"
|
||||||
{(connection as any).company_name || connection.company_code}
|
onClick={(e) => { e.stopPropagation(); handleEditConnection(c); }}
|
||||||
</TableCell>
|
className="h-9 flex-1 gap-2 text-sm">
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<Pencil className="h-4 w-4" />
|
||||||
<Badge variant="outline">
|
편집
|
||||||
{DB_TYPE_LABELS[connection.db_type] || connection.db_type}
|
</Button>
|
||||||
</Badge>
|
<Button variant="outline" size="sm"
|
||||||
</TableCell>
|
onClick={(e) => { e.stopPropagation(); handleDeleteConnection(c); }}
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 flex-1 gap-2 text-sm">
|
||||||
{connection.host}:{connection.port}
|
<Trash2 className="h-4 w-4" />
|
||||||
</TableCell>
|
삭제
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{connection.database_name}</TableCell>
|
</Button>
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{connection.username}</TableCell>
|
</>
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
)}
|
||||||
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"}>
|
actionsLabel="작업"
|
||||||
{connection.is_active === "Y" ? "활성" : "비활성"}
|
actionsWidth="180px"
|
||||||
</Badge>
|
/>
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
|
||||||
{connection.created_date ? new Date(connection.created_date).toLocaleDateString() : "N/A"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleTestConnection(connection)}
|
|
||||||
disabled={testingConnections.has(connection.id!)}
|
|
||||||
className="h-9 text-sm"
|
|
||||||
>
|
|
||||||
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
|
|
||||||
</Button>
|
|
||||||
{testResults.has(connection.id!) && (
|
|
||||||
<Badge variant={testResults.get(connection.id!) ? "default" : "destructive"}>
|
|
||||||
{testResults.get(connection.id!) ? "성공" : "실패"}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 px-6 py-3 text-right">
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
console.log("SQL 쿼리 실행 버튼 클릭 - connection:", connection);
|
|
||||||
setSelectedConnection(connection);
|
|
||||||
setSqlModalOpen(true);
|
|
||||||
}}
|
|
||||||
className="h-8 w-8"
|
|
||||||
title="SQL 쿼리 실행"
|
|
||||||
>
|
|
||||||
<Terminal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => handleEditConnection(connection)}
|
|
||||||
className="h-8 w-8"
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => handleDeleteConnection(connection)}
|
|
||||||
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 연결 설정 모달 */}
|
{/* 연결 설정 모달 */}
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
|
|
@ -430,7 +399,7 @@ export default function ExternalConnectionsPage() {
|
||||||
<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">
|
||||||
"{connectionToDelete?.connection_name}" 연결을 삭제하시겠습니까?
|
“{connectionToDelete?.connection_name}” 연결을 삭제하시겠습니까?
|
||||||
<br />
|
<br />
|
||||||
이 작업은 되돌릴 수 없습니다.
|
이 작업은 되돌릴 수 없습니다.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
|
|
@ -472,6 +441,7 @@ export default function ExternalConnectionsPage() {
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
<ScrollToTop />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Plus, Edit2, Trash2, Workflow, Table, Calendar, User, Check, ChevronsUpDown } from "lucide-react";
|
import { Plus, Edit2, Trash2, Workflow, Search, Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
|
|
@ -35,6 +35,7 @@ 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 } 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";
|
||||||
|
|
||||||
export default function FlowManagementPage() {
|
export default function FlowManagementPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -513,6 +514,81 @@ export default function FlowManagementPage() {
|
||||||
router.push(`/admin/flow-management/${flowId}`);
|
router.push(`/admin/flow-management/${flowId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 검색 필터 상태
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
// 검색 필터링된 플로우 목록
|
||||||
|
const filteredFlows = searchText
|
||||||
|
? flows.filter(
|
||||||
|
(f) =>
|
||||||
|
f.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
f.tableName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
f.description?.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
)
|
||||||
|
: flows;
|
||||||
|
|
||||||
|
// ResponsiveDataView 컬럼 정의
|
||||||
|
const columns: RDVColumn<FlowDefinition>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
label: "플로우명",
|
||||||
|
render: (_v, row) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(row.id)}
|
||||||
|
className="hover:text-primary truncate text-left font-medium transition-colors hover:underline"
|
||||||
|
>
|
||||||
|
{row.name}
|
||||||
|
</button>
|
||||||
|
{row.isActive && (
|
||||||
|
<Badge variant="default" className="shrink-0">활성</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "description",
|
||||||
|
label: "설명",
|
||||||
|
render: (_v, row) => (
|
||||||
|
<span className="text-muted-foreground line-clamp-1">
|
||||||
|
{row.description || "-"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tableName",
|
||||||
|
label: "연결 테이블",
|
||||||
|
render: (_v, row) => (
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">{row.tableName}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdBy",
|
||||||
|
label: "생성자",
|
||||||
|
width: "120px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAt",
|
||||||
|
label: "수정일",
|
||||||
|
width: "120px",
|
||||||
|
render: (_v, row) => new Date(row.updatedAt).toLocaleDateString("ko-KR"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 모바일 카드 필드 정의
|
||||||
|
const cardFields: RDVCardField<FlowDefinition>[] = [
|
||||||
|
{ label: "설명", render: (f) => f.description || "-" },
|
||||||
|
{
|
||||||
|
label: "테이블",
|
||||||
|
render: (f) => <span className="font-mono text-xs">{f.tableName}</span>,
|
||||||
|
},
|
||||||
|
{ label: "생성자", render: (f) => f.createdBy },
|
||||||
|
{
|
||||||
|
label: "수정일",
|
||||||
|
render: (f) => new Date(f.updatedAt).toLocaleDateString("ko-KR"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex min-h-screen flex-col">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-4 sm:p-6">
|
<div className="space-y-6 p-4 sm:p-6">
|
||||||
|
|
@ -522,123 +598,74 @@ export default function FlowManagementPage() {
|
||||||
<p className="text-muted-foreground text-sm">업무 프로세스 플로우를 생성하고 관리합니다</p>
|
<p className="text-muted-foreground text-sm">업무 프로세스 플로우를 생성하고 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 액션 버튼 영역 */}
|
{/* 검색 툴바 (반응형) */}
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative w-full sm:w-[300px]">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="플로우명, 테이블, 설명으로 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="h-10 pl-10 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground hidden text-sm sm:block">
|
||||||
|
총 <span className="text-foreground font-semibold">{filteredFlows.length}</span> 건
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Button onClick={() => setIsCreateDialogOpen(true)} className="h-10 gap-2 text-sm font-medium">
|
<Button onClick={() => setIsCreateDialogOpen(true)} className="h-10 gap-2 text-sm font-medium">
|
||||||
<Plus className="h-4 w-4" />새 플로우 생성
|
<Plus className="h-4 w-4" />새 플로우 생성
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 플로우 카드 목록 */}
|
{/* 플로우 목록 (ResponsiveDataView) */}
|
||||||
{loading ? (
|
<ResponsiveDataView<FlowDefinition>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
data={filteredFlows}
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
columns={columns}
|
||||||
<div key={index} className="bg-card rounded-lg border p-6 shadow-sm">
|
keyExtractor={(f) => String(f.id)}
|
||||||
<div className="mb-4 space-y-2">
|
isLoading={loading}
|
||||||
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
emptyMessage="생성된 플로우가 없습니다."
|
||||||
<div className="bg-muted h-4 w-full animate-pulse rounded"></div>
|
skeletonCount={6}
|
||||||
<div className="bg-muted h-4 w-3/4 animate-pulse rounded"></div>
|
cardTitle={(f) => f.name}
|
||||||
</div>
|
cardSubtitle={(f) => f.description || "설명 없음"}
|
||||||
<div className="space-y-2 border-t pt-4">
|
cardHeaderRight={(f) =>
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
f.isActive ? (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<Badge variant="default" className="shrink-0">활성</Badge>
|
||||||
<div className="bg-muted h-4 w-4 animate-pulse rounded"></div>
|
) : null
|
||||||
<div className="bg-muted h-4 flex-1 animate-pulse rounded"></div>
|
}
|
||||||
</div>
|
cardFields={cardFields}
|
||||||
))}
|
onRowClick={(f) => handleEdit(f.id)}
|
||||||
</div>
|
renderActions={(f) => (
|
||||||
<div className="mt-4 flex gap-2 border-t pt-4">
|
<>
|
||||||
<div className="bg-muted h-9 flex-1 animate-pulse rounded"></div>
|
<Button
|
||||||
<div className="bg-muted h-9 w-9 animate-pulse rounded"></div>
|
variant="outline"
|
||||||
</div>
|
size="sm"
|
||||||
</div>
|
className="h-9 flex-1 gap-2 text-sm"
|
||||||
))}
|
onClick={(e) => {
|
||||||
</div>
|
e.stopPropagation();
|
||||||
) : flows.length === 0 ? (
|
handleEdit(f.id);
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
}}
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
|
||||||
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-full">
|
|
||||||
<Workflow className="text-muted-foreground h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold">생성된 플로우가 없습니다</h3>
|
|
||||||
<p className="text-muted-foreground max-w-sm text-sm">
|
|
||||||
새 플로우를 생성하여 업무 프로세스를 관리해보세요.
|
|
||||||
</p>
|
|
||||||
<Button onClick={() => setIsCreateDialogOpen(true)} className="mt-4 h-10 gap-2 text-sm font-medium">
|
|
||||||
<Plus className="h-4 w-4" />첫 플로우 만들기
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{flows.map((flow) => (
|
|
||||||
<div
|
|
||||||
key={flow.id}
|
|
||||||
className="bg-card hover:bg-muted/50 cursor-pointer rounded-lg border p-6 shadow-sm transition-colors"
|
|
||||||
onClick={() => handleEdit(flow.id)}
|
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
<Edit2 className="h-4 w-4" />
|
||||||
<div className="mb-4 flex items-start justify-between">
|
편집
|
||||||
<div className="min-w-0 flex-1">
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<Button
|
||||||
<h3 className="truncate text-base font-semibold">{flow.name}</h3>
|
variant="outline"
|
||||||
{flow.isActive && (
|
size="sm"
|
||||||
<Badge className="shrink-0 bg-emerald-500 text-white hover:bg-emerald-600">활성</Badge>
|
className="h-9 w-9 p-0"
|
||||||
)}
|
onClick={(e) => {
|
||||||
</div>
|
e.stopPropagation();
|
||||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">{flow.description || "설명 없음"}</p>
|
setSelectedFlow(f);
|
||||||
</div>
|
setIsDeleteDialogOpen(true);
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
{/* 정보 */}
|
<Trash2 className="h-4 w-4" />
|
||||||
<div className="space-y-2 border-t pt-4">
|
</Button>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
</>
|
||||||
<Table className="text-muted-foreground h-4 w-4 shrink-0" />
|
)}
|
||||||
<span className="text-muted-foreground truncate">{flow.tableName}</span>
|
actionsWidth="160px"
|
||||||
</div>
|
/>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<User className="text-muted-foreground h-4 w-4 shrink-0" />
|
|
||||||
<span className="text-muted-foreground truncate">생성자: {flow.createdBy}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<Calendar className="text-muted-foreground h-4 w-4 shrink-0" />
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{new Date(flow.updatedAt).toLocaleDateString("ko-KR")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 액션 */}
|
|
||||||
<div className="mt-4 flex gap-2 border-t pt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 flex-1 gap-2 text-sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleEdit(flow.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Edit2 className="h-4 w-4" />
|
|
||||||
편집
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 w-9 p-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setSelectedFlow(flow);
|
|
||||||
setIsDeleteDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 생성 다이얼로그 */}
|
{/* 생성 다이얼로그 */}
|
||||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { dashboardApi } from "@/lib/api/dashboard";
|
||||||
import { Dashboard } from "@/lib/api/dashboard";
|
import { Dashboard } from "@/lib/api/dashboard";
|
||||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -16,6 +15,8 @@ import {
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
||||||
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
|
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
|
||||||
|
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
|
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -30,7 +31,7 @@ export default function DashboardListPage() {
|
||||||
|
|
||||||
// 상태 관리
|
// 상태 관리
|
||||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||||
const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
|
@ -83,52 +84,36 @@ export default function DashboardListPage() {
|
||||||
endItem: Math.min(currentPage * pageSize, totalCount),
|
endItem: Math.min(currentPage * pageSize, totalCount),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 페이지 변경 핸들러
|
const handlePageChange = (page: number) => setCurrentPage(page);
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
setCurrentPage(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 페이지 크기 변경 핸들러
|
|
||||||
const handlePageSizeChange = (size: number) => {
|
const handlePageSizeChange = (size: number) => {
|
||||||
setPageSize(size);
|
setPageSize(size);
|
||||||
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 대시보드 삭제 확인 모달 열기
|
|
||||||
const handleDeleteClick = (id: string, title: string) => {
|
const handleDeleteClick = (id: string, title: string) => {
|
||||||
setDeleteTarget({ id, title });
|
setDeleteTarget({ id, title });
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 대시보드 삭제 실행
|
|
||||||
const handleDeleteConfirm = async () => {
|
const handleDeleteConfirm = async () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await dashboardApi.deleteDashboard(deleteTarget.id);
|
await dashboardApi.deleteDashboard(deleteTarget.id);
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
toast({
|
toast({ title: "성공", description: "대시보드가 삭제되었습니다." });
|
||||||
title: "성공",
|
|
||||||
description: "대시보드가 삭제되었습니다.",
|
|
||||||
});
|
|
||||||
loadDashboards();
|
loadDashboards();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete dashboard:", err);
|
console.error("Failed to delete dashboard:", err);
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
toast({
|
toast({ title: "오류", description: "대시보드 삭제에 실패했습니다.", variant: "destructive" });
|
||||||
title: "오류",
|
|
||||||
description: "대시보드 삭제에 실패했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 대시보드 복사
|
|
||||||
const handleCopy = async (dashboard: Dashboard) => {
|
const handleCopy = async (dashboard: Dashboard) => {
|
||||||
try {
|
try {
|
||||||
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
||||||
|
|
||||||
await dashboardApi.createDashboard({
|
await dashboardApi.createDashboard({
|
||||||
title: `${fullDashboard.title} (복사본)`,
|
title: `${fullDashboard.title} (복사본)`,
|
||||||
description: fullDashboard.description,
|
description: fullDashboard.description,
|
||||||
|
|
@ -138,40 +123,85 @@ export default function DashboardListPage() {
|
||||||
category: fullDashboard.category,
|
category: fullDashboard.category,
|
||||||
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
|
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
|
||||||
});
|
});
|
||||||
toast({
|
toast({ title: "성공", description: "대시보드가 복사되었습니다." });
|
||||||
title: "성공",
|
|
||||||
description: "대시보드가 복사되었습니다.",
|
|
||||||
});
|
|
||||||
loadDashboards();
|
loadDashboards();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to copy dashboard:", err);
|
console.error("Failed to copy dashboard:", err);
|
||||||
toast({
|
toast({ title: "오류", description: "대시보드 복사에 실패했습니다.", variant: "destructive" });
|
||||||
title: "오류",
|
|
||||||
description: "대시보드 복사에 실패했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 포맷팅 헬퍼
|
const formatDate = (dateString: string) =>
|
||||||
const formatDate = (dateString: string) => {
|
new Date(dateString).toLocaleDateString("ko-KR", {
|
||||||
return new Date(dateString).toLocaleDateString("ko-KR", {
|
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
// ResponsiveDataView 컬럼 정의
|
||||||
|
const columns: RDVColumn<Dashboard>[] = [
|
||||||
|
{
|
||||||
|
key: "title",
|
||||||
|
label: "제목",
|
||||||
|
render: (_v, row) => (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/admin/screenMng/dashboardList/${row.id}`)}
|
||||||
|
className="hover:text-primary cursor-pointer text-left font-medium transition-colors hover:underline"
|
||||||
|
>
|
||||||
|
{row.title}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "description",
|
||||||
|
label: "설명",
|
||||||
|
render: (_v, row) => (
|
||||||
|
<span className="text-muted-foreground max-w-md truncate">{row.description || "-"}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdByName",
|
||||||
|
label: "생성자",
|
||||||
|
width: "120px",
|
||||||
|
render: (_v, row) => row.createdByName || row.createdBy || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAt",
|
||||||
|
label: "생성일",
|
||||||
|
width: "120px",
|
||||||
|
render: (_v, row) => formatDate(row.createdAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAt",
|
||||||
|
label: "수정일",
|
||||||
|
width: "120px",
|
||||||
|
render: (_v, row) => formatDate(row.updatedAt),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 모바일 카드 필드 정의
|
||||||
|
const cardFields: RDVCardField<Dashboard>[] = [
|
||||||
|
{
|
||||||
|
label: "설명",
|
||||||
|
render: (d) => (
|
||||||
|
<span className="max-w-[200px] truncate">{d.description || "-"}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: "생성자", render: (d) => d.createdByName || d.createdBy || "-" },
|
||||||
|
{ label: "생성일", render: (d) => formatDate(d.createdAt) },
|
||||||
|
{ label: "수정일", render: (d) => formatDate(d.updatedAt) },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex min-h-screen flex-col">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<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-muted-foreground text-sm">대시보드를 생성하고 관리할 수 있습니다</p>
|
<p className="text-muted-foreground text-sm">대시보드를 생성하고 관리할 수 있습니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색 및 액션 */}
|
{/* 검색 및 액션 (반응형) */}
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="relative w-full sm:w-[300px]">
|
<div className="relative w-full sm:w-[300px]">
|
||||||
|
|
@ -183,7 +213,7 @@ export default function DashboardListPage() {
|
||||||
className="h-10 pl-10 text-sm"
|
className="h-10 pl-10 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-muted-foreground hidden text-sm sm:block">
|
||||||
총 <span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span> 건
|
총 <span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span> 건
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -194,71 +224,7 @@ export default function DashboardListPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 대시보드 목록 */}
|
{/* 대시보드 목록 */}
|
||||||
{loading ? (
|
{error ? (
|
||||||
<>
|
|
||||||
{/* 데스크톱 테이블 스켈레톤 */}
|
|
||||||
<div className="bg-card hidden shadow-sm lg:block">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
|
||||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{Array.from({ length: 10 }).map((_, index) => (
|
|
||||||
<TableRow key={index} className="border-b">
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 w-20 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 text-right">
|
|
||||||
<div className="bg-muted ml-auto h-8 w-8 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 모바일/태블릿 카드 스켈레톤 */}
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
|
||||||
<div key={index} className="bg-card rounded-lg border p-4 shadow-sm">
|
|
||||||
<div className="mb-4 flex items-start justify-between">
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
|
||||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 border-t pt-4">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i} className="flex justify-between">
|
|
||||||
<div className="bg-muted h-4 w-16 animate-pulse rounded"></div>
|
|
||||||
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : error ? (
|
|
||||||
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
|
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
<div className="bg-destructive/20 flex h-16 w-16 items-center justify-center rounded-full">
|
<div className="bg-destructive/20 flex h-16 w-16 items-center justify-center rounded-full">
|
||||||
|
|
@ -274,158 +240,50 @@ export default function DashboardListPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : dashboards.length === 0 ? (
|
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center shadow-sm">
|
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
|
||||||
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<ResponsiveDataView<Dashboard>
|
||||||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
data={dashboards}
|
||||||
<div className="bg-card hidden shadow-sm lg:block">
|
columns={columns}
|
||||||
<Table>
|
keyExtractor={(d) => d.id}
|
||||||
<TableHeader>
|
isLoading={loading}
|
||||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
emptyMessage="대시보드가 없습니다."
|
||||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
skeletonCount={10}
|
||||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
cardTitle={(d) => d.title}
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
cardSubtitle={(d) => d.id}
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
cardFields={cardFields}
|
||||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
onRowClick={(d) => router.push(`/admin/screenMng/dashboardList/${d.id}`)}
|
||||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
renderActions={(d) => (
|
||||||
</TableRow>
|
<DropdownMenu>
|
||||||
</TableHeader>
|
<DropdownMenuTrigger asChild>
|
||||||
<TableBody>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}>
|
||||||
{dashboards.map((dashboard) => (
|
<MoreVertical className="h-4 w-4" />
|
||||||
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
</Button>
|
||||||
<TableCell className="h-16 text-sm font-medium">
|
</DropdownMenuTrigger>
|
||||||
<button
|
<DropdownMenuContent align="end">
|
||||||
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
<DropdownMenuItem
|
||||||
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
onClick={() => router.push(`/admin/screenMng/dashboardList/${d.id}`)}
|
||||||
>
|
className="gap-2 text-sm"
|
||||||
{dashboard.title}
|
>
|
||||||
</button>
|
<Edit className="h-4 w-4" />
|
||||||
</TableCell>
|
편집
|
||||||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
</DropdownMenuItem>
|
||||||
{dashboard.description || "-"}
|
<DropdownMenuItem onClick={() => handleCopy(d)} className="gap-2 text-sm">
|
||||||
</TableCell>
|
<Copy className="h-4 w-4" />
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
복사
|
||||||
{dashboard.createdByName || dashboard.createdBy || "-"}
|
</DropdownMenuItem>
|
||||||
</TableCell>
|
<DropdownMenuItem
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
onClick={() => handleDeleteClick(d.id, d.title)}
|
||||||
{formatDate(dashboard.createdAt)}
|
className="text-destructive focus:text-destructive gap-2 text-sm"
|
||||||
</TableCell>
|
>
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
<Trash2 className="h-4 w-4" />
|
||||||
{formatDate(dashboard.updatedAt)}
|
삭제
|
||||||
</TableCell>
|
</DropdownMenuItem>
|
||||||
<TableCell className="h-16 text-right">
|
</DropdownMenuContent>
|
||||||
<DropdownMenu>
|
</DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
)}
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
actionsLabel="작업"
|
||||||
<MoreVertical className="h-4 w-4" />
|
actionsWidth="80px"
|
||||||
</Button>
|
/>
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
|
||||||
className="gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
편집
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
복사
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
|
||||||
className="text-destructive focus:text-destructive gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
삭제
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
|
||||||
{dashboards.map((dashboard) => (
|
|
||||||
<div
|
|
||||||
key={dashboard.id}
|
|
||||||
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="mb-4 flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<button
|
|
||||||
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
|
||||||
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
|
||||||
>
|
|
||||||
<h3 className="text-base font-semibold">{dashboard.title}</h3>
|
|
||||||
</button>
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm">{dashboard.id}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 정보 */}
|
|
||||||
<div className="space-y-2 border-t pt-4">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">설명</span>
|
|
||||||
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">생성자</span>
|
|
||||||
<span className="font-medium">{dashboard.createdByName || dashboard.createdBy || "-"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">생성일</span>
|
|
||||||
<span className="font-medium">{formatDate(dashboard.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">수정일</span>
|
|
||||||
<span className="font-medium">{formatDate(dashboard.updatedAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 액션 */}
|
|
||||||
<div className="mt-4 flex gap-2 border-t pt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 flex-1 gap-2 text-sm"
|
|
||||||
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
편집
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 flex-1 gap-2 text-sm"
|
|
||||||
onClick={() => handleCopy(dashboard)}
|
|
||||||
>
|
|
||||||
<Copy 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={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
|
|
@ -453,6 +311,9 @@ export default function DashboardListPage() {
|
||||||
onConfirm={handleDeleteConfirm}
|
onConfirm={handleDeleteConfirm}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 */}
|
||||||
|
<ScrollToTop />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue