[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 { 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}" 연결을 삭제하시겠습니까?
|
||||
“{connectionToDelete?.connection_name}” 연결을 삭제하시겠습니까?
|
||||
<br />
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
|
|
@ -472,6 +441,7 @@ export default function ExternalConnectionsPage() {
|
|||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue