diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index aa7213d9..0c7bad25 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -19,6 +19,9 @@ services: CORS_CREDENTIALS: "true" LOG_LEVEL: info ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure + KMA_API_KEY: ogdXr2e9T4iHV69nvV-IwA + ITS_API_KEY: d6b9befec3114d648284674b8fddcc32 + EXPRESSWAY_API_KEY: ${EXPRESSWAY_API_KEY:-} volumes: - backend_uploads:/app/uploads - backend_data:/app/data diff --git a/docker/prod/docker-compose.backend.prod.yml b/docker/prod/docker-compose.backend.prod.yml index 9c56830c..f924d56e 100644 --- a/docker/prod/docker-compose.backend.prod.yml +++ b/docker/prod/docker-compose.backend.prod.yml @@ -5,7 +5,10 @@ services: context: ../../backend-node dockerfile: ../docker/prod/backend.Dockerfile # 운영용 Dockerfile container_name: pms-backend-prod - network_mode: "host" # 호스트 네트워크 모드 + ports: + - "8080:8080" # 호스트:컨테이너 포트 매핑 + networks: + - pms-network environment: - NODE_ENV=production - PORT=8080 @@ -27,4 +30,4 @@ services: networks: pms-network: - driver: bridge + external: true # 외부에서 생성된 네트워크 사용 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/[dashboardId]/page.tsx b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx index 54d61f77..5ece6a66 100644 --- a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx +++ b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx @@ -163,6 +163,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { 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/app/globals.css b/frontend/app/globals.css index d3c13848..d01fa8a4 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -51,8 +51,37 @@ /* Font Families */ --font-sans: var(--font-inter); --font-mono: var(--font-jetbrains-mono); - - /* Border Radius */ + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-success: var(--success); + --color-warning: var(--warning); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 23d51880..599718b9 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -26,124 +26,124 @@ import { // 위젯 동적 임포트 const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); const ExchangeWidget = dynamic(() => import("@/components/dashboard/widgets/ExchangeWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); const CalculatorWidget = dynamic(() => import("@/components/dashboard/widgets/CalculatorWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); const VehicleStatusWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleStatusWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); const VehicleListWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleListWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); const VehicleMapOnlyWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleMapOnlyWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); // 범용 지도 위젯 (차량, 창고, 고객 등 모든 위치 위젯 통합) const MapSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/MapSummaryWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); // 🧪 테스트용 지도 위젯 (REST API 지원) const MapTestWidget = dynamic(() => import("@/components/dashboard/widgets/MapTestWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); // 🧪 테스트용 지도 위젯 V2 (다중 데이터 소스) const MapTestWidgetV2 = dynamic(() => import("@/components/dashboard/widgets/MapTestWidgetV2"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); // 🧪 테스트용 차트 위젯 (다중 데이터 소스) const ChartTestWidget = dynamic(() => import("@/components/dashboard/widgets/ChartTestWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); const ListTestWidget = dynamic( () => import("@/components/dashboard/widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }, ); const CustomMetricTestWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricTestWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); const RiskAlertTestWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertTestWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); // 범용 상태 요약 위젯 (차량, 배송 등 모든 상태 위젯 통합) const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/StatusSummaryWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); // 범용 목록 위젯 (차량, 기사, 제품 등 모든 목록 위젯 통합) - 다른 분 작업 중, 임시 주석 /* const ListSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/ListSummaryWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); */ // 개별 위젯들 (주석 처리 - StatusSummaryWidget으로 통합됨) // const DeliveryStatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryStatusSummaryWidget"), { // ssr: false, -// loading: () =>
로딩 중...
, +// loading: () =>
로딩 중...
, // }); // const DeliveryTodayStatsWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryTodayStatsWidget"), { // ssr: false, -// loading: () =>
로딩 중...
, +// loading: () =>
로딩 중...
, // }); // const CargoListWidget = dynamic(() => import("@/components/dashboard/widgets/CargoListWidget"), { // ssr: false, -// loading: () =>
로딩 중...
, +// loading: () =>
로딩 중...
, // }); // const CustomerIssuesWidget = dynamic(() => import("@/components/dashboard/widgets/CustomerIssuesWidget"), { // ssr: false, -// loading: () =>
로딩 중...
, +// loading: () =>
로딩 중...
, // }); const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); const TaskWidget = dynamic(() => import("@/components/dashboard/widgets/TaskWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); const BookingAlertWidget = dynamic(() => import("@/components/dashboard/widgets/BookingAlertWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); const DocumentWidget = dynamic(() => import("@/components/dashboard/widgets/DocumentWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); // 시계 위젯 임포트 @@ -160,25 +160,25 @@ import { Button } from "@/components/ui/button"; // 야드 관리 3D 위젯 const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); // 작업 이력 위젯 const WorkHistoryWidget = dynamic(() => import("@/components/dashboard/widgets/WorkHistoryWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); // 커스텀 통계 카드 위젯 const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/CustomStatsWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); // 사용자 커스텀 카드 위젯 const CustomMetricWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricWidget"), { ssr: false, - loading: () =>
로딩 중...
, + loading: () =>
로딩 중...
, }); interface CanvasElementProps { @@ -712,33 +712,33 @@ export function CanvasElement({ if (element.type === "chart") { switch (element.subtype) { case "bar": - return "bg-gradient-to-br from-indigo-400 to-purple-600"; + return "bg-gradient-to-br from-primary to-purple-500"; case "pie": - return "bg-gradient-to-br from-pink-400 to-red-500"; + return "bg-gradient-to-br from-destructive to-destructive/80"; case "line": - return "bg-gradient-to-br from-blue-400 to-cyan-400"; + return "bg-gradient-to-br from-primary to-primary/80"; default: - return "bg-gray-200"; + return "bg-muted"; } } else if (element.type === "widget") { switch (element.subtype) { case "exchange": - return "bg-gradient-to-br from-pink-400 to-yellow-400"; + return "bg-gradient-to-br from-warning to-warning/80"; case "weather": - return "bg-gradient-to-br from-cyan-400 to-indigo-800"; + return "bg-gradient-to-br from-primary to-primary/80"; case "clock": - return "bg-gradient-to-br from-teal-400 to-cyan-600"; + return "bg-gradient-to-br from-primary to-primary/80"; case "calendar": - return "bg-gradient-to-br from-indigo-400 to-purple-600"; + return "bg-gradient-to-br from-primary to-purple-500"; case "driver-management": - return "bg-gradient-to-br from-blue-400 to-indigo-600"; + return "bg-gradient-to-br from-primary to-primary"; case "list": - return "bg-gradient-to-br from-cyan-400 to-blue-600"; + return "bg-gradient-to-br from-primary to-primary/80"; default: - return "bg-gray-200"; + return "bg-muted"; } } - return "bg-gray-200"; + return "bg-muted"; }; // 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용 @@ -758,7 +758,7 @@ export function CanvasElement({
{element.customTitle || element.title} + {element.customTitle || element.title} ) : null}
@@ -817,7 +817,7 @@ export function CanvasElement({ - - - - {/* 캔버스 배경색 변경 버튼 */} -
- - - {/* 색상 선택 패널 */} - {showColorPicker && ( -
-
- onCanvasBackgroundColorChange(e.target.value)} - className="h-10 w-16 border border-gray-300 rounded cursor-pointer" - /> - onCanvasBackgroundColorChange(e.target.value)} - placeholder="#ffffff" - className="flex-1 px-2 py-1 text-sm border border-gray-300 rounded" - /> -
- - {/* 프리셋 색상 */} -
- {[ - '#ffffff', '#f9fafb', '#f3f4f6', '#e5e7eb', - '#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', - '#10b981', '#06b6d4', '#6366f1', '#84cc16', - ].map((color) => ( -
- - -
- )} -
-
- ); -} diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index 12d73f98..14a2283a 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -11,7 +11,13 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Save, Trash2, Palette } from "lucide-react"; +import { Save, Trash2, Palette, Download } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { ElementType, ElementSubtype } from "./types"; import { ResolutionSelector, Resolution } from "./ResolutionSelector"; import { Input } from "@/components/ui/input"; @@ -66,14 +72,206 @@ export function DashboardTopMenu({ } }; + // 대시보드 다운로드 + // 헬퍼 함수: dataUrl로 다운로드 처리 + const handleDownloadWithDataUrl = async ( + dataUrl: string, + format: "png" | "pdf", + canvasWidth: number, + canvasHeight: number + ) => { + if (format === "png") { + console.log("💾 PNG 다운로드 시작..."); + const link = document.createElement("a"); + const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.png`; + link.download = filename; + link.href = dataUrl; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + console.log("✅ PNG 다운로드 완료:", filename); + } else { + console.log("📄 PDF 생성 중..."); + const jsPDF = (await import("jspdf")).default; + + // dataUrl에서 이미지 크기 계산 + const img = new Image(); + img.src = dataUrl; + await new Promise((resolve) => { + img.onload = resolve; + }); + + console.log("📐 이미지 실제 크기:", { width: img.width, height: img.height }); + console.log("📐 캔버스 계산 크기:", { width: canvasWidth, height: canvasHeight }); + + // PDF 크기 계산 (A4 기준) + const imgWidth = 210; // A4 width in mm + const actualHeight = canvasHeight; + const actualWidth = canvasWidth; + const imgHeight = (actualHeight * imgWidth) / actualWidth; + + console.log("📄 PDF 크기:", { width: imgWidth, height: imgHeight }); + + const pdf = new jsPDF({ + orientation: imgHeight > imgWidth ? "portrait" : "landscape", + unit: "mm", + format: [imgWidth, imgHeight], + }); + + pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight); + const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.pdf`; + pdf.save(filename); + console.log("✅ PDF 다운로드 완료:", filename); + } + }; + + const handleDownload = async (format: "png" | "pdf") => { + try { + console.log("🔍 다운로드 시작:", format); + + // 실제 위젯들이 있는 캔버스 찾기 + const canvas = document.querySelector(".dashboard-canvas") as HTMLElement; + console.log("🔍 캔버스 찾기:", canvas); + + if (!canvas) { + alert("대시보드를 찾을 수 없습니다. 페이지를 새로고침 후 다시 시도해주세요."); + return; + } + + console.log("📸 html-to-image 로딩 중..."); + // html-to-image 동적 import + const { toPng, toJpeg } = await import("html-to-image"); + + console.log("📸 캔버스 캡처 중..."); + + // 3D/WebGL 렌더링 완료 대기 + console.log("⏳ 3D 렌더링 완료 대기 중..."); + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // WebGL 캔버스를 이미지로 변환 (Three.js 캔버스 보존) + console.log("🎨 WebGL 캔버스 처리 중..."); + const webglCanvases = canvas.querySelectorAll("canvas"); + const webglImages: { canvas: HTMLCanvasElement; dataUrl: string; rect: DOMRect }[] = []; + + webglCanvases.forEach((webglCanvas) => { + try { + const rect = webglCanvas.getBoundingClientRect(); + const dataUrl = webglCanvas.toDataURL("image/png"); + webglImages.push({ canvas: webglCanvas, dataUrl, rect }); + console.log("✅ WebGL 캔버스 캡처:", { width: rect.width, height: rect.height }); + } catch (error) { + console.warn("⚠️ WebGL 캔버스 캡처 실패:", error); + } + }); + + // 캔버스의 실제 크기와 위치 가져오기 + const rect = canvas.getBoundingClientRect(); + const canvasWidth = canvas.scrollWidth; + + // 실제 콘텐츠의 최하단 위치 계산 + const children = canvas.querySelectorAll(".canvas-element"); + let maxBottom = 0; + children.forEach((child) => { + const childRect = child.getBoundingClientRect(); + const relativeBottom = childRect.bottom - rect.top; + if (relativeBottom > maxBottom) { + maxBottom = relativeBottom; + } + }); + + // 실제 콘텐츠 높이 + 여유 공간 (50px) + const canvasHeight = maxBottom > 0 ? maxBottom + 50 : canvas.scrollHeight; + + console.log("📐 캔버스 정보:", { + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + scroll: { width: canvasWidth, height: canvas.scrollHeight }, + calculated: { width: canvasWidth, height: canvasHeight }, + maxBottom: maxBottom, + webglCount: webglImages.length + }); + + // html-to-image로 캔버스 캡처 (WebGL 제외) + const dataUrl = await toPng(canvas, { + backgroundColor: backgroundColor || "#ffffff", + width: canvasWidth, + height: canvasHeight, + pixelRatio: 2, // 고해상도 + cacheBust: true, + skipFonts: false, + preferredFontFormat: 'woff2', + filter: (node) => { + // WebGL 캔버스는 제외 (나중에 수동으로 합성) + if (node instanceof HTMLCanvasElement) { + return false; + } + return true; + }, + }); + + // WebGL 캔버스를 이미지 위에 합성 + if (webglImages.length > 0) { + console.log("🖼️ WebGL 이미지 합성 중..."); + const img = new Image(); + img.src = dataUrl; + await new Promise((resolve) => { + img.onload = resolve; + }); + + // 새 캔버스에 합성 + const compositeCanvas = document.createElement("canvas"); + compositeCanvas.width = img.width; + compositeCanvas.height = img.height; + const ctx = compositeCanvas.getContext("2d"); + + if (ctx) { + // 기본 이미지 그리기 + ctx.drawImage(img, 0, 0); + + // WebGL 이미지들을 위치에 맞게 그리기 + for (const { dataUrl: webglDataUrl, rect: webglRect } of webglImages) { + const webglImg = new Image(); + webglImg.src = webglDataUrl; + await new Promise((resolve) => { + webglImg.onload = resolve; + }); + + // 상대 위치 계산 (pixelRatio 2 고려) + const relativeX = (webglRect.left - rect.left) * 2; + const relativeY = (webglRect.top - rect.top) * 2; + const width = webglRect.width * 2; + const height = webglRect.height * 2; + + ctx.drawImage(webglImg, relativeX, relativeY, width, height); + console.log("✅ WebGL 이미지 합성 완료:", { x: relativeX, y: relativeY, width, height }); + } + + // 합성된 이미지를 dataUrl로 변환 + const compositeDataUrl = compositeCanvas.toDataURL("image/png"); + console.log("✅ 최종 합성 완료"); + + // 기존 dataUrl을 합성된 것으로 교체 + return await handleDownloadWithDataUrl(compositeDataUrl, format, canvasWidth, canvasHeight); + } + } + + console.log("✅ 캡처 완료 (WebGL 없음)"); + + // WebGL이 없는 경우 기본 다운로드 + await handleDownloadWithDataUrl(dataUrl, format, canvasWidth, canvasHeight); + } catch (error) { + console.error("❌ 다운로드 실패:", error); + alert(`다운로드에 실패했습니다.\n\n에러: ${error instanceof Error ? error.message : String(error)}`); + } + }; + return ( -
+
{/* 좌측: 대시보드 제목 */}
{dashboardTitle && (
- {dashboardTitle} - 편집 중 + {dashboardTitle} + 편집 중
)}
@@ -89,7 +287,7 @@ export function DashboardTopMenu({ /> )} -
+
{/* 배경색 선택 */} {onBackgroundColorChange && ( @@ -97,7 +295,7 @@ export function DashboardTopMenu({ @@ -151,8 +349,8 @@ export function DashboardTopMenu({ )} -
- +
+ {/* 차트 선택 */} @@ -104,12 +104,12 @@ export function DateFilterPanel({ config, dateColumns, onChange }: DateFilterPan ))} -

감지된 날짜 컬럼: {dateColumns.join(", ")}

+

감지된 날짜 컬럼: {dateColumns.join(", ")}

{/* 빠른 선택 */}
- +
-
- - {/* 커스텀 제목 입력 */} -
- - setCustomTitle(e.target.value)} - onKeyDown={(e) => { - // 모든 키보드 이벤트를 input 필드 내부에서만 처리 - e.stopPropagation(); - }} - placeholder="예: 정비 일정 목록, 창고 위치 현황 등 (비워두면 자동 생성)" - className="focus:border-primary focus:ring-primary w-full rounded-md border border-gray-300 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-gray-300" - /> - -
-
- - {/* 진행 상황 표시 - 간단한 위젯과 헤더 전용 위젯은 표시 안 함 */} - {!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 b8d838f2..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-gray-200 bg-gray-50 px-2 text-xs placeholder:text-gray-400 focus:bg-white 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/MapTestConfigPanel.tsx b/frontend/components/admin/dashboard/MapTestConfigPanel.tsx index b5be7a35..38d60e5b 100644 --- a/frontend/components/admin/dashboard/MapTestConfigPanel.tsx +++ b/frontend/components/admin/dashboard/MapTestConfigPanel.tsx @@ -123,9 +123,9 @@ export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapT
{/* 타일맵 URL 설정 (외부 커넥션 또는 직접 입력) */}
-