333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
"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<Dashboard[]>(initialDashboards);
|
|
const [loading, setLoading] = useState(false); // 초기 로딩은 서버에서 완료
|
|
const [error, setError] = useState<string | null>(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 (
|
|
<>
|
|
{/* 검색 및 액션 */}
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<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={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="h-10 pl-10 text-sm"
|
|
/>
|
|
</div>
|
|
<Button onClick={() => router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
|
|
<Plus className="h-4 w-4" />새 대시보드 생성
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 대시보드 목록 */}
|
|
{loading ? (
|
|
<div className="bg-card flex h-64 items-center justify-center rounded-lg border shadow-sm">
|
|
<div className="text-center">
|
|
<div className="text-sm font-medium">로딩 중...</div>
|
|
<div className="text-muted-foreground mt-2 text-xs">대시보드 목록을 불러오고 있습니다</div>
|
|
</div>
|
|
</div>
|
|
) : error ? (
|
|
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
|
|
<div className="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">
|
|
<AlertCircle className="text-destructive h-8 w-8" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-destructive mb-2 text-lg font-semibold">데이터를 불러올 수 없습니다</h3>
|
|
<p className="text-destructive/80 max-w-md text-sm">{error}</p>
|
|
</div>
|
|
<Button onClick={loadDashboards} variant="outline" className="mt-2 gap-2">
|
|
<RefreshCw className="h-4 w-4" />
|
|
다시 시도
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : dashboards.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">
|
|
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="bg-card rounded-lg border shadow-sm">
|
|
<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-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">{dashboard.title}</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">
|
|
{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/dashboard/edit/${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>
|
|
)}
|
|
|
|
{/* 페이지네이션 */}
|
|
{!loading && dashboards.length > 0 && (
|
|
<Pagination
|
|
paginationInfo={paginationInfo}
|
|
onPageChange={handlePageChange}
|
|
onPageSizeChange={handlePageSizeChange}
|
|
showPageSizeSelector={true}
|
|
pageSizeOptions={[10, 20, 50, 100]}
|
|
/>
|
|
)}
|
|
|
|
{/* 삭제 확인 모달 */}
|
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle className="text-base sm:text-lg">대시보드 삭제</AlertDialogTitle>
|
|
<AlertDialogDescription className="text-xs sm:text-sm">
|
|
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
|
<br />이 작업은 되돌릴 수 없습니다.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
|
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">취소</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleDeleteConfirm}
|
|
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
삭제
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
}
|