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}" 대시보드를 삭제하시겠습니까? -
이 작업은 되돌릴 수 없습니다. -
-
- - 취소 - - 삭제 - - -
-
); } diff --git a/frontend/app/(main)/dashboard/page.tsx b/frontend/app/(main)/dashboard/page.tsx index 50245fb8..e62f08ae 100644 --- a/frontend/app/(main)/dashboard/page.tsx +++ b/frontend/app/(main)/dashboard/page.tsx @@ -1,7 +1,7 @@ -'use client'; +"use client"; -import React, { useState, useEffect } from 'react'; -import Link from 'next/link'; +import React, { useState, useEffect } from "react"; +import Link from "next/link"; interface Dashboard { id: string; @@ -23,7 +23,7 @@ interface Dashboard { export default function DashboardListPage() { const [dashboards, setDashboards] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [searchTerm, setSearchTerm] = useState(''); + const [searchTerm, setSearchTerm] = useState(""); // 대시보드 목록 로딩 useEffect(() => { @@ -32,14 +32,14 @@ export default function DashboardListPage() { const loadDashboards = async () => { setIsLoading(true); - + try { // 실제 API 호출 시도 - const { dashboardApi } = await import('@/lib/api/dashboard'); - + const { dashboardApi } = await import("@/lib/api/dashboard"); + try { const result = await dashboardApi.getDashboards({ page: 1, limit: 50 }); - + // API에서 가져온 대시보드들을 Dashboard 형식으로 변환 const apiDashboards: Dashboard[] = result.dashboards.map((dashboard: any) => ({ id: dashboard.id, @@ -49,48 +49,47 @@ export default function DashboardListPage() { createdAt: dashboard.createdAt, updatedAt: dashboard.updatedAt, isPublic: dashboard.isPublic, - creatorName: dashboard.creatorName + creatorName: dashboard.creatorName, })); - + setDashboards(apiDashboards); - } catch (apiError) { - console.warn('API 호출 실패, 로컬 스토리지 및 샘플 데이터 사용:', apiError); - + console.warn("API 호출 실패, 로컬 스토리지 및 샘플 데이터 사용:", apiError); + // API 실패 시 로컬 스토리지 + 샘플 데이터 사용 - const savedDashboards = JSON.parse(localStorage.getItem('savedDashboards') || '[]'); - + const savedDashboards = JSON.parse(localStorage.getItem("savedDashboards") || "[]"); + // 샘플 대시보드들 const sampleDashboards: Dashboard[] = [ { - id: 'sales-overview', - title: '📊 매출 현황 대시보드', - description: '월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.', + id: "sales-overview", + title: "📊 매출 현황 대시보드", + description: "월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.", elementsCount: 3, - createdAt: '2024-09-30T10:00:00Z', - updatedAt: '2024-09-30T14:30:00Z', - isPublic: true + createdAt: "2024-09-30T10:00:00Z", + updatedAt: "2024-09-30T14:30:00Z", + isPublic: true, }, { - id: 'user-analytics', - title: '👥 사용자 분석 대시보드', - description: '사용자 행동 패턴 및 가입 추이 분석', + id: "user-analytics", + title: "👥 사용자 분석 대시보드", + description: "사용자 행동 패턴 및 가입 추이 분석", elementsCount: 1, - createdAt: '2024-09-29T15:00:00Z', - updatedAt: '2024-09-30T09:15:00Z', - isPublic: false + createdAt: "2024-09-29T15:00:00Z", + updatedAt: "2024-09-30T09:15:00Z", + isPublic: false, }, { - id: 'inventory-status', - title: '📦 재고 현황 대시보드', - description: '실시간 재고 현황 및 입출고 내역', + id: "inventory-status", + title: "📦 재고 현황 대시보드", + description: "실시간 재고 현황 및 입출고 내역", elementsCount: 4, - createdAt: '2024-09-28T11:30:00Z', - updatedAt: '2024-09-29T16:45:00Z', - isPublic: true - } + createdAt: "2024-09-28T11:30:00Z", + updatedAt: "2024-09-29T16:45:00Z", + isPublic: true, + }, ]; - + // 저장된 대시보드를 Dashboard 형식으로 변환 const userDashboards: Dashboard[] = savedDashboards.map((dashboard: any) => ({ id: dashboard.id, @@ -99,44 +98,45 @@ export default function DashboardListPage() { elementsCount: dashboard.elements?.length || 0, createdAt: dashboard.createdAt, updatedAt: dashboard.updatedAt, - isPublic: false // 사용자가 만든 대시보드는 기본적으로 비공개 + isPublic: false, // 사용자가 만든 대시보드는 기본적으로 비공개 })); - + // 사용자 대시보드를 맨 앞에 배치 setDashboards([...userDashboards, ...sampleDashboards]); } } catch (error) { - console.error('Dashboard loading error:', error); + console.error("Dashboard loading error:", error); } finally { setIsLoading(false); } }; // 검색 필터링 - const filteredDashboards = dashboards.filter(dashboard => - dashboard.title.toLowerCase().includes(searchTerm.toLowerCase()) || - dashboard.description?.toLowerCase().includes(searchTerm.toLowerCase()) + const filteredDashboards = dashboards.filter( + (dashboard) => + dashboard.title.toLowerCase().includes(searchTerm.toLowerCase()) || + dashboard.description?.toLowerCase().includes(searchTerm.toLowerCase()), ); return (
{/* 헤더 */} -
-
-
+
+
+

📊 대시보드

-

데이터를 시각화하고 인사이트를 얻어보세요

+

데이터를 시각화하고 인사이트를 얻어보세요

- + ➕ 새 대시보드 만들기
- + {/* 검색 바 */}
@@ -145,31 +145,29 @@ export default function DashboardListPage() { placeholder="대시보드 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + className="w-full rounded-lg border border-gray-300 py-2 pr-4 pl-10 focus:border-transparent focus:ring-2 focus:ring-blue-500" /> -
- 🔍 -
+
🔍
{/* 메인 콘텐츠 */} -
+
{isLoading ? ( // 로딩 상태 -
+
{[1, 2, 3, 4, 5, 6].map((i) => ( -
+
-
-
-
-
+
+
+
+
-
-
+
+
@@ -177,20 +175,18 @@ export default function DashboardListPage() {
) : filteredDashboards.length === 0 ? ( // 빈 상태 -
-
📊
-

- {searchTerm ? '검색 결과가 없습니다' : '아직 대시보드가 없습니다'} +
+
📊
+

+ {searchTerm ? "검색 결과가 없습니다" : "아직 대시보드가 없습니다"}

-

- {searchTerm - ? '다른 검색어로 시도해보세요' - : '첫 번째 대시보드를 만들어보세요'} +

+ {searchTerm ? "다른 검색어로 시도해보세요" : "첫 번째 대시보드를 만들어보세요"}

{!searchTerm && ( ➕ 대시보드 만들기 @@ -198,7 +194,7 @@ export default function DashboardListPage() {
) : ( // 대시보드 그리드 -
+
{filteredDashboards.map((dashboard) => ( ))} @@ -218,64 +214,54 @@ interface DashboardCardProps { */ function DashboardCard({ dashboard }: DashboardCardProps) { return ( -
+
{/* 썸네일 영역 */} -
+
-
📊
+
📊
{dashboard.elementsCount}개 요소
- + {/* 카드 내용 */}
-
-

- {dashboard.title} -

+
+

{dashboard.title}

{dashboard.isPublic ? ( - - 공개 - + 공개 ) : ( - - 비공개 - + 비공개 )}
- - {dashboard.description && ( -

- {dashboard.description} -

- )} - + + {dashboard.description &&

{dashboard.description}

} + {/* 메타 정보 */} -
+
생성: {new Date(dashboard.createdAt).toLocaleDateString()}
수정: {new Date(dashboard.updatedAt).toLocaleDateString()}
- + {/* 액션 버튼들 */}
보기 편집
); -} \ No newline at end of file +} diff --git a/frontend/components/admin/dashboard/DashboardToolbar.tsx b/frontend/components/admin/dashboard/DashboardToolbar.tsx deleted file mode 100644 index c9a9581d..00000000 --- a/frontend/components/admin/dashboard/DashboardToolbar.tsx +++ /dev/null @@ -1,110 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; - -interface DashboardToolbarProps { - onClearCanvas: () => void; - onSaveLayout: () => void; - canvasBackgroundColor: string; - onCanvasBackgroundColorChange: (color: string) => void; -} - -/** - * 대시보드 툴바 컴포넌트 - * - 전체 삭제, 레이아웃 저장 등 주요 액션 버튼 - */ -export function DashboardToolbar({ onClearCanvas, onSaveLayout, canvasBackgroundColor, onCanvasBackgroundColorChange }: DashboardToolbarProps) { - const [showColorPicker, setShowColorPicker] = useState(false); - return ( -
- - - - - {/* 캔버스 배경색 변경 버튼 */} -
- - - {/* 색상 선택 패널 */} - {showColorPicker && ( -
-
- onCanvasBackgroundColorChange(e.target.value)} - className="h-10 w-16 border border-border rounded cursor-pointer" - /> - onCanvasBackgroundColorChange(e.target.value)} - placeholder="#ffffff" - className="flex-1 px-2 py-1 text-sm border border-border rounded" - /> -
- - {/* 프리셋 색상 */} -
- {[ - '#ffffff', '#f9fafb', '#f3f4f6', '#e5e7eb', - '#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', - '#10b981', '#06b6d4', '#6366f1', '#84cc16', - ].map((color) => ( -
- - -
- )} -
-
- ); -} diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx deleted file mode 100644 index 0b59b2ab..00000000 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ /dev/null @@ -1,427 +0,0 @@ -"use client"; - -import React, { useState, useCallback, useEffect } from "react"; -import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./types"; -import { QueryEditor } from "./QueryEditor"; -import { ChartConfigPanel } from "./ChartConfigPanel"; -import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel"; -import { MapTestConfigPanel } from "./MapTestConfigPanel"; -import { DataSourceSelector } from "./data-sources/DataSourceSelector"; -import { DatabaseConfig } from "./data-sources/DatabaseConfig"; -import { ApiConfig } from "./data-sources/ApiConfig"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { X, ChevronLeft, ChevronRight, Save } from "lucide-react"; - -interface ElementConfigModalProps { - element: DashboardElement; - isOpen: boolean; - onClose: () => void; - onSave: (element: DashboardElement) => void; - onPreview?: (element: DashboardElement) => void; // 실시간 미리보기용 (저장 전) -} - -/** - * 요소 설정 모달 컴포넌트 (리팩토링) - * - 2단계 플로우: 데이터 소스 선택 → 데이터 설정 및 차트 설정 - * - 새로운 데이터 소스 컴포넌트 통합 - */ -export function ElementConfigModal({ element, isOpen, onClose, onSave, onPreview }: ElementConfigModalProps) { - const [dataSource, setDataSource] = useState( - element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }, - ); - const [chartConfig, setChartConfig] = useState(element.chartConfig || {}); - const [queryResult, setQueryResult] = useState(null); - const [currentStep, setCurrentStep] = useState<1 | 2>(1); - const [customTitle, setCustomTitle] = useState(element.customTitle || ""); - const [showHeader, setShowHeader] = useState(element.showHeader !== false); - - // 차트 설정이 필요 없는 위젯 (쿼리/API만 필요) - const isSimpleWidget = - element.subtype === "todo" || // To-Do 위젯 - element.subtype === "booking-alert" || // 예약 알림 위젯 - element.subtype === "maintenance" || // 정비 일정 위젯 - element.subtype === "document" || // 문서 위젯 - element.subtype === "risk-alert" || // 리스크 알림 위젯 - element.subtype === "vehicle-status" || - element.subtype === "vehicle-list" || - element.subtype === "status-summary" || // 커스텀 상태 카드 - // element.subtype === "list-summary" || // 커스텀 목록 카드 (다른 분 작업 중 - 임시 주석) - element.subtype === "delivery-status" || - element.subtype === "delivery-status-summary" || - element.subtype === "delivery-today-stats" || - element.subtype === "cargo-list" || - element.subtype === "customer-issues" || - element.subtype === "driver-management" || - element.subtype === "work-history" || // 작업 이력 위젯 (쿼리 필요) - element.subtype === "transport-stats"; // 커스텀 통계 카드 위젯 (쿼리 필요) - - // 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능) - const isSelfContainedWidget = - element.subtype === "weather" || // 날씨 위젯 (외부 API) - element.subtype === "exchange" || // 환율 위젯 (외부 API) - element.subtype === "calculator"; // 계산기 위젯 (자체 기능) - - // 지도 위젯 (위도/경도 매핑 필요) - const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary" || element.subtype === "map-test"; - - // 주석 - // 모달이 열릴 때 초기화 - useEffect(() => { - if (isOpen) { - const dataSourceToSet = element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }; - setDataSource(dataSourceToSet); - setChartConfig(element.chartConfig || {}); - setQueryResult(null); - setCurrentStep(1); - setCustomTitle(element.customTitle || ""); - setShowHeader(element.showHeader !== false); // showHeader 초기화 - - // 쿼리가 이미 있으면 자동 실행 - if (dataSourceToSet.type === "database" && dataSourceToSet.query) { - console.log("🔄 기존 쿼리 자동 실행:", dataSourceToSet.query); - executeQueryAutomatically(dataSourceToSet); - } - } - }, [isOpen, element]); - - // 쿼리 자동 실행 함수 - const executeQueryAutomatically = async (dataSourceToExecute: ChartDataSource) => { - if (dataSourceToExecute.type !== "database" || !dataSourceToExecute.query) return; - - try { - const { queryApi } = await import("@/lib/api/query"); - const result = await queryApi.executeQuery({ - query: dataSourceToExecute.query, - connectionType: dataSourceToExecute.connectionType || "current", - externalConnectionId: dataSourceToExecute.externalConnectionId, - }); - - console.log("✅ 쿼리 자동 실행 완료:", result); - setQueryResult(result); - } catch (error) { - console.error("❌ 쿼리 자동 실행 실패:", error); - // 실패해도 모달은 열리도록 (사용자가 다시 실행 가능) - } - }; - - // 데이터 소스 타입 변경 - const handleDataSourceTypeChange = useCallback((type: "database" | "api") => { - if (type === "database") { - setDataSource({ - type: "database", - connectionType: "current", - refreshInterval: 0, - }); - } else { - setDataSource({ - type: "api", - method: "GET", - refreshInterval: 0, - }); - } - - // 데이터 소스 변경 시 쿼리 결과와 차트 설정 초기화 - setQueryResult(null); - setChartConfig({}); - }, []); - - // 데이터 소스 업데이트 - const handleDataSourceUpdate = useCallback((updates: Partial) => { - setDataSource((prev) => ({ ...prev, ...updates })); - }, []); - - // 차트 설정 변경 처리 - const handleChartConfigChange = useCallback((newConfig: ChartConfig) => { - setChartConfig(newConfig); - - // 🎯 실시간 미리보기: chartConfig 변경 시 즉시 부모에게 전달 - if (onPreview) { - onPreview({ - ...element, - chartConfig: newConfig, - dataSource: dataSource, - customTitle: customTitle, - showHeader: showHeader, - }); - } - }, [element, dataSource, customTitle, showHeader, onPreview]); - - // 쿼리 테스트 결과 처리 - const handleQueryTest = useCallback((result: QueryResult) => { - setQueryResult(result); - - // 쿼리가 변경되었으므로 차트 설정 초기화 (X/Y축 리셋) - // console.log("🔄 쿼리 변경 감지 - 차트 설정 초기화"); - setChartConfig({}); - }, []); - - // 다음 단계로 이동 - const handleNext = useCallback(() => { - if (currentStep === 1) { - setCurrentStep(2); - } - }, [currentStep]); - - // 이전 단계로 이동 - const handlePrev = useCallback(() => { - if (currentStep > 1) { - setCurrentStep((prev) => (prev - 1) as 1 | 2); - } - }, [currentStep]); - - // 저장 처리 - const handleSave = useCallback(() => { - const updatedElement: DashboardElement = { - ...element, - dataSource, - chartConfig, - customTitle: customTitle.trim() || undefined, // 빈 문자열이면 undefined - showHeader, // 헤더 표시 여부 - }; - - // console.log(" 저장할 element:", updatedElement); - - onSave(updatedElement); - onClose(); - }, [element, dataSource, chartConfig, customTitle, showHeader, onSave, onClose]); - - // 모달이 열려있지 않으면 렌더링하지 않음 - if (!isOpen) return null; - - // 시계, 달력, 날씨, 환율, 계산기 위젯은 헤더 설정만 가능 - const isHeaderOnlyWidget = - element.type === "widget" && - (element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget); - - // 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음 - if (element.type === "widget" && element.subtype === "driver-management") { - return null; - } - - // 저장 가능 여부 확인 - const isPieChart = element.subtype === "pie" || element.subtype === "donut"; - const isApiSource = dataSource.type === "api"; - - // Y축 검증 헬퍼 - const hasYAxis = - chartConfig.yAxis && - (typeof chartConfig.yAxis === "string" || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0)); - - // customTitle이 변경되었는지 확인 - const isTitleChanged = customTitle.trim() !== (element.customTitle || ""); - - // showHeader가 변경되었는지 확인 - const isHeaderChanged = showHeader !== (element.showHeader !== false); - - const canSave = - isTitleChanged || // 제목만 변경해도 저장 가능 - isHeaderChanged || // 헤더 표시 여부만 변경해도 저장 가능 - (isSimpleWidget - ? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 (차트 설정 불필요) - currentStep === 2 && queryResult && queryResult.rows.length > 0 - : isMapWidget - ? // 지도 위젯: 타일맵 URL 또는 위도/경도 매핑 필요 - element.subtype === "map-test" - ? // 🧪 지도 테스트 위젯: 타일맵 URL만 있으면 저장 가능 - currentStep === 2 && chartConfig.tileMapUrl - : // 기존 지도 위젯: 쿼리 결과 + 위도/경도 필수 - currentStep === 2 && - queryResult && - queryResult.rows.length > 0 && - chartConfig.latitudeColumn && - chartConfig.longitudeColumn - : // 차트: 기존 로직 (2단계에서 차트 설정 필요) - currentStep === 2 && - queryResult && - queryResult.rows.length > 0 && - chartConfig.xAxis && - (isPieChart || isApiSource - ? // 파이/도넛 차트 또는 REST API - chartConfig.aggregation === "count" - ? true // count는 Y축 없어도 됨 - : hasYAxis // 다른 집계(sum, avg, max, min) 또는 집계 없음 → Y축 필수 - : // 일반 차트 (DB): Y축 필수 - hasYAxis)); - - return ( -
-
- {/* 모달 헤더 */} -
-
-
-

{element.title} 설정

-
- -
- - {/* 커스텀 제목 입력 */} -
- - setCustomTitle(e.target.value)} - onKeyDown={(e) => { - // 모든 키보드 이벤트를 input 필드 내부에서만 처리 - e.stopPropagation(); - }} - placeholder="예: 정비 일정 목록, 창고 위치 현황 등 (비워두면 자동 생성)" - className="focus:border-primary focus:ring-primary w-full rounded-md border border-border px-3 py-2 text-sm focus:ring-1 focus:outline-none" - /> -

- 비워두면 테이블명으로 자동 생성됩니다 (예: "maintenance_schedules 목록") -

-
- - {/* 헤더 표시 옵션 */} -
- setShowHeader(e.target.checked)} - className="text-primary focus:ring-primary h-4 w-4 rounded border-border" - /> - -
-
- - {/* 진행 상황 표시 - 간단한 위젯과 헤더 전용 위젯은 표시 안 함 */} - {!isSimpleWidget && !isHeaderOnlyWidget && ( -
-
-
- 단계 {currentStep} / 2: {currentStep === 1 ? "데이터 소스 선택" : "데이터 설정 및 차트 설정"} -
-
-
- )} - - {/* 단계별 내용 */} - {!isHeaderOnlyWidget && ( -
- {currentStep === 1 && ( - - )} - - {currentStep === 2 && ( -
- {/* 왼쪽: 데이터 설정 */} -
- {dataSource.type === "database" ? ( - <> - - - - ) : ( - - )} -
- - {/* 오른쪽: 설정 패널 */} - {!isSimpleWidget && ( -
- {isMapWidget ? ( - // 지도 위젯: 위도/경도 매핑 패널 - element.subtype === "map-test" ? ( - // 🧪 지도 테스트 위젯: 타일맵 URL 필수, 마커 데이터 선택사항 - - ) : queryResult && queryResult.rows.length > 0 ? ( - // 기존 지도 위젯: 쿼리 결과 필수 - - ) : ( -
-
-
데이터를 가져온 후 지도 설정이 표시됩니다
-
-
- ) - ) : // 차트: 차트 설정 패널 - queryResult && queryResult.rows.length > 0 ? ( - - ) : ( -
-
-
데이터를 가져온 후 차트 설정이 표시됩니다
-
-
- )} -
- )} -
- )} -
- )} - - {/* 모달 푸터 */} -
-
{queryResult && {queryResult.rows.length}개 데이터 로드됨}
- -
- {!isSimpleWidget && !isHeaderOnlyWidget && currentStep > 1 && ( - - )} - - {isHeaderOnlyWidget ? ( - // 헤더 전용 위젯: 바로 저장 - - ) : currentStep === 1 ? ( - // 1단계: 다음 버튼 (차트 위젯, 간단한 위젯 모두) - - ) : ( - // 2단계: 저장 버튼 - - )} -
-
-
-
- ); -} diff --git a/frontend/components/admin/dashboard/ElementConfigSidebar.tsx b/frontend/components/admin/dashboard/ElementConfigSidebar.tsx index 32a45a49..f5f83c50 100644 --- a/frontend/components/admin/dashboard/ElementConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/ElementConfigSidebar.tsx @@ -16,6 +16,10 @@ import { X } from "lucide-react"; import { cn } from "@/lib/utils"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import CustomMetricConfigSidebar from "./widgets/custom-metric/CustomMetricConfigSidebar"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; interface ElementConfigSidebarProps { element: DashboardElement | null; @@ -50,16 +54,11 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem // 사이드바가 열릴 때 초기화 useEffect(() => { if (isOpen && element) { - console.log("🔄 ElementConfigSidebar 초기화 - element.id:", element.id); - console.log("🔄 element.dataSources:", element.dataSources); - console.log("🔄 element.chartConfig?.dataSources:", element.chartConfig?.dataSources); - setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }); // dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드 // ⚠️ 중요: 없으면 반드시 빈 배열로 초기화 const initialDataSources = element.dataSources || element.chartConfig?.dataSources || []; - console.log("🔄 초기화된 dataSources:", initialDataSources); setDataSources(initialDataSources); setChartConfig(element.chartConfig || {}); @@ -69,7 +68,6 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem setShowHeader(element.showHeader !== false); } else if (!isOpen) { // 사이드바가 닫힐 때 모든 상태 초기화 - console.log("🧹 ElementConfigSidebar 닫힘 - 상태 초기화"); setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 }); setDataSources([]); setChartConfig({}); @@ -124,8 +122,8 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem (newConfig: ChartConfig) => { setChartConfig(newConfig); - // 🎯 실시간 미리보기: 즉시 부모에게 전달 (map-test 위젯용) - if (element && element.subtype === "map-test" && newConfig.tileMapUrl) { + // 🎯 실시간 미리보기: 즉시 부모에게 전달 (map-summary-v2 위젯용) + if (element && element.subtype === "map-summary-v2" && newConfig.tileMapUrl) { onApply({ ...element, chartConfig: newConfig, @@ -148,10 +146,6 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem const handleApply = useCallback(() => { if (!element) return; - console.log("🔧 적용 버튼 클릭 - dataSource:", dataSource); - console.log("🔧 적용 버튼 클릭 - dataSources:", dataSources); - console.log("🔧 적용 버튼 클릭 - chartConfig:", chartConfig); - // 다중 데이터 소스 위젯 체크 const isMultiDS = element.subtype === "map-summary-v2" || @@ -170,7 +164,6 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem showHeader, }; - console.log("🔧 적용할 요소:", updatedElement); onApply(updatedElement); // 사이드바는 열린 채로 유지 (연속 수정 가능) }, [element, dataSource, dataSources, chartConfig, customTitle, showHeader, onApply]); @@ -179,7 +172,7 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem if (!element) return null; // 리스트 위젯은 별도 사이드바로 처리 - if (element.subtype === "list") { + if (element.subtype === "list-v2") { return ( 0 : isMapWidget - ? element.subtype === "map-test" - ? chartConfig.tileMapUrl || (queryResult && queryResult.rows.length > 0) // 🧪 지도 테스트 위젯: 타일맵 URL 또는 API 데이터 + ? element.subtype === "map-summary-v2" + ? chartConfig.tileMapUrl || (queryResult && queryResult.rows.length > 0) // 지도 위젯: 타일맵 URL 또는 API 데이터 : queryResult && queryResult.rows.length > 0 && chartConfig.latitudeColumn && chartConfig.longitudeColumn : queryResult && queryResult.rows.length > 0 && @@ -291,62 +281,58 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem return (
{/* 헤더 */} -
+
- {element.title} + {element.title}
- +
{/* 본문: 스크롤 가능 영역 */}
{/* 기본 설정 카드 */} -
-
기본 설정
+
+
기본 설정
{/* 커스텀 제목 입력 */}
- setCustomTitle(e.target.value)} onKeyDown={(e) => e.stopPropagation()} placeholder="위젯 제목" - className="focus:border-primary focus:ring-primary/20 h-8 w-full rounded border border-border bg-muted px-2 text-xs placeholder:text-muted-foreground focus:bg-background focus:ring-1 focus:outline-none" + className="bg-muted focus:bg-background h-8 text-xs" />
{/* 헤더 표시 옵션 */} - + +
{/* 다중 데이터 소스 위젯 */} {isMultiDataSourceWidget && ( <> -
+
{ const updated = new Map(prev); updated.set(dataSourceId, result); - console.log("📊 테스트 결과 저장:", dataSourceId, result); return updated; }); }} @@ -372,11 +356,11 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem {/* 지도 위젯: 타일맵 URL 설정 */} {element.subtype === "map-summary-v2" && ( -
+
- +
-
+
타일맵 설정 (선택사항)
기본 VWorld 타일맵 사용 중
@@ -403,11 +387,13 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem {/* 차트 위젯: 차트 설정 */} {element.subtype === "chart" && ( -
+
- +
-
차트 설정
+
+ 차트 설정 +
{testResults.size > 0 ? `${testResults.size}개 데이터 소스 • X축, Y축, 차트 타입 설정` @@ -439,24 +425,26 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem {/* 헤더 전용 위젯이 아닐 때만 데이터 소스 표시 */} {!isHeaderOnlyWidget && !isMultiDataSourceWidget && ( -
-
데이터 소스
+
+
+ 데이터 소스 +
handleDataSourceTypeChange(value as "database" | "api")} className="w-full" > - + 데이터베이스 REST API @@ -472,10 +460,10 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem {/* 차트/지도 설정 */} {!isSimpleWidget && - (element.subtype === "map-test" || (queryResult && queryResult.rows.length > 0)) && ( + (element.subtype === "map-summary-v2" || (queryResult && queryResult.rows.length > 0)) && (
{isMapWidget ? ( - element.subtype === "map-test" ? ( + element.subtype === "map-summary-v2" ? ( 0)) && ( + (element.subtype === "map-summary-v2" || (queryResult && queryResult.rows.length > 0)) && (
{isMapWidget ? ( - element.subtype === "map-test" ? ( + element.subtype === "map-summary-v2" ? ( -
- - {queryResult.rows.length}개 데이터 로드됨 - +
+
+ {queryResult.rows.length}개 데이터 로드됨
)}
@@ -564,20 +550,13 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
{/* 푸터: 적용 버튼 */} -
- - + +
); diff --git a/frontend/components/admin/dashboard/index.ts b/frontend/components/admin/dashboard/index.ts index 40dc17ab..f8f2e824 100644 --- a/frontend/components/admin/dashboard/index.ts +++ b/frontend/components/admin/dashboard/index.ts @@ -2,12 +2,10 @@ * 대시보드 관리 컴포넌트 인덱스 */ -export { default as DashboardDesigner } from './DashboardDesigner'; -export { DashboardCanvas } from './DashboardCanvas'; -export { DashboardSidebar } from './DashboardSidebar'; -export { DashboardToolbar } from './DashboardToolbar'; -export { CanvasElement } from './CanvasElement'; -export { QueryEditor } from './QueryEditor'; -export { ChartConfigPanel } from './ChartConfigPanel'; -export { ElementConfigModal } from './ElementConfigModal'; -export * from './types'; +export { default as DashboardDesigner } from "./DashboardDesigner"; +export { DashboardCanvas } from "./DashboardCanvas"; +export { DashboardSidebar } from "./DashboardSidebar"; +export { CanvasElement } from "./CanvasElement"; +export { QueryEditor } from "./QueryEditor"; +export { ChartConfigPanel } from "./ChartConfigPanel"; +export * from "./types"; diff --git a/frontend/components/admin/dashboard/widgets/ListWidgetConfigModal.tsx b/frontend/components/admin/dashboard/widgets/ListWidgetConfigModal.tsx deleted file mode 100644 index 77757333..00000000 --- a/frontend/components/admin/dashboard/widgets/ListWidgetConfigModal.tsx +++ /dev/null @@ -1,326 +0,0 @@ -"use client"; - -import React, { useState, useCallback, useEffect } from "react"; -import { DashboardElement, ChartDataSource, QueryResult, ListWidgetConfig, ListColumn } from "../types"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { ChevronLeft, ChevronRight, Save, X } from "lucide-react"; -import { DataSourceSelector } from "../data-sources/DataSourceSelector"; -import { DatabaseConfig } from "../data-sources/DatabaseConfig"; -import { ApiConfig } from "../data-sources/ApiConfig"; -import { QueryEditor } from "../QueryEditor"; -import { ColumnSelector } from "./list-widget/ColumnSelector"; -import { ManualColumnEditor } from "./list-widget/ManualColumnEditor"; -import { ListTableOptions } from "./list-widget/ListTableOptions"; - -interface ListWidgetConfigModalProps { - isOpen: boolean; - element: DashboardElement; - onClose: () => void; - onSave: (updates: Partial) => void; -} - -/** - * 리스트 위젯 설정 모달 - * - 3단계 설정: 데이터 소스 → 데이터 가져오기 → 컬럼 설정 - */ -export function ListWidgetConfigModal({ isOpen, element, onClose, onSave }: ListWidgetConfigModalProps) { - const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1); - const [title, setTitle] = useState(element.title || "📋 리스트"); - const [dataSource, setDataSource] = useState( - element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }, - ); - const [queryResult, setQueryResult] = useState(null); - const [listConfig, setListConfig] = useState( - element.listConfig || { - columnMode: "auto", - viewMode: "table", - columns: [], - pageSize: 10, - enablePagination: true, - showHeader: true, - stripedRows: true, - compactMode: false, - cardColumns: 3, - }, - ); - - // 모달 열릴 때 element에서 설정 로드 (한 번만) - useEffect(() => { - if (isOpen) { - // element가 변경되었을 때만 설정을 다시 로드 - setTitle(element.title || "📋 리스트"); - - // 기존 dataSource가 있으면 그대로 사용, 없으면 기본값 - if (element.dataSource) { - setDataSource(element.dataSource); - } - - // 기존 listConfig가 있으면 그대로 사용, 없으면 기본값 - if (element.listConfig) { - setListConfig(element.listConfig); - } - - // 현재 스텝은 1로 초기화 - setCurrentStep(1); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen, element.id]); // element.id가 변경될 때만 재실행 - - // 데이터 소스 타입 변경 - const handleDataSourceTypeChange = useCallback((type: "database" | "api") => { - if (type === "database") { - setDataSource((prev) => ({ - ...prev, - type: "database", - connectionType: "current", - })); - } else { - setDataSource((prev) => ({ - ...prev, - type: "api", - method: "GET", - })); - } - - // 데이터 소스 타입 변경 시에는 쿼리 결과만 초기화 (컬럼 설정은 유지) - setQueryResult(null); - }, []); - - // 데이터 소스 업데이트 - const handleDataSourceUpdate = useCallback((updates: Partial) => { - setDataSource((prev) => ({ ...prev, ...updates })); - }, []); - - // 쿼리 실행 결과 처리 - const handleQueryTest = useCallback((result: QueryResult) => { - setQueryResult(result); - - // 쿼리 실행할 때마다 컬럼 초기화 후 자동 생성 - if (result.columns.length > 0) { - const autoColumns: ListColumn[] = result.columns.map((col, idx) => ({ - id: `col_${idx}`, - label: col, - field: col, - align: "left", - visible: true, - })); - setListConfig((prev) => ({ ...prev, columns: autoColumns })); - } - }, []); - - // 다음 단계 - const handleNext = () => { - if (currentStep < 3) { - setCurrentStep((prev) => (prev + 1) as 1 | 2 | 3); - } - }; - - // 이전 단계 - const handlePrev = () => { - if (currentStep > 1) { - setCurrentStep((prev) => (prev - 1) as 1 | 2 | 3); - } - }; - - // 저장 - const handleSave = () => { - onSave({ - customTitle: title, - dataSource, - listConfig, - }); - onClose(); - }; - - // 저장 가능 여부 - const canSave = queryResult && queryResult.rows.length > 0 && listConfig.columns.length > 0; - - if (!isOpen) return null; - - return ( -
-
- {/* 헤더 */} -
-
-
-

📋 리스트 위젯 설정

-

데이터 소스와 컬럼을 설정하세요

-
- -
- {/* 제목 입력 */} -
- - setTitle(e.target.value)} - onKeyDown={(e) => { - // 모든 키보드 이벤트를 input 필드 내부에서만 처리 - e.stopPropagation(); - }} - placeholder="예: 사용자 목록" - className="mt-1" - /> -
- - {/* 참고: 리스트 위젯은 제목이 항상 표시됩니다 */} -
💡 리스트 위젯은 제목이 항상 표시됩니다
-
- - {/* 진행 상태 표시 */} -
-
-
-
= 1 ? "text-primary" : "text-muted-foreground"}`}> -
= 1 ? "bg-primary text-white" : "bg-muted"}`} - > - 1 -
- 데이터 소스 -
-
-
= 2 ? "text-primary" : "text-muted-foreground"}`}> -
= 2 ? "bg-primary text-white" : "bg-muted"}`} - > - 2 -
- 데이터 가져오기 -
-
-
= 3 ? "text-primary" : "text-muted-foreground"}`}> -
= 3 ? "bg-primary text-white" : "bg-muted"}`} - > - 3 -
- 컬럼 설정 -
-
-
-
- - {/* 컨텐츠 */} -
- {currentStep === 1 && ( - - )} - - {currentStep === 2 && ( -
- {/* 왼쪽: 데이터 소스 설정 */} -
- {dataSource.type === "database" ? ( - - ) : ( - - )} - - {dataSource.type === "database" && ( -
- -
- )} -
- - {/* 오른쪽: 데이터 미리보기 */} -
- {queryResult && queryResult.rows.length > 0 ? ( -
-

📋 데이터 미리보기

-
- - {queryResult.totalRows}개 데이터 - -
-                        {JSON.stringify(queryResult.rows.slice(0, 3), null, 2)}
-                      
-
-
- ) : ( -
-
-
데이터를 가져온 후 미리보기가 표시됩니다
-
-
- )} -
-
- )} - - {currentStep === 3 && queryResult && ( -
- {listConfig.columnMode === "auto" ? ( - setListConfig((prev) => ({ ...prev, columns }))} - /> - ) : ( - setListConfig((prev) => ({ ...prev, columns }))} - /> - )} - - setListConfig((prev) => ({ ...prev, ...updates }))} - /> -
- )} -
- - {/* 푸터 */} -
-
- {queryResult && ( - - 📊 {queryResult.rows.length}개 데이터 로드됨 - - )} -
- -
- {currentStep > 1 && ( - - )} - - {currentStep < 3 ? ( - - ) : ( - - )} -
-
-
-
- ); -} diff --git a/frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx b/frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx deleted file mode 100644 index 6c73f976..00000000 --- a/frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx +++ /dev/null @@ -1,664 +0,0 @@ -"use client"; - -import React, { useState, useCallback, useEffect } from "react"; -import { DashboardElement, ChartDataSource, QueryResult } from "../types"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { ChevronLeft, ChevronRight, Save, X } from "lucide-react"; -import { DataSourceSelector } from "../data-sources/DataSourceSelector"; -import { DatabaseConfig } from "../data-sources/DatabaseConfig"; -import { ApiConfig } from "../data-sources/ApiConfig"; -import { QueryEditor } from "../QueryEditor"; - -interface TodoWidgetConfigModalProps { - isOpen: boolean; - element: DashboardElement; - onClose: () => void; - onSave: (updates: Partial) => void; -} - -/** - * 일정관리 위젯 설정 모달 (범용) - * - 2단계 설정: 데이터 소스 → 쿼리 입력/테스트 - */ -export function TodoWidgetConfigModal({ isOpen, element, onClose, onSave }: TodoWidgetConfigModalProps) { - const [currentStep, setCurrentStep] = useState<1 | 2>(1); - const [title, setTitle] = useState(element.title || "일정관리 위젯"); - const [dataSource, setDataSource] = useState( - element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }, - ); - const [queryResult, setQueryResult] = useState(null); - - // 데이터베이스 연동 설정 - const [enableDbSync, setEnableDbSync] = useState(element.chartConfig?.enableDbSync || false); - const [dbSyncMode, setDbSyncMode] = useState<"simple" | "advanced">(element.chartConfig?.dbSyncMode || "simple"); - const [tableName, setTableName] = useState(element.chartConfig?.tableName || ""); - const [columnMapping, setColumnMapping] = useState(element.chartConfig?.columnMapping || { - id: "id", - title: "title", - description: "description", - priority: "priority", - status: "status", - assignedTo: "assigned_to", - dueDate: "due_date", - isUrgent: "is_urgent", - }); - - // 모달 열릴 때 element에서 설정 로드 - useEffect(() => { - if (isOpen) { - setTitle(element.title || "일정관리 위젯"); - - // 데이터 소스 설정 로드 (저장된 설정 우선, 없으면 기본값) - const loadedDataSource = element.dataSource || { - type: "database", - connectionType: "current", - refreshInterval: 0 - }; - setDataSource(loadedDataSource); - - // 저장된 쿼리가 있으면 자동으로 실행 (실제 결과 가져오기) - if (loadedDataSource.query) { - // 쿼리 자동 실행 - const executeQuery = async () => { - try { - const token = localStorage.getItem("authToken"); - const userLang = localStorage.getItem("userLang") || "KR"; - - const apiUrl = loadedDataSource.connectionType === "external" && loadedDataSource.externalConnectionId - ? `http://localhost:9771/api/external-db/query?userLang=${userLang}` - : `http://localhost:9771/api/dashboards/execute-query?userLang=${userLang}`; - - const requestBody = loadedDataSource.connectionType === "external" && loadedDataSource.externalConnectionId - ? { - connectionId: parseInt(loadedDataSource.externalConnectionId), - query: loadedDataSource.query, - } - : { query: loadedDataSource.query }; - - const response = await fetch(apiUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(requestBody), - }); - - if (response.ok) { - const result = await response.json(); - const rows = result.data?.rows || result.data || []; - setQueryResult({ - rows: rows, - rowCount: rows.length, - executionTime: 0, - }); - } else { - // 실패해도 더미 결과로 2단계 진입 가능 - setQueryResult({ - rows: [{ _info: "저장된 쿼리가 있습니다. 다시 테스트해주세요." }], - rowCount: 1, - executionTime: 0, - }); - } - } catch (error) { - // 에러 발생해도 2단계 진입 가능 - setQueryResult({ - rows: [{ _info: "저장된 쿼리가 있습니다. 다시 테스트해주세요." }], - rowCount: 1, - executionTime: 0, - }); - } - }; - - executeQuery(); - } - - // DB 동기화 설정 로드 - setEnableDbSync(element.chartConfig?.enableDbSync || false); - setDbSyncMode(element.chartConfig?.dbSyncMode || "simple"); - setTableName(element.chartConfig?.tableName || ""); - if (element.chartConfig?.columnMapping) { - setColumnMapping(element.chartConfig.columnMapping); - } - setCurrentStep(1); - } - }, [isOpen, element.id]); - - // 데이터 소스 타입 변경 - const handleDataSourceTypeChange = useCallback((type: "database" | "api") => { - if (type === "database") { - setDataSource((prev) => ({ - ...prev, - type: "database", - connectionType: "current", - })); - } else { - setDataSource((prev) => ({ - ...prev, - type: "api", - method: "GET", - })); - } - setQueryResult(null); - }, []); - - // 데이터 소스 업데이트 - const handleDataSourceUpdate = useCallback((updates: Partial) => { - setDataSource((prev) => ({ ...prev, ...updates })); - }, []); - - // 쿼리 실행 결과 처리 - const handleQueryTest = useCallback( - (result: QueryResult) => { - // console.log("🎯 TodoWidget - handleQueryTest 호출됨!"); - // console.log("📊 쿼리 결과:", result); - // console.log("📝 rows 개수:", result.rows?.length); - // console.log("❌ error:", result.error); - setQueryResult(result); - // console.log("✅ setQueryResult 호출 완료!"); - - // 강제 리렌더링 확인 - // setTimeout(() => { - // console.log("🔄 1초 후 queryResult 상태:", result); - // }, 1000); - }, - [], - ); - - // 저장 - const handleSave = useCallback(() => { - if (!dataSource.query || !queryResult || queryResult.error) { - alert("쿼리를 입력하고 테스트를 먼저 실행해주세요."); - return; - } - - if (!queryResult.rows || queryResult.rows.length === 0) { - alert("쿼리 결과가 없습니다. 데이터가 반환되는 쿼리를 입력해주세요."); - return; - } - - // 간편 모드에서 테이블명 필수 체크 - if (enableDbSync && dbSyncMode === "simple" && !tableName.trim()) { - alert("데이터베이스 연동을 활성화하려면 테이블명을 입력해주세요."); - return; - } - - onSave({ - title, - dataSource, - chartConfig: { - ...element.chartConfig, - enableDbSync, - dbSyncMode, - tableName, - columnMapping, - insertQuery: element.chartConfig?.insertQuery, - updateQuery: element.chartConfig?.updateQuery, - deleteQuery: element.chartConfig?.deleteQuery, - }, - }); - - onClose(); - }, [title, dataSource, queryResult, enableDbSync, dbSyncMode, tableName, columnMapping, element.chartConfig, onSave, onClose]); - - // 다음 단계로 - const handleNext = useCallback(() => { - if (currentStep === 1) { - if (dataSource.type === "database") { - if (!dataSource.connectionId && dataSource.connectionType === "external") { - alert("외부 데이터베이스를 선택해주세요."); - return; - } - } else if (dataSource.type === "api") { - if (!dataSource.url) { - alert("API URL을 입력해주세요."); - return; - } - } - setCurrentStep(2); - } - }, [currentStep, dataSource]); - - // 이전 단계로 - const handlePrev = useCallback(() => { - if (currentStep === 2) { - setCurrentStep(1); - } - }, [currentStep]); - - if (!isOpen) return null; - - return ( -
-
- {/* 헤더 */} -
-
-

일정관리 위젯 설정

-

- 데이터 소스와 쿼리를 설정하면 자동으로 일정 목록이 표시됩니다 -

-
- -
- - {/* 진행 상태 */} -
-
-
-
- 1 -
- 데이터 소스 선택 -
- -
-
- 2 -
- 쿼리 입력 및 테스트 -
-
-
- - {/* 본문 */} -
- {/* Step 1: 데이터 소스 선택 */} - {currentStep === 1 && ( -
-
- - setTitle(e.target.value)} - placeholder="예: 오늘의 일정" - className="mt-2" - /> -
- -
- - -
- - {dataSource.type === "database" && ( - - )} - - {dataSource.type === "api" && } -
- )} - - {/* Step 2: 쿼리 입력 및 테스트 */} - {currentStep === 2 && ( -
-
-
-

💡 컬럼명 가이드

-

- 쿼리 결과에 다음 컬럼명이 있으면 자동으로 일정 항목으로 변환됩니다: -

-
    -
  • - id - 고유 ID (없으면 자동 생성) -
  • -
  • - title,{" "} - task,{" "} - name - 제목 (필수) -
  • -
  • - description,{" "} - desc,{" "} - content - 상세 설명 -
  • -
  • - priority - 우선순위 (urgent, high, - normal, low) -
  • -
  • - status - 상태 (pending, in_progress, - completed) -
  • -
  • - assigned_to,{" "} - assignedTo,{" "} - user - 담당자 -
  • -
  • - due_date,{" "} - dueDate,{" "} - deadline - 마감일 -
  • -
  • - is_urgent,{" "} - isUrgent,{" "} - urgent - 긴급 여부 -
  • -
-
- - -
- - {/* 디버그: 항상 표시되는 테스트 메시지 */} -
-

- 🔍 디버그: queryResult 상태 = {queryResult ? "있음" : "없음"} -

- {queryResult && ( -

- rows: {queryResult.rows?.length}개, error: {queryResult.error || "없음"} -

- )} -
- - {queryResult && !queryResult.error && queryResult.rows && queryResult.rows.length > 0 && ( -
-

✅ 쿼리 테스트 성공!

-

- 총 {queryResult.rows.length}개의 일정 항목을 찾았습니다. -

-
-

첫 번째 데이터 미리보기:

-
-                      {JSON.stringify(queryResult.rows[0], null, 2)}
-                    
-
-
- )} - - {/* 데이터베이스 연동 쿼리 (선택사항) */} -
-
-
-

🔗 데이터베이스 연동 (선택사항)

-

- 위젯에서 추가/수정/삭제 시 데이터베이스에 직접 반영 -

-
- -
- - {enableDbSync && ( - <> - {/* 모드 선택 */} -
- - -
- - {/* 간편 모드 */} - {dbSyncMode === "simple" && ( -
-

- 테이블명과 컬럼 매핑만 입력하면 자동으로 INSERT/UPDATE/DELETE 쿼리가 생성됩니다. -

- - {/* 테이블명 */} -
- - setTableName(e.target.value)} - placeholder="예: tasks" - className="mt-2" - /> -
- - {/* 컬럼 매핑 */} -
- -
-
- - setColumnMapping({ ...columnMapping, id: e.target.value })} - placeholder="id" - className="mt-1 h-8 text-sm" - /> -
-
- - setColumnMapping({ ...columnMapping, title: e.target.value })} - placeholder="title" - className="mt-1 h-8 text-sm" - /> -
-
- - setColumnMapping({ ...columnMapping, description: e.target.value })} - placeholder="description" - className="mt-1 h-8 text-sm" - /> -
-
- - setColumnMapping({ ...columnMapping, priority: e.target.value })} - placeholder="priority" - className="mt-1 h-8 text-sm" - /> -
-
- - setColumnMapping({ ...columnMapping, status: e.target.value })} - placeholder="status" - className="mt-1 h-8 text-sm" - /> -
-
- - setColumnMapping({ ...columnMapping, assignedTo: e.target.value })} - placeholder="assigned_to" - className="mt-1 h-8 text-sm" - /> -
-
- - setColumnMapping({ ...columnMapping, dueDate: e.target.value })} - placeholder="due_date" - className="mt-1 h-8 text-sm" - /> -
-
- - setColumnMapping({ ...columnMapping, isUrgent: e.target.value })} - placeholder="is_urgent" - className="mt-1 h-8 text-sm" - /> -
-
-
-
- )} - - {/* 고급 모드 */} - {dbSyncMode === "advanced" && ( -
-

- 복잡한 로직이 필요한 경우 직접 쿼리를 작성하세요. -

- - {/* INSERT 쿼리 */} -
- -

- 사용 가능한 변수: ${"{title}"}, ${"{description}"}, ${"{priority}"}, ${"{status}"}, ${"{assignedTo}"}, ${"{dueDate}"}, ${"{isUrgent}"} -

-