Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/report
This commit is contained in:
18
frontend/app/(main)/admin/dashboard/page.tsx
Normal file
18
frontend/app/(main)/admin/dashboard/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import DashboardDesigner from '@/components/admin/dashboard/DashboardDesigner';
|
||||
|
||||
/**
|
||||
* 대시보드 관리 페이지
|
||||
* - 드래그 앤 드롭으로 대시보드 레이아웃 설계
|
||||
* - 차트 및 위젯 배치 관리
|
||||
* - 독립적인 컴포넌트로 구성되어 다른 시스템에 영향 없음
|
||||
*/
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="h-full">
|
||||
<DashboardDesigner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
286
frontend/app/(main)/dashboard/[dashboardId]/page.tsx
Normal file
286
frontend/app/(main)/dashboard/[dashboardId]/page.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { DashboardViewer } from '@/components/dashboard/DashboardViewer';
|
||||
import { DashboardElement } from '@/components/admin/dashboard/types';
|
||||
|
||||
interface DashboardViewPageProps {
|
||||
params: {
|
||||
dashboardId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 뷰어 페이지
|
||||
* - 저장된 대시보드를 읽기 전용으로 표시
|
||||
* - 실시간 데이터 업데이트
|
||||
* - 전체화면 모드 지원
|
||||
*/
|
||||
export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||
const [dashboard, setDashboard] = useState<{
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
elements: DashboardElement[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
} | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 대시보드 데이터 로딩
|
||||
useEffect(() => {
|
||||
loadDashboard();
|
||||
}, [params.dashboardId]);
|
||||
|
||||
const loadDashboard = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 실제 API 호출 시도
|
||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
||||
|
||||
try {
|
||||
const dashboardData = await dashboardApi.getDashboard(params.dashboardId);
|
||||
setDashboard(dashboardData);
|
||||
} catch (apiError) {
|
||||
console.warn('API 호출 실패, 로컬 스토리지 확인:', apiError);
|
||||
|
||||
// API 실패 시 로컬 스토리지에서 찾기
|
||||
const savedDashboards = JSON.parse(localStorage.getItem('savedDashboards') || '[]');
|
||||
const savedDashboard = savedDashboards.find((d: any) => d.id === params.dashboardId);
|
||||
|
||||
if (savedDashboard) {
|
||||
setDashboard(savedDashboard);
|
||||
} else {
|
||||
// 로컬에도 없으면 샘플 데이터 사용
|
||||
const sampleDashboard = generateSampleDashboard(params.dashboardId);
|
||||
setDashboard(sampleDashboard);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError('대시보드를 불러오는 중 오류가 발생했습니다.');
|
||||
console.error('Dashboard loading error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<div className="text-lg font-medium text-gray-700">대시보드 로딩 중...</div>
|
||||
<div className="text-sm text-gray-500 mt-1">잠시만 기다려주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error || !dashboard) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">😞</div>
|
||||
<div className="text-xl font-medium text-gray-700 mb-2">
|
||||
{error || '대시보드를 찾을 수 없습니다'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mb-4">
|
||||
대시보드 ID: {params.dashboardId}
|
||||
</div>
|
||||
<button
|
||||
onClick={loadDashboard}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-gray-50">
|
||||
{/* 대시보드 헤더 */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">{dashboard.title}</h1>
|
||||
{dashboard.description && (
|
||||
<p className="text-sm text-gray-600 mt-1">{dashboard.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 새로고침 버튼 */}
|
||||
<button
|
||||
onClick={loadDashboard}
|
||||
className="px-3 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
title="새로고침"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
|
||||
{/* 전체화면 버튼 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
document.documentElement.requestFullscreen();
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
title="전체화면"
|
||||
>
|
||||
⛶
|
||||
</button>
|
||||
|
||||
{/* 편집 버튼 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.open(`/admin/dashboard?load=${params.dashboardId}`, '_blank');
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
|
||||
>
|
||||
편집
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메타 정보 */}
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
||||
<span>생성: {new Date(dashboard.createdAt).toLocaleString()}</span>
|
||||
<span>수정: {new Date(dashboard.updatedAt).toLocaleString()}</span>
|
||||
<span>요소: {dashboard.elements.length}개</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 대시보드 뷰어 */}
|
||||
<div className="h-[calc(100vh-120px)]">
|
||||
<DashboardViewer
|
||||
elements={dashboard.elements}
|
||||
dashboardId={dashboard.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 샘플 대시보드 생성 함수
|
||||
*/
|
||||
function generateSampleDashboard(dashboardId: string) {
|
||||
const dashboards: Record<string, any> = {
|
||||
'sales-overview': {
|
||||
id: 'sales-overview',
|
||||
title: '📊 매출 현황 대시보드',
|
||||
description: '월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.',
|
||||
elements: [
|
||||
{
|
||||
id: 'chart-1',
|
||||
type: 'chart',
|
||||
subtype: 'bar',
|
||||
position: { x: 20, y: 20 },
|
||||
size: { width: 400, height: 300 },
|
||||
title: '📊 월별 매출 추이',
|
||||
content: '월별 매출 데이터',
|
||||
dataSource: {
|
||||
type: 'database',
|
||||
query: 'SELECT month, sales FROM monthly_sales',
|
||||
refreshInterval: 30000
|
||||
},
|
||||
chartConfig: {
|
||||
xAxis: 'month',
|
||||
yAxis: 'sales',
|
||||
title: '월별 매출 추이',
|
||||
colors: ['#3B82F6', '#EF4444', '#10B981']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'chart-2',
|
||||
type: 'chart',
|
||||
subtype: 'pie',
|
||||
position: { x: 450, y: 20 },
|
||||
size: { width: 350, height: 300 },
|
||||
title: '🥧 상품별 판매 비율',
|
||||
content: '상품별 판매 데이터',
|
||||
dataSource: {
|
||||
type: 'database',
|
||||
query: 'SELECT product_name, total_sold FROM product_sales',
|
||||
refreshInterval: 60000
|
||||
},
|
||||
chartConfig: {
|
||||
xAxis: 'product_name',
|
||||
yAxis: 'total_sold',
|
||||
title: '상품별 판매 비율',
|
||||
colors: ['#8B5CF6', '#EC4899', '#06B6D4', '#84CC16']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'chart-3',
|
||||
type: 'chart',
|
||||
subtype: 'line',
|
||||
position: { x: 20, y: 350 },
|
||||
size: { width: 780, height: 250 },
|
||||
title: '📈 사용자 가입 추이',
|
||||
content: '사용자 가입 데이터',
|
||||
dataSource: {
|
||||
type: 'database',
|
||||
query: 'SELECT week, new_users FROM user_growth',
|
||||
refreshInterval: 300000
|
||||
},
|
||||
chartConfig: {
|
||||
xAxis: 'week',
|
||||
yAxis: 'new_users',
|
||||
title: '주간 신규 사용자 가입 추이',
|
||||
colors: ['#10B981']
|
||||
}
|
||||
}
|
||||
],
|
||||
createdAt: '2024-09-30T10:00:00Z',
|
||||
updatedAt: '2024-09-30T14:30:00Z'
|
||||
},
|
||||
'user-analytics': {
|
||||
id: 'user-analytics',
|
||||
title: '👥 사용자 분석 대시보드',
|
||||
description: '사용자 행동 패턴 및 가입 추이 분석',
|
||||
elements: [
|
||||
{
|
||||
id: 'chart-4',
|
||||
type: 'chart',
|
||||
subtype: 'line',
|
||||
position: { x: 20, y: 20 },
|
||||
size: { width: 500, height: 300 },
|
||||
title: '📈 일일 활성 사용자',
|
||||
content: '사용자 활동 데이터',
|
||||
dataSource: {
|
||||
type: 'database',
|
||||
query: 'SELECT date, active_users FROM daily_active_users',
|
||||
refreshInterval: 60000
|
||||
},
|
||||
chartConfig: {
|
||||
xAxis: 'date',
|
||||
yAxis: 'active_users',
|
||||
title: '일일 활성 사용자 추이'
|
||||
}
|
||||
}
|
||||
],
|
||||
createdAt: '2024-09-29T15:00:00Z',
|
||||
updatedAt: '2024-09-30T09:15:00Z'
|
||||
}
|
||||
};
|
||||
|
||||
return dashboards[dashboardId] || {
|
||||
id: dashboardId,
|
||||
title: `대시보드 ${dashboardId}`,
|
||||
description: '샘플 대시보드입니다.',
|
||||
elements: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -1,270 +1,287 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Home,
|
||||
FileText,
|
||||
Users,
|
||||
Settings,
|
||||
Package,
|
||||
BarChart3,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface UserInfo {
|
||||
userId: string;
|
||||
userName: string;
|
||||
deptName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
interface Dashboard {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: any;
|
||||
children?: MenuItem[];
|
||||
description?: string;
|
||||
thumbnail?: string;
|
||||
elementsCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: "dashboard",
|
||||
title: "대시보드",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
id: "project",
|
||||
title: "프로젝트 관리",
|
||||
icon: FileText,
|
||||
children: [
|
||||
{ id: "project-list", title: "프로젝트 목록", icon: FileText },
|
||||
{ id: "project-concept", title: "프로젝트 컨셉", icon: FileText },
|
||||
{ id: "project-planning", title: "프로젝트 기획", icon: FileText },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "part",
|
||||
title: "부품 관리",
|
||||
icon: Package,
|
||||
children: [
|
||||
{ id: "part-list", title: "부품 목록", icon: Package },
|
||||
{ id: "part-bom", title: "BOM 관리", icon: Package },
|
||||
{ id: "part-inventory", title: "재고 관리", icon: Package },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "user",
|
||||
title: "사용자 관리",
|
||||
icon: Users,
|
||||
children: [
|
||||
{ id: "user-list", title: "사용자 목록", icon: Users },
|
||||
{ id: "user-auth", title: "권한 관리", icon: Users },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "report",
|
||||
title: "보고서",
|
||||
icon: BarChart3,
|
||||
children: [
|
||||
{ id: "report-project", title: "프로젝트 보고서", icon: BarChart3 },
|
||||
{ id: "report-cost", title: "비용 보고서", icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "시스템 설정",
|
||||
icon: Settings,
|
||||
children: [
|
||||
{ id: "settings-system", title: "시스템 설정", icon: Settings },
|
||||
{ id: "settings-common", title: "공통 코드", icon: Settings },
|
||||
],
|
||||
},
|
||||
];
|
||||
/**
|
||||
* 대시보드 목록 페이지
|
||||
* - 저장된 대시보드들의 목록 표시
|
||||
* - 새 대시보드 생성 링크
|
||||
* - 대시보드 미리보기 및 관리
|
||||
*/
|
||||
export default function DashboardListPage() {
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user, logout } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set(["dashboard"]));
|
||||
const [selectedMenu, setSelectedMenu] = useState("dashboard");
|
||||
const [currentContent, setCurrentContent] = useState<string>("dashboard");
|
||||
// 대시보드 목록 로딩
|
||||
useEffect(() => {
|
||||
loadDashboards();
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
const toggleMenu = (menuId: string) => {
|
||||
const newExpanded = new Set(expandedMenus);
|
||||
if (newExpanded.has(menuId)) {
|
||||
newExpanded.delete(menuId);
|
||||
} else {
|
||||
newExpanded.add(menuId);
|
||||
}
|
||||
setExpandedMenus(newExpanded);
|
||||
};
|
||||
|
||||
const handleMenuClick = (menuId: string) => {
|
||||
setSelectedMenu(menuId);
|
||||
setCurrentContent(menuId);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (currentContent) {
|
||||
case "dashboard":
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold text-slate-900">대시보드</h1>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center">
|
||||
<FileText className="h-8 w-8 text-blue-600" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-slate-600">전체 프로젝트</p>
|
||||
<p className="text-2xl font-bold text-slate-900">24</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center">
|
||||
<Package className="h-8 w-8 text-green-600" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-slate-600">등록된 부품</p>
|
||||
<p className="text-2xl font-bold text-slate-900">1,247</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center">
|
||||
<Users className="h-8 w-8 text-purple-600" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-slate-600">활성 사용자</p>
|
||||
<p className="text-2xl font-bold text-slate-900">89</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center">
|
||||
<BarChart3 className="h-8 w-8 text-orange-600" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-slate-600">진행중인 작업</p>
|
||||
<p className="text-2xl font-bold text-slate-900">12</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h2 className="mb-4 text-xl font-semibold">최근 활동</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500"></div>
|
||||
<span className="text-sm text-slate-600">새로운 프로젝트 '제품 A' 생성됨</span>
|
||||
<span className="text-xs text-slate-400">2시간 전</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
<span className="text-sm text-slate-600">부품 'PCB-001' 승인 완료</span>
|
||||
<span className="text-xs text-slate-400">4시간 전</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-2 w-2 rounded-full bg-orange-500"></div>
|
||||
<span className="text-sm text-slate-600">사용자 '김개발' 권한 변경</span>
|
||||
<span className="text-xs text-slate-400">1일 전</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold text-slate-900">
|
||||
{menuItems.find((item) => item.id === currentContent)?.title ||
|
||||
menuItems.flatMap((item) => item.children || []).find((child) => child.id === currentContent)?.title ||
|
||||
"페이지를 찾을 수 없습니다"}
|
||||
</h1>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-slate-600">{currentContent} 페이지 컨텐츠가 여기에 표시됩니다.</p>
|
||||
<p className="mt-2 text-sm text-slate-400">각 메뉴에 맞는 컴포넌트를 개발하여 연결할 수 있습니다.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
const loadDashboards = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 실제 API 호출 시도
|
||||
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,
|
||||
title: dashboard.title,
|
||||
description: dashboard.description,
|
||||
elementsCount: dashboard.elementsCount || dashboard.elements?.length || 0,
|
||||
createdAt: dashboard.createdAt,
|
||||
updatedAt: dashboard.updatedAt,
|
||||
isPublic: dashboard.isPublic,
|
||||
creatorName: dashboard.creatorName
|
||||
}));
|
||||
|
||||
setDashboards(apiDashboards);
|
||||
|
||||
} catch (apiError) {
|
||||
console.warn('API 호출 실패, 로컬 스토리지 및 샘플 데이터 사용:', apiError);
|
||||
|
||||
// API 실패 시 로컬 스토리지 + 샘플 데이터 사용
|
||||
const savedDashboards = JSON.parse(localStorage.getItem('savedDashboards') || '[]');
|
||||
|
||||
// 샘플 대시보드들
|
||||
const sampleDashboards: Dashboard[] = [
|
||||
{
|
||||
id: 'sales-overview',
|
||||
title: '📊 매출 현황 대시보드',
|
||||
description: '월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.',
|
||||
elementsCount: 3,
|
||||
createdAt: '2024-09-30T10:00:00Z',
|
||||
updatedAt: '2024-09-30T14:30:00Z',
|
||||
isPublic: true
|
||||
},
|
||||
{
|
||||
id: 'user-analytics',
|
||||
title: '👥 사용자 분석 대시보드',
|
||||
description: '사용자 행동 패턴 및 가입 추이 분석',
|
||||
elementsCount: 1,
|
||||
createdAt: '2024-09-29T15:00:00Z',
|
||||
updatedAt: '2024-09-30T09:15:00Z',
|
||||
isPublic: false
|
||||
},
|
||||
{
|
||||
id: 'inventory-status',
|
||||
title: '📦 재고 현황 대시보드',
|
||||
description: '실시간 재고 현황 및 입출고 내역',
|
||||
elementsCount: 4,
|
||||
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,
|
||||
title: dashboard.title,
|
||||
description: dashboard.description,
|
||||
elementsCount: dashboard.elements?.length || 0,
|
||||
createdAt: dashboard.createdAt,
|
||||
updatedAt: dashboard.updatedAt,
|
||||
isPublic: false // 사용자가 만든 대시보드는 기본적으로 비공개
|
||||
}));
|
||||
|
||||
// 사용자 대시보드를 맨 앞에 배치
|
||||
setDashboards([...userDashboards, ...sampleDashboards]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Dashboard loading error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMenuItem = (item: MenuItem, level: number = 0) => {
|
||||
const isExpanded = expandedMenus.has(item.id);
|
||||
const isSelected = selectedMenu === item.id;
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<div
|
||||
className={`flex cursor-pointer items-center rounded-md px-4 py-2 text-sm transition-colors ${
|
||||
isSelected ? "bg-blue-600 text-white" : "text-slate-700 hover:bg-slate-100"
|
||||
} ${level > 0 ? "ml-6" : ""}`}
|
||||
onClick={() => {
|
||||
if (hasChildren) {
|
||||
toggleMenu(item.id);
|
||||
} else {
|
||||
handleMenuClick(item.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<item.icon className="mr-3 h-4 w-4" />
|
||||
<span className="flex-1">{item.title}</span>
|
||||
{hasChildren && (isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />)}
|
||||
</div>
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="mt-1">{item.children?.map((child) => renderMenuItem(child, level + 1))}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// 검색 필터링
|
||||
const filteredDashboards = dashboards.filter(dashboard =>
|
||||
dashboard.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
dashboard.description?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 헤더 */}
|
||||
<header className="border-b border-slate-200 bg-white shadow-sm">
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen(!sidebarOpen)}>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-slate-900">PLM 시스템</h1>
|
||||
<div className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">📊 대시보드</h1>
|
||||
<p className="text-gray-600 mt-1">데이터를 시각화하고 인사이트를 얻어보세요</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/admin/dashboard"
|
||||
className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 font-medium"
|
||||
>
|
||||
➕ 새 대시보드 만들기
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 검색 바 */}
|
||||
<div className="mt-6">
|
||||
<div className="relative max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
<div className="absolute left-3 top-2.5 text-gray-400">
|
||||
🔍
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
{/* 사이드바 */}
|
||||
<aside
|
||||
className={`${sidebarOpen ? "w-64" : "w-0"} overflow-hidden border-r border-slate-200 bg-white transition-all duration-300`}
|
||||
>
|
||||
<nav className="space-y-2 p-4">{menuItems.map((item) => renderMenuItem(item))}</nav>
|
||||
</aside>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<main className="flex-1 p-6">{renderContent()}</main>
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
{isLoading ? (
|
||||
// 로딩 상태
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-3"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-full mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-2/3 mb-4"></div>
|
||||
<div className="h-32 bg-gray-200 rounded mb-4"></div>
|
||||
<div className="flex justify-between">
|
||||
<div className="h-3 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredDashboards.length === 0 ? (
|
||||
// 빈 상태
|
||||
<div className="text-center py-12">
|
||||
<div className="text-6xl mb-4">📊</div>
|
||||
<h3 className="text-xl font-medium text-gray-700 mb-2">
|
||||
{searchTerm ? '검색 결과가 없습니다' : '아직 대시보드가 없습니다'}
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
{searchTerm
|
||||
? '다른 검색어로 시도해보세요'
|
||||
: '첫 번째 대시보드를 만들어보세요'}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<Link
|
||||
href="/admin/dashboard"
|
||||
className="inline-flex items-center px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 font-medium"
|
||||
>
|
||||
➕ 대시보드 만들기
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 대시보드 그리드
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredDashboards.map((dashboard) => (
|
||||
<DashboardCard key={dashboard.id} dashboard={dashboard} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DashboardCardProps {
|
||||
dashboard: Dashboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 대시보드 카드 컴포넌트
|
||||
*/
|
||||
function DashboardCard({ dashboard }: DashboardCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow">
|
||||
{/* 썸네일 영역 */}
|
||||
<div className="h-48 bg-gradient-to-br from-blue-50 to-indigo-100 rounded-t-lg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">📊</div>
|
||||
<div className="text-sm text-gray-600">{dashboard.elementsCount}개 요소</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카드 내용 */}
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1">
|
||||
{dashboard.title}
|
||||
</h3>
|
||||
{dashboard.isPublic ? (
|
||||
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full">
|
||||
공개
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs bg-gray-100 text-gray-800 px-2 py-1 rounded-full">
|
||||
비공개
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dashboard.description && (
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
|
||||
{dashboard.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 메타 정보 */}
|
||||
<div className="text-xs text-gray-500 mb-4">
|
||||
<div>생성: {new Date(dashboard.createdAt).toLocaleDateString()}</div>
|
||||
<div>수정: {new Date(dashboard.updatedAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href={`/dashboard/${dashboard.id}`}
|
||||
className="flex-1 px-4 py-2 bg-blue-500 text-white text-center rounded-lg hover:bg-blue-600 text-sm font-medium"
|
||||
>
|
||||
보기
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/dashboard?load=${dashboard.id}`}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm"
|
||||
>
|
||||
편집
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
// 복사 기능 구현
|
||||
console.log('Dashboard copy:', dashboard.id);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm"
|
||||
title="복사"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user