Merge branch 'main' into lhj - 대시보드 기능 통합

This commit is contained in:
leeheejin
2025-10-15 17:19:53 +09:00
7 changed files with 296 additions and 49 deletions

View File

@@ -0,0 +1,23 @@
"use client";
import React from "react";
import { use } from "react";
import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
interface PageProps {
params: Promise<{ id: string }>;
}
/**
* 대시보드 편집 페이지
* - 기존 대시보드 편집
*/
export default function DashboardEditPage({ params }: PageProps) {
const { id } = use(params);
return (
<div className="h-full">
<DashboardDesigner dashboardId={id} />
</div>
);
}

View File

@@ -0,0 +1,15 @@
"use client";
import React from "react";
import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
/**
* 새 대시보드 생성 페이지
*/
export default function DashboardNewPage() {
return (
<div className="h-full">
<DashboardDesigner />
</div>
);
}

View File

@@ -1,18 +1,236 @@
'use client';
"use client";
import React from 'react';
import DashboardDesigner from '@/components/admin/dashboard/DashboardDesigner';
import React, { 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 { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Plus, Search, MoreVertical, Edit, Trash2, Copy, Eye } from "lucide-react";
/**
* 대시보드 관리 페이지
* - 드래그 앤 드롭으로 대시보드 레이아웃 설계
* - 차트 및 위젯 배치 관리
* - 독립적인 컴포넌트로 구성되어 다른 시스템에 영향 없음
* - 대시보드 목록 조회
* - 대시보드 생성/수정/삭제/복사
*/
export default function DashboardPage() {
export default function DashboardListPage() {
const router = useRouter();
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [error, setError] = useState<string | null>(null);
// 대시보드 목록 로드
const loadDashboards = async () => {
try {
setLoading(true);
setError(null);
const result = await dashboardApi.getMyDashboards({ search: searchTerm });
setDashboards(result.dashboards);
} catch (err) {
console.error("Failed to load dashboards:", err);
setError("대시보드 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadDashboards();
}, [searchTerm]);
// 대시보드 삭제
const handleDelete = async (id: string, title: string) => {
if (!confirm(`"${title}" 대시보드를 삭제하시겠습니까?`)) {
return;
}
try {
await dashboardApi.deleteDashboard(id);
alert("대시보드가 삭제되었습니다.");
loadDashboards();
} catch (err) {
console.error("Failed to delete dashboard:", err);
alert("대시보드 삭제에 실패했습니다.");
}
};
// 대시보드 복사
const handleCopy = async (dashboard: Dashboard) => {
try {
const newDashboard = await dashboardApi.createDashboard({
title: `${dashboard.title} (복사본)`,
description: dashboard.description,
elements: dashboard.elements || [],
isPublic: false,
tags: dashboard.tags,
category: dashboard.category,
});
alert("대시보드가 복사되었습니다.");
loadDashboards();
} catch (err) {
console.error("Failed to copy dashboard:", err);
alert("대시보드 복사에 실패했습니다.");
}
};
// 포맷팅 헬퍼
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="text-lg font-medium text-gray-900"> ...</div>
<div className="mt-2 text-sm text-gray-500"> </div>
</div>
</div>
);
}
return (
<div className="h-full">
<DashboardDesigner />
<div className="h-full overflow-auto bg-gray-50 p-6">
<div className="mx-auto max-w-7xl">
{/* 헤더 */}
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-sm text-gray-600"> </p>
</div>
{/* 액션 바 */}
<div className="mb-6 flex items-center justify-between">
<div className="relative w-64">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="대시보드 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
<Button onClick={() => router.push("/admin/dashboard/new")} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 에러 메시지 */}
{error && (
<Card className="mb-6 border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</Card>
)}
{/* 대시보드 목록 */}
{dashboards.length === 0 ? (
<Card className="p-12 text-center">
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-full bg-gray-100">
<Plus className="h-12 w-12 text-gray-400" />
</div>
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<p className="mb-6 text-sm text-gray-500"> </p>
<Button onClick={() => router.push("/admin/dashboard/new")} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dashboards.map((dashboard) => (
<TableRow key={dashboard.id} className="cursor-pointer hover:bg-gray-50">
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{dashboard.title}
{dashboard.isPublic && (
<Badge variant="outline" className="text-xs">
</Badge>
)}
</div>
</TableCell>
<TableCell className="max-w-md truncate text-sm text-gray-500">
{dashboard.description || "-"}
</TableCell>
<TableCell>
<Badge variant="secondary">{dashboard.elementsCount || 0}</Badge>
</TableCell>
<TableCell>
{dashboard.isPublic ? (
<Badge className="bg-green-100 text-green-800"></Badge>
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
<TableCell className="text-sm text-gray-500">{formatDate(dashboard.createdAt)}</TableCell>
<TableCell className="text-sm text-gray-500">{formatDate(dashboard.updatedAt)}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => router.push(`/dashboard/${dashboard.id}`)} className="gap-2">
<Eye className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
className="gap-2"
>
<Edit className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2">
<Copy className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(dashboard.id, dashboard.title)}
className="gap-2 text-red-600 focus:text-red-600"
>
<Trash2 className="h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</div>
</div>
);
}

View File

@@ -10,6 +10,10 @@ import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal";
import { DashboardElement, ElementType, ElementSubtype } from "./types";
import { GRID_CONFIG } from "./gridUtils";
interface DashboardDesignerProps {
dashboardId?: string;
}
/**
* 대시보드 설계 도구 메인 컴포넌트
* - 드래그 앤 드롭으로 차트/위젯 배치
@@ -17,27 +21,24 @@ import { GRID_CONFIG } from "./gridUtils";
* - 요소 이동, 크기 조절, 삭제 기능
* - 레이아웃 저장/불러오기 기능
*/
export default function DashboardDesigner() {
export default function DashboardDesigner({ dashboardId: initialDashboardId }: DashboardDesignerProps = {}) {
const router = useRouter();
const [elements, setElements] = useState<DashboardElement[]>([]);
const [selectedElement, setSelectedElement] = useState<string | null>(null);
const [elementCounter, setElementCounter] = useState(0);
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
const [dashboardId, setDashboardId] = useState<string | null>(null);
const [dashboardId, setDashboardId] = useState<string | null>(initialDashboardId || null);
const [dashboardTitle, setDashboardTitle] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState<string>("#f9fafb");
const canvasRef = useRef<HTMLDivElement>(null);
// URL 파라미터에서 대시보드 ID 읽기 및 데이터 로드
// 대시보드 ID가 props로 전달되면 로드
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
const loadId = params.get("load");
if (loadId) {
loadDashboard(loadId);
if (initialDashboardId) {
loadDashboard(initialDashboardId);
}
}, []);
}, [initialDashboardId]);
// 대시보드 데이터 로드
const loadDashboard = async (id: string) => {

View File

@@ -125,14 +125,7 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
};
} else {
// 현재 DB - 필터 적용
console.log("📊 ChartRenderer: 현재 DB 쿼리 실행");
console.log(" 원본 쿼리:", element.dataSource.query);
console.log(" chartConfig:", element.chartConfig);
const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig);
console.log(" 필터 적용된 쿼리:", filteredQuery);
const result = await dashboardApi.executeQuery(filteredQuery);
queryResult = {
columns: result.columns,
@@ -140,8 +133,6 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
totalRows: result.rowCount,
executionTime: 0,
};
console.log(" 쿼리 결과:", queryResult);
}
} else {
throw new Error("데이터 소스가 올바르게 설정되지 않았습니다");

View File

@@ -88,11 +88,6 @@ export function applyDateFilter(query: string, dateColumn: string, startDate?: s
// 쿼리 재조립
const finalQuery = `${baseQuery}${whereClause}${groupByClause}${orderByClause}${limitClause}`;
console.log("🔧 날짜 필터 적용:");
console.log(" 원본 쿼리:", query);
console.log(" 최종 쿼리:", finalQuery);
return finalQuery;
}
@@ -233,23 +228,16 @@ export function detectDateColumns(columns: string[], rows: Record<string, any>[]
* 쿼리에 필터와 안전장치를 모두 적용
*/
export function applyQueryFilters(query: string, config?: ChartConfig): string {
console.log("🔍 applyQueryFilters 호출:");
console.log(" config:", config);
console.log(" dateFilter:", config?.dateFilter);
let processedQuery = query;
// 1. 날짜 필터 적용
if (config?.dateFilter?.enabled && config.dateFilter.dateColumn) {
console.log("✅ 날짜 필터 적용 중...");
processedQuery = applyDateFilter(
processedQuery,
config.dateFilter.dateColumn,
config.dateFilter.startDate,
config.dateFilter.endDate,
);
} else {
console.log("⚠️ 날짜 필터 비활성화 또는 설정 없음");
}
// 2. 안전장치 LIMIT 적용

View File

@@ -187,8 +187,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
);
}
// 데이터 또는 설정 없음
if (!data || config.columns.length === 0) {
// 데이터 없음
if (!data) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4">
<div className="text-center">
@@ -200,6 +200,17 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
);
}
// 컬럼 설정이 없으면 자동으로 모든 컬럼 표시
const displayColumns =
config.columns.length > 0
? config.columns
: data.columns.map((col) => ({
id: col,
name: col,
dataKey: col,
visible: true,
}));
// 페이지네이션
const totalPages = Math.ceil(data.rows.length / config.pageSize);
const startIdx = (currentPage - 1) * config.pageSize;
@@ -219,7 +230,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
{config.showHeader && (
<TableHeader>
<TableRow>
{config.columns
{displayColumns
.filter((col) => col.visible)
.map((col) => (
<TableHead
@@ -227,7 +238,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
style={{ width: col.width ? `${col.width}px` : undefined }}
>
{col.label}
{col.label || col.name}
</TableHead>
))}
</TableRow>
@@ -237,7 +248,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
{paginatedRows.length === 0 ? (
<TableRow>
<TableCell
colSpan={config.columns.filter((col) => col.visible).length}
colSpan={displayColumns.filter((col) => col.visible).length}
className="text-center text-gray-500"
>
@@ -246,14 +257,14 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
) : (
paginatedRows.map((row, idx) => (
<TableRow key={idx} className={config.stripedRows ? undefined : ""}>
{config.columns
{displayColumns
.filter((col) => col.visible)
.map((col) => (
<TableCell
key={col.id}
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
>
{String(row[col.field] ?? "")}
{String(row[col.dataKey || col.field] ?? "")}
</TableCell>
))}
</TableRow>
@@ -279,15 +290,15 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
{paginatedRows.map((row, idx) => (
<Card key={idx} className="p-4 transition-shadow hover:shadow-md">
<div className="space-y-2">
{config.columns
{displayColumns
.filter((col) => col.visible)
.map((col) => (
<div key={col.id}>
<div className="text-xs font-medium text-gray-500">{col.label}</div>
<div className="text-xs font-medium text-gray-500">{col.label || col.name}</div>
<div
className={`font-medium text-gray-900 ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
>
{String(row[col.field] ?? "")}
{String(row[col.dataKey || col.field] ?? "")}
</div>
</div>
))}