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

This commit is contained in:
DDD1542 2026-03-09 20:56:39 +09:00
parent 074abfcdb0
commit e41df3b922
3 changed files with 374 additions and 516 deletions

View File

@ -4,10 +4,8 @@ import React, { useState, useEffect } from "react";
import { Plus, Search, Pencil, Trash2, Database, Terminal, Globe } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
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 {
AlertDialog,
@ -24,11 +22,12 @@ import {
ExternalDbConnectionAPI,
ExternalDbConnection,
ExternalDbConnectionFilter,
ConnectionTestRequest,
} from "@/lib/api/externalDbConnection";
import { ExternalDbConnectionModal } from "@/components/admin/ExternalDbConnectionModal";
import { SqlQueryModal } from "@/components/admin/SqlQueryModal";
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";
@ -102,7 +101,6 @@ export default function ExternalConnectionsPage() {
setSupportedDbTypes([{ value: "ALL", label: "전체" }, ...types]);
} catch (error) {
console.error("지원 DB 타입 로딩 오류:", error);
// 실패 시 기본값 사용
setSupportedDbTypes([
{ value: "ALL", label: "전체" },
{ value: "mysql", label: "MySQL" },
@ -114,45 +112,36 @@ export default function ExternalConnectionsPage() {
}
};
// 초기 데이터 로딩
useEffect(() => {
loadConnections();
loadSupportedDbTypes();
}, []);
// 필터 변경 시 데이터 재로딩
useEffect(() => {
loadConnections();
}, [searchTerm, dbTypeFilter, activeStatusFilter]);
// 새 연결 추가
const handleAddConnection = () => {
setEditingConnection(undefined);
setIsModalOpen(true);
};
// 연결 편집
const handleEditConnection = (connection: ExternalDbConnection) => {
setEditingConnection(connection);
setIsModalOpen(true);
};
// 연결 삭제 확인 다이얼로그 열기
const handleDeleteConnection = (connection: ExternalDbConnection) => {
setConnectionToDelete(connection);
setDeleteDialogOpen(true);
};
// 연결 삭제 실행
const confirmDeleteConnection = async () => {
if (!connectionToDelete?.id) return;
try {
await ExternalDbConnectionAPI.deleteConnection(connectionToDelete.id);
toast({
title: "성공",
description: "연결이 삭제되었습니다.",
});
toast({ title: "성공", description: "연결이 삭제되었습니다." });
loadConnections();
} catch (error) {
console.error("연결 삭제 오류:", error);
@ -167,13 +156,11 @@ export default function ExternalConnectionsPage() {
}
};
// 연결 삭제 취소
const cancelDeleteConnection = () => {
setDeleteDialogOpen(false);
setConnectionToDelete(null);
};
// 연결 테스트
const handleTestConnection = async (connection: ExternalDbConnection) => {
if (!connection.id) return;
@ -181,14 +168,10 @@ export default function ExternalConnectionsPage() {
try {
const result = await ExternalDbConnectionAPI.testConnection(connection.id);
setTestResults((prev) => new Map(prev).set(connection.id!, result.success));
if (result.success) {
toast({
title: "연결 성공",
description: `${connection.connection_name} 연결이 성공했습니다.`,
});
toast({ title: "연결 성공", description: `${connection.connection_name} 연결이 성공했습니다.` });
} else {
toast({
title: "연결 실패",
@ -199,11 +182,7 @@ export default function ExternalConnectionsPage() {
} catch (error) {
console.error("연결 테스트 오류:", error);
setTestResults((prev) => new Map(prev).set(connection.id!, false));
toast({
title: "연결 테스트 오류",
description: "연결 테스트 중 오류가 발생했습니다.",
variant: "destructive",
});
toast({ title: "연결 테스트 오류", description: "연결 테스트 중 오류가 발생했습니다.", variant: "destructive" });
} finally {
setTestingConnections((prev) => {
const newSet = new Set(prev);
@ -213,19 +192,77 @@ export default function ExternalConnectionsPage() {
}
};
// 모달 저장 처리
const handleModalSave = () => {
setIsModalOpen(false);
setEditingConnection(undefined);
loadConnections();
};
// 모달 취소 처리
const handleModalCancel = () => {
setIsModalOpen(false);
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 (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
@ -237,7 +274,7 @@ export default function ExternalConnectionsPage() {
{/* 탭 */}
<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">
<Database className="h-4 w-4" />
@ -252,8 +289,7 @@ export default function ExternalConnectionsPage() {
<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 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]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@ -263,8 +299,6 @@ export default function ExternalConnectionsPage() {
className="h-10 pl-10 text-sm"
/>
</div>
{/* DB 타입 필터 */}
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
<SelectTrigger className="h-10 w-full sm:w-[160px]">
<SelectValue placeholder="DB 타입" />
@ -277,8 +311,6 @@ export default function ExternalConnectionsPage() {
))}
</SelectContent>
</Select>
{/* 활성 상태 필터 */}
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
<SelectTrigger className="h-10 w-full sm:w-[120px]">
<SelectValue placeholder="상태" />
@ -292,126 +324,63 @@ export default function ExternalConnectionsPage() {
</SelectContent>
</Select>
</div>
{/* 추가 버튼 */}
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 연결 목록 */}
{loading ? (
<div className="flex h-64 items-center justify-center bg-card">
<div className="text-sm text-muted-foreground"> ...</div>
</div>
) : connections.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center bg-card">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
) : (
<div className="bg-card">
<Table>
<TableHeader>
<TableRow className="bg-background">
<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"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">DB </TableHead>
<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"></TableHead>
<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"></TableHead>
<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"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-right text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connections.map((connection) => (
<TableRow key={connection.id} className="bg-background transition-colors hover:bg-muted/50">
<TableCell className="h-16 px-6 py-3 text-sm">
<div className="font-medium">{connection.connection_name}</div>
</TableCell>
<TableCell className="h-16 px-6 py-3 text-sm">
{(connection as any).company_name || connection.company_code}
</TableCell>
<TableCell className="h-16 px-6 py-3 text-sm">
<Badge variant="outline">
{DB_TYPE_LABELS[connection.db_type] || connection.db_type}
</Badge>
</TableCell>
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
{connection.host}:{connection.port}
</TableCell>
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{connection.database_name}</TableCell>
<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"}>
{connection.is_active === "Y" ? "활성" : "비활성"}
</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>
)}
{/* 연결 목록 - ResponsiveDataView */}
<ResponsiveDataView
data={connections}
columns={columns}
keyExtractor={(c) => String(c.id || c.connection_name)}
isLoading={loading}
emptyMessage="등록된 연결이 없습니다"
skeletonCount={5}
cardTitle={(c) => c.connection_name}
cardSubtitle={(c) => <span className="font-mono text-xs">{c.host}:{c.port}/{c.database_name}</span>}
cardHeaderRight={(c) => (
<Badge variant={c.is_active === "Y" ? "default" : "secondary"}>
{c.is_active === "Y" ? "활성" : "비활성"}
</Badge>
)}
cardFields={cardFields}
renderActions={(c) => (
<>
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleTestConnection(c); }}
disabled={testingConnections.has(c.id!)}
className="h-9 flex-1 gap-2 text-sm">
{testingConnections.has(c.id!) ? "테스트 중..." : "테스트"}
</Button>
<Button variant="outline" size="sm"
onClick={(e) => {
e.stopPropagation();
setSelectedConnection(c);
setSqlModalOpen(true);
}}
className="h-9 flex-1 gap-2 text-sm">
<Terminal className="h-4 w-4" />
SQL
</Button>
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleEditConnection(c); }}
className="h-9 flex-1 gap-2 text-sm">
<Pencil className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleDeleteConnection(c); }}
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 flex-1 gap-2 text-sm">
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
actionsLabel="작업"
actionsWidth="180px"
/>
{/* 연결 설정 모달 */}
{isModalOpen && (
@ -430,7 +399,7 @@ export default function ExternalConnectionsPage() {
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
"{connectionToDelete?.connection_name}" ?
&ldquo;{connectionToDelete?.connection_name}&rdquo; ?
<br />
.
</AlertDialogDescription>
@ -472,6 +441,7 @@ export default function ExternalConnectionsPage() {
</TabsContent>
</Tabs>
</div>
<ScrollToTop />
</div>
);
}

View File

@ -9,7 +9,7 @@
import { useState, useEffect } from "react";
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 { Badge } from "@/components/ui/badge";
import {
@ -35,6 +35,7 @@ import { tableManagementApi } from "@/lib/api/tableManagement";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
export default function FlowManagementPage() {
const router = useRouter();
@ -513,6 +514,81 @@ export default function FlowManagementPage() {
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 (
<div className="bg-background flex min-h-screen flex-col">
<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>
</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">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 플로우 카드 목록 */}
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="bg-card rounded-lg border p-6 shadow-sm">
<div className="mb-4 space-y-2">
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
<div className="bg-muted h-4 w-full animate-pulse rounded"></div>
<div className="bg-muted h-4 w-3/4 animate-pulse rounded"></div>
</div>
<div className="space-y-2 border-t pt-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<div className="bg-muted h-4 w-4 animate-pulse rounded"></div>
<div className="bg-muted h-4 flex-1 animate-pulse rounded"></div>
</div>
))}
</div>
<div className="mt-4 flex gap-2 border-t pt-4">
<div className="bg-muted h-9 flex-1 animate-pulse rounded"></div>
<div className="bg-muted h-9 w-9 animate-pulse rounded"></div>
</div>
</div>
))}
</div>
) : flows.length === 0 ? (
<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)}
{/* 플로우 목록 (ResponsiveDataView) */}
<ResponsiveDataView<FlowDefinition>
data={filteredFlows}
columns={columns}
keyExtractor={(f) => String(f.id)}
isLoading={loading}
emptyMessage="생성된 플로우가 없습니다."
skeletonCount={6}
cardTitle={(f) => f.name}
cardSubtitle={(f) => f.description || "설명 없음"}
cardHeaderRight={(f) =>
f.isActive ? (
<Badge variant="default" className="shrink-0"></Badge>
) : null
}
cardFields={cardFields}
onRowClick={(f) => handleEdit(f.id)}
renderActions={(f) => (
<>
<Button
variant="outline"
size="sm"
className="h-9 flex-1 gap-2 text-sm"
onClick={(e) => {
e.stopPropagation();
handleEdit(f.id);
}}
>
{/* 헤더 */}
<div className="mb-4 flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-base font-semibold">{flow.name}</h3>
{flow.isActive && (
<Badge className="shrink-0 bg-emerald-500 text-white hover:bg-emerald-600"></Badge>
)}
</div>
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">{flow.description || "설명 없음"}</p>
</div>
</div>
{/* 정보 */}
<div className="space-y-2 border-t pt-4">
<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>
</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>
)}
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="h-9 w-9 p-0"
onClick={(e) => {
e.stopPropagation();
setSelectedFlow(f);
setIsDeleteDialogOpen(true);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
actionsWidth="160px"
/>
{/* 생성 다이얼로그 */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>

View File

@ -6,7 +6,6 @@ import { dashboardApi } from "@/lib/api/dashboard";
import { Dashboard } from "@/lib/api/dashboard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
@ -16,6 +15,8 @@ import {
import { useToast } from "@/hooks/use-toast";
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
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";
/**
@ -30,7 +31,7 @@ export default function DashboardListPage() {
// 상태 관리
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 [searchTerm, setSearchTerm] = useState("");
@ -83,52 +84,36 @@ export default function DashboardListPage() {
endItem: Math.min(currentPage * pageSize, totalCount),
};
// 페이지 변경 핸들러
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handlePageChange = (page: number) => setCurrentPage(page);
// 페이지 크기 변경 핸들러
const handlePageSizeChange = (size: number) => {
setPageSize(size);
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
setCurrentPage(1);
};
// 대시보드 삭제 확인 모달 열기
const handleDeleteClick = (id: string, title: string) => {
setDeleteTarget({ id, title });
setDeleteDialogOpen(true);
};
// 대시보드 삭제 실행
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
try {
await dashboardApi.deleteDashboard(deleteTarget.id);
setDeleteDialogOpen(false);
setDeleteTarget(null);
toast({
title: "성공",
description: "대시보드가 삭제되었습니다.",
});
toast({ title: "성공", description: "대시보드가 삭제되었습니다." });
loadDashboards();
} catch (err) {
console.error("Failed to delete dashboard:", err);
setDeleteDialogOpen(false);
toast({
title: "오류",
description: "대시보드 삭제에 실패했습니다.",
variant: "destructive",
});
toast({ title: "오류", description: "대시보드 삭제에 실패했습니다.", variant: "destructive" });
}
};
// 대시보드 복사
const handleCopy = async (dashboard: Dashboard) => {
try {
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
await dashboardApi.createDashboard({
title: `${fullDashboard.title} (복사본)`,
description: fullDashboard.description,
@ -138,40 +123,85 @@ export default function DashboardListPage() {
category: fullDashboard.category,
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
});
toast({
title: "성공",
description: "대시보드가 복사되었습니다.",
});
toast({ title: "성공", description: "대시보드가 복사되었습니다." });
loadDashboards();
} catch (err) {
console.error("Failed to copy dashboard:", err);
toast({
title: "오류",
description: "대시보드 복사에 실패했습니다.",
variant: "destructive",
});
toast({ title: "오류", description: "대시보드 복사에 실패했습니다.", variant: "destructive" });
}
};
// 포맷팅 헬퍼
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ko-KR", {
const formatDate = (dateString: string) =>
new Date(dateString).toLocaleDateString("ko-KR", {
year: "numeric",
month: "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 (
<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">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-sm"> </p>
</div>
{/* 검색 및 액션 */}
{/* 검색 및 액션 (반응형) */}
<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]">
@ -183,7 +213,7 @@ export default function DashboardListPage() {
className="h-10 pl-10 text-sm"
/>
</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>
</div>
</div>
@ -194,71 +224,7 @@ export default function DashboardListPage() {
</div>
{/* 대시보드 목록 */}
{loading ? (
<>
{/* 데스크톱 테이블 스켈레톤 */}
<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 ? (
{error ? (
<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="bg-destructive/20 flex h-16 w-16 items-center justify-center rounded-full">
@ -274,158 +240,50 @@ export default function DashboardListPage() {
</Button>
</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>
) : (
<>
{/* 데스크톱 테이블 뷰 (lg 이상) */}
<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>
{dashboards.map((dashboard) => (
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
<TableCell className="h-16 text-sm font-medium">
<button
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
>
{dashboard.title}
</button>
</TableCell>
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
{dashboard.description || "-"}
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{dashboard.createdByName || dashboard.createdBy || "-"}
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{formatDate(dashboard.createdAt)}
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{formatDate(dashboard.updatedAt)}
</TableCell>
<TableCell className="h-16 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="h-4 w-4" />
</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>
</>
<ResponsiveDataView<Dashboard>
data={dashboards}
columns={columns}
keyExtractor={(d) => d.id}
isLoading={loading}
emptyMessage="대시보드가 없습니다."
skeletonCount={10}
cardTitle={(d) => d.title}
cardSubtitle={(d) => d.id}
cardFields={cardFields}
onRowClick={(d) => router.push(`/admin/screenMng/dashboardList/${d.id}`)}
renderActions={(d) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => router.push(`/admin/screenMng/dashboardList/${d.id}`)}
className="gap-2 text-sm"
>
<Edit className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopy(d)} className="gap-2 text-sm">
<Copy className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(d.id, d.title)}
className="text-destructive focus:text-destructive gap-2 text-sm"
>
<Trash2 className="h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
actionsLabel="작업"
actionsWidth="80px"
/>
)}
{/* 페이지네이션 */}
@ -453,6 +311,9 @@ export default function DashboardListPage() {
onConfirm={handleDeleteConfirm}
/>
</div>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}