diff --git a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx new file mode 100644 index 00000000..8c55e366 --- /dev/null +++ b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx @@ -0,0 +1,332 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +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, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useToast } from "@/hooks/use-toast"; +import { Pagination, PaginationInfo } from "@/components/common/Pagination"; +import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react"; + +interface DashboardListClientProps { + initialDashboards: Dashboard[]; + initialPagination: { + total: number; + page: number; + limit: number; + }; +} + +/** + * 대시보드 목록 클라이언트 컴포넌트 + * - 대시보드 목록 조회 + * - 대시보드 생성/수정/삭제/복사 + */ +export default function DashboardListClient({ initialDashboards, initialPagination }: DashboardListClientProps) { + const router = useRouter(); + const { toast } = useToast(); + const [dashboards, setDashboards] = useState(initialDashboards); + const [loading, setLoading] = useState(false); // 초기 로딩은 서버에서 완료 + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + + // 페이지네이션 상태 + const [currentPage, setCurrentPage] = useState(initialPagination.page); + const [pageSize, setPageSize] = useState(initialPagination.limit); + const [totalCount, setTotalCount] = useState(initialPagination.total); + + // 모달 상태 + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null); + + // 대시보드 목록 로드 + const loadDashboards = async () => { + try { + setLoading(true); + setError(null); + const result = await dashboardApi.getMyDashboards({ + search: searchTerm, + page: currentPage, + limit: pageSize, + }); + setDashboards(result.dashboards); + setTotalCount(result.pagination.total); + } catch (err) { + console.error("Failed to load dashboards:", err); + setError( + err instanceof Error + ? err.message + : "대시보드 목록을 불러오는데 실패했습니다. 네트워크 연결을 확인하거나 잠시 후 다시 시도해주세요.", + ); + } finally { + setLoading(false); + } + }; + + // 초기 로드 여부 추적 + const [isInitialLoad, setIsInitialLoad] = useState(true); + + useEffect(() => { + // 초기 로드는 건너뛰기 (서버에서 이미 데이터를 가져왔음) + if (isInitialLoad) { + setIsInitialLoad(false); + return; + } + + // 이후 검색어/페이지 변경 시에만 fetch + loadDashboards(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchTerm, currentPage, pageSize]); + + // 페이지네이션 정보 계산 + const paginationInfo: PaginationInfo = { + currentPage, + totalPages: Math.ceil(totalCount / pageSize), + totalItems: totalCount, + itemsPerPage: pageSize, + startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1, + endItem: Math.min(currentPage * pageSize, totalCount), + }; + + // 페이지 변경 핸들러 + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + // 페이지 크기 변경 핸들러 + const handlePageSizeChange = (size: number) => { + setPageSize(size); + 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: "대시보드가 삭제되었습니다.", + }); + loadDashboards(); + } catch (err) { + console.error("Failed to delete dashboard:", err); + setDeleteDialogOpen(false); + 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, + elements: fullDashboard.elements || [], + isPublic: false, + tags: fullDashboard.tags, + category: fullDashboard.category, + settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string }, + }); + toast({ + title: "성공", + description: "대시보드가 복사되었습니다.", + }); + loadDashboards(); + } catch (err) { + console.error("Failed to copy dashboard:", err); + toast({ + title: "오류", + description: "대시보드 복사에 실패했습니다.", + variant: "destructive", + }); + } + }; + + // 포맷팅 헬퍼 + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + }; + + return ( + <> + {/* 검색 및 액션 */} +
+
+ + setSearchTerm(e.target.value)} + className="h-10 pl-10 text-sm" + /> +
+ +
+ + {/* 대시보드 목록 */} + {loading ? ( +
+
+
로딩 중...
+
대시보드 목록을 불러오고 있습니다
+
+
+ ) : error ? ( +
+
+
+ +
+
+

데이터를 불러올 수 없습니다

+

{error}

+
+ +
+
+ ) : dashboards.length === 0 ? ( +
+
+

대시보드가 없습니다

+
+
+ ) : ( +
+ + + + 제목 + 설명 + 생성일 + 수정일 + 작업 + + + + {dashboards.map((dashboard) => ( + + {dashboard.title} + + {dashboard.description || "-"} + + + {formatDate(dashboard.createdAt)} + + + {formatDate(dashboard.updatedAt)} + + + + + + + + router.push(`/admin/dashboard/edit/${dashboard.id}`)} + className="gap-2 text-sm" + > + + 편집 + + handleCopy(dashboard)} className="gap-2 text-sm"> + + 복사 + + handleDeleteClick(dashboard.id, dashboard.title)} + className="text-destructive focus:text-destructive gap-2 text-sm" + > + + 삭제 + + + + + + ))} + +
+
+ )} + + {/* 페이지네이션 */} + {!loading && dashboards.length > 0 && ( + + )} + + {/* 삭제 확인 모달 */} + + + + 대시보드 삭제 + + "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까? +
이 작업은 되돌릴 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
+ + ); +} diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index 16e2ed6a..8d78600c 100644 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -1,307 +1,74 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useRouter } from "next/navigation"; -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, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { useToast } from "@/hooks/use-toast"; -import { Pagination, PaginationInfo } from "@/components/common/Pagination"; -import { Plus, Search, Edit, Trash2, Copy, MoreVertical } from "lucide-react"; +import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient"; +import { cookies } from "next/headers"; /** - * 대시보드 관리 페이지 - * - 대시보드 목록 조회 - * - 대시보드 생성/수정/삭제/복사 + * 서버에서 초기 대시보드 목록 fetch */ -export default function DashboardListPage() { - const router = useRouter(); - const { toast } = useToast(); - const [dashboards, setDashboards] = useState([]); - const [loading, setLoading] = useState(true); - const [searchTerm, setSearchTerm] = useState(""); +async function getInitialDashboards() { + try { + // 서버 사이드 전용: 백엔드 API 직접 호출 + // 도커 네트워크 내부에서는 서비스 이름 사용, 로컬에서는 127.0.0.1 + const backendUrl = process.env.SERVER_API_URL || "http://backend:8080"; - // 페이지네이션 상태 - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - const [totalCount, setTotalCount] = useState(0); + // 쿠키에서 authToken 추출 + const cookieStore = await cookies(); + const authToken = cookieStore.get("authToken")?.value; - // 모달 상태 - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null); - - // 대시보드 목록 로드 - const loadDashboards = async () => { - try { - setLoading(true); - const result = await dashboardApi.getMyDashboards({ - search: searchTerm, - page: currentPage, - limit: pageSize, - }); - setDashboards(result.dashboards); - setTotalCount(result.pagination.total); - } catch (err) { - console.error("Failed to load dashboards:", err); - toast({ - title: "오류", - description: "대시보드 목록을 불러오는데 실패했습니다.", - variant: "destructive", - }); - } finally { - setLoading(false); + if (!authToken) { + // 토큰이 없으면 빈 데이터 반환 (클라이언트에서 로드) + return { + dashboards: [], + pagination: { total: 0, page: 1, limit: 10 }, + }; } - }; - useEffect(() => { - loadDashboards(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchTerm, currentPage, pageSize]); - - // 페이지네이션 정보 계산 - const paginationInfo: PaginationInfo = { - currentPage, - totalPages: Math.ceil(totalCount / pageSize), - totalItems: totalCount, - itemsPerPage: pageSize, - startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1, - endItem: Math.min(currentPage * pageSize, totalCount), - }; - - // 페이지 변경 핸들러 - const handlePageChange = (page: number) => { - setCurrentPage(page); - }; - - // 페이지 크기 변경 핸들러 - const handlePageSizeChange = (size: number) => { - setPageSize(size); - 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: "대시보드가 삭제되었습니다.", - }); - loadDashboards(); - } catch (err) { - console.error("Failed to delete dashboard:", err); - setDeleteDialogOpen(false); - 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, - elements: fullDashboard.elements || [], - isPublic: false, - tags: fullDashboard.tags, - category: fullDashboard.category, - settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string }, - }); - toast({ - title: "성공", - description: "대시보드가 복사되었습니다.", - }); - loadDashboards(); - } catch (err) { - console.error("Failed to copy dashboard:", err); - toast({ - title: "오류", - description: "대시보드 복사에 실패했습니다.", - variant: "destructive", - }); - } - }; - - // 포맷팅 헬퍼 - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString("ko-KR", { - year: "numeric", - month: "2-digit", - day: "2-digit", + const response = await fetch(`${backendUrl}/api/dashboards/my?page=1&limit=10`, { + cache: "no-store", // 항상 최신 데이터 + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, // Authorization 헤더로 전달 + }, }); - }; - if (loading) { - return ( -
-
-
로딩 중...
-
대시보드 목록을 불러오고 있습니다
-
-
- ); + if (!response.ok) { + throw new Error(`Failed to fetch dashboards: ${response.status}`); + } + + const data = await response.json(); + return { + dashboards: data.data || [], + pagination: data.pagination || { total: 0, page: 1, limit: 10 }, + }; + } catch (error) { + console.error("Server-side fetch error:", error); + // 에러 발생 시 빈 데이터 반환 (클라이언트에서 재시도 가능) + return { + dashboards: [], + pagination: { total: 0, page: 1, limit: 10 }, + }; } +} + +/** + * 대시보드 관리 페이지 (서버 컴포넌트) + * - 페이지 헤더 + 초기 데이터를 서버에서 렌더링 + * - 클라이언트 컴포넌트로 초기 데이터 전달 + */ +export default async function DashboardListPage() { + const initialData = await getInitialDashboards(); return (
- {/* 페이지 헤더 */} + {/* 페이지 헤더 (서버에서 렌더링) */}

대시보드 관리

대시보드를 생성하고 관리할 수 있습니다

- {/* 검색 및 액션 */} -
-
- - setSearchTerm(e.target.value)} - className="h-10 pl-10 text-sm" - /> -
- -
- - {/* 대시보드 목록 */} - {dashboards.length === 0 ? ( -
-
-

대시보드가 없습니다

-
-
- ) : ( -
- - - - 제목 - 설명 - 생성일 - 수정일 - 작업 - - - - {dashboards.map((dashboard) => ( - - {dashboard.title} - - {dashboard.description || "-"} - - - {formatDate(dashboard.createdAt)} - - - {formatDate(dashboard.updatedAt)} - - - - - - - - router.push(`/admin/dashboard/edit/${dashboard.id}`)} - className="gap-2 text-sm" - > - - 편집 - - handleCopy(dashboard)} className="gap-2 text-sm"> - - 복사 - - handleDeleteClick(dashboard.id, dashboard.title)} - className="text-destructive focus:text-destructive gap-2 text-sm" - > - - 삭제 - - - - - - ))} - -
-
- )} - - {/* 페이지네이션 */} - {!loading && dashboards.length > 0 && ( - - )} + {/* 나머지 컨텐츠 (클라이언트 컴포넌트 + 서버 데이터) */} +
- - {/* 삭제 확인 모달 */} - - - - 대시보드 삭제 - - "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까? -
이 작업은 되돌릴 수 없습니다. -
-
- - 취소 - - 삭제 - - -
-
); }