519 lines
18 KiB
TypeScript
519 lines
18 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||
import dynamic from "next/dynamic";
|
||
import { DashboardElement, QueryResult } from "./types";
|
||
import { ChartRenderer } from "./charts/ChartRenderer";
|
||
import { snapToGrid, snapSizeToGrid, GRID_CONFIG } from "./gridUtils";
|
||
|
||
// 위젯 동적 임포트
|
||
const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), {
|
||
ssr: false,
|
||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||
});
|
||
|
||
const ExchangeWidget = dynamic(() => import("@/components/dashboard/widgets/ExchangeWidget"), {
|
||
ssr: false,
|
||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||
});
|
||
|
||
const CalculatorWidget = dynamic(() => import("@/components/dashboard/widgets/CalculatorWidget"), {
|
||
ssr: false,
|
||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||
});
|
||
|
||
const VehicleMapWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleMapWidget"), {
|
||
ssr: false,
|
||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||
});
|
||
|
||
// 시계 위젯 임포트
|
||
import { ClockWidget } from "./widgets/ClockWidget";
|
||
|
||
interface CanvasElementProps {
|
||
element: DashboardElement;
|
||
isSelected: boolean;
|
||
cellSize: number;
|
||
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
|
||
onRemove: (id: string) => void;
|
||
onSelect: (id: string | null) => void;
|
||
onConfigure?: (element: DashboardElement) => void;
|
||
}
|
||
|
||
/**
|
||
* 캔버스에 배치된 개별 요소 컴포넌트
|
||
* - 드래그로 이동 가능 (그리드 스냅)
|
||
* - 크기 조절 핸들 (그리드 스냅)
|
||
* - 삭제 버튼
|
||
*/
|
||
export function CanvasElement({
|
||
element,
|
||
isSelected,
|
||
cellSize,
|
||
onUpdate,
|
||
onRemove,
|
||
onSelect,
|
||
onConfigure,
|
||
}: CanvasElementProps) {
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [isResizing, setIsResizing] = useState(false);
|
||
const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 });
|
||
const [resizeStart, setResizeStart] = useState({
|
||
x: 0,
|
||
y: 0,
|
||
width: 0,
|
||
height: 0,
|
||
elementX: 0,
|
||
elementY: 0,
|
||
handle: "",
|
||
});
|
||
const [chartData, setChartData] = useState<QueryResult | null>(null);
|
||
const [isLoadingData, setIsLoadingData] = useState(false);
|
||
const elementRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 드래그/리사이즈 중 임시 위치/크기 (스냅 전)
|
||
const [tempPosition, setTempPosition] = useState<{ x: number; y: number } | null>(null);
|
||
const [tempSize, setTempSize] = useState<{ width: number; height: number } | null>(null);
|
||
|
||
// 요소 선택 처리
|
||
const handleMouseDown = useCallback(
|
||
(e: React.MouseEvent) => {
|
||
// 닫기 버튼이나 리사이즈 핸들 클릭 시 무시
|
||
if ((e.target as HTMLElement).closest(".element-close, .resize-handle")) {
|
||
return;
|
||
}
|
||
|
||
// 위젯 내부 (헤더 제외) 클릭 시 드래그 무시 - 인터랙티브 사용 가능
|
||
if ((e.target as HTMLElement).closest(".widget-interactive-area")) {
|
||
return;
|
||
}
|
||
|
||
onSelect(element.id);
|
||
setIsDragging(true);
|
||
setDragStart({
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
elementX: element.position.x,
|
||
elementY: element.position.y,
|
||
});
|
||
e.preventDefault();
|
||
},
|
||
[element.id, element.position.x, element.position.y, onSelect],
|
||
);
|
||
|
||
// 리사이즈 핸들 마우스다운
|
||
const handleResizeMouseDown = useCallback(
|
||
(e: React.MouseEvent, handle: string) => {
|
||
e.stopPropagation();
|
||
setIsResizing(true);
|
||
setResizeStart({
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
width: element.size.width,
|
||
height: element.size.height,
|
||
elementX: element.position.x,
|
||
elementY: element.position.y,
|
||
handle,
|
||
});
|
||
},
|
||
[element.size.width, element.size.height, element.position.x, element.position.y],
|
||
);
|
||
|
||
// 마우스 이동 처리 (그리드 스냅 적용)
|
||
const handleMouseMove = useCallback(
|
||
(e: MouseEvent) => {
|
||
if (isDragging) {
|
||
const deltaX = e.clientX - dragStart.x;
|
||
const deltaY = e.clientY - dragStart.y;
|
||
|
||
// 임시 위치 계산 (스냅 안 됨)
|
||
const rawX = Math.max(0, dragStart.elementX + deltaX);
|
||
const rawY = Math.max(0, dragStart.elementY + deltaY);
|
||
|
||
setTempPosition({ x: rawX, y: rawY });
|
||
} else if (isResizing) {
|
||
const deltaX = e.clientX - resizeStart.x;
|
||
const deltaY = e.clientY - resizeStart.y;
|
||
|
||
let newWidth = resizeStart.width;
|
||
let newHeight = resizeStart.height;
|
||
let newX = resizeStart.elementX;
|
||
let newY = resizeStart.elementY;
|
||
|
||
const minSize = GRID_CONFIG.CELL_SIZE * 2; // 최소 2셀
|
||
|
||
switch (resizeStart.handle) {
|
||
case "se": // 오른쪽 아래
|
||
newWidth = Math.max(minSize, resizeStart.width + deltaX);
|
||
newHeight = Math.max(minSize, resizeStart.height + deltaY);
|
||
break;
|
||
case "sw": // 왼쪽 아래
|
||
newWidth = Math.max(minSize, resizeStart.width - deltaX);
|
||
newHeight = Math.max(minSize, resizeStart.height + deltaY);
|
||
newX = resizeStart.elementX + deltaX;
|
||
break;
|
||
case "ne": // 오른쪽 위
|
||
newWidth = Math.max(minSize, resizeStart.width + deltaX);
|
||
newHeight = Math.max(minSize, resizeStart.height - deltaY);
|
||
newY = resizeStart.elementY + deltaY;
|
||
break;
|
||
case "nw": // 왼쪽 위
|
||
newWidth = Math.max(minSize, resizeStart.width - deltaX);
|
||
newHeight = Math.max(minSize, resizeStart.height - deltaY);
|
||
newX = resizeStart.elementX + deltaX;
|
||
newY = resizeStart.elementY + deltaY;
|
||
break;
|
||
}
|
||
|
||
// 임시 크기/위치 저장 (스냅 안 됨)
|
||
setTempPosition({ x: Math.max(0, newX), y: Math.max(0, newY) });
|
||
setTempSize({ width: newWidth, height: newHeight });
|
||
}
|
||
},
|
||
[isDragging, isResizing, dragStart, resizeStart],
|
||
);
|
||
|
||
// 마우스 업 처리 (그리드 스냅 적용)
|
||
const handleMouseUp = useCallback(() => {
|
||
if (isDragging && tempPosition) {
|
||
// 드래그 종료 시 그리드에 스냅 (동적 셀 크기 사용)
|
||
const snappedX = snapToGrid(tempPosition.x, cellSize);
|
||
const snappedY = snapToGrid(tempPosition.y, cellSize);
|
||
|
||
onUpdate(element.id, {
|
||
position: { x: snappedX, y: snappedY },
|
||
});
|
||
|
||
setTempPosition(null);
|
||
}
|
||
|
||
if (isResizing && tempPosition && tempSize) {
|
||
// 리사이즈 종료 시 그리드에 스냅 (동적 셀 크기 사용)
|
||
const snappedX = snapToGrid(tempPosition.x, cellSize);
|
||
const snappedY = snapToGrid(tempPosition.y, cellSize);
|
||
const snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize);
|
||
const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize);
|
||
|
||
onUpdate(element.id, {
|
||
position: { x: snappedX, y: snappedY },
|
||
size: { width: snappedWidth, height: snappedHeight },
|
||
});
|
||
|
||
setTempPosition(null);
|
||
setTempSize(null);
|
||
}
|
||
|
||
setIsDragging(false);
|
||
setIsResizing(false);
|
||
}, [isDragging, isResizing, tempPosition, tempSize, element.id, onUpdate, cellSize]);
|
||
|
||
// 전역 마우스 이벤트 등록
|
||
React.useEffect(() => {
|
||
if (isDragging || isResizing) {
|
||
document.addEventListener("mousemove", handleMouseMove);
|
||
document.addEventListener("mouseup", handleMouseUp);
|
||
|
||
return () => {
|
||
document.removeEventListener("mousemove", handleMouseMove);
|
||
document.removeEventListener("mouseup", handleMouseUp);
|
||
};
|
||
}
|
||
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
|
||
|
||
// 데이터 로딩
|
||
const loadChartData = useCallback(async () => {
|
||
if (!element.dataSource?.query || element.type !== "chart") {
|
||
return;
|
||
}
|
||
|
||
setIsLoadingData(true);
|
||
try {
|
||
// console.log('🔄 쿼리 실행 시작:', element.dataSource.query);
|
||
|
||
// 실제 API 호출
|
||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
||
|
||
// console.log('✅ 쿼리 실행 결과:', result);
|
||
|
||
setChartData({
|
||
columns: result.columns || [],
|
||
rows: result.rows || [],
|
||
totalRows: result.rowCount || 0,
|
||
executionTime: 0,
|
||
});
|
||
} catch (error) {
|
||
// console.error('❌ 데이터 로딩 오류:', error);
|
||
setChartData(null);
|
||
} finally {
|
||
setIsLoadingData(false);
|
||
}
|
||
}, [element.dataSource?.query, element.type, element.subtype]);
|
||
|
||
// 컴포넌트 마운트 시 및 쿼리 변경 시 데이터 로딩
|
||
useEffect(() => {
|
||
loadChartData();
|
||
}, [loadChartData]);
|
||
|
||
// 자동 새로고침 설정
|
||
useEffect(() => {
|
||
if (!element.dataSource?.refreshInterval || element.dataSource.refreshInterval === 0) {
|
||
return;
|
||
}
|
||
|
||
const interval = setInterval(loadChartData, element.dataSource.refreshInterval);
|
||
return () => clearInterval(interval);
|
||
}, [element.dataSource?.refreshInterval, loadChartData]);
|
||
|
||
// 요소 삭제
|
||
const handleRemove = useCallback(() => {
|
||
onRemove(element.id);
|
||
}, [element.id, onRemove]);
|
||
|
||
// 스타일 클래스 생성
|
||
const getContentClass = () => {
|
||
if (element.type === "chart") {
|
||
switch (element.subtype) {
|
||
case "bar":
|
||
return "bg-gradient-to-br from-indigo-400 to-purple-600";
|
||
case "pie":
|
||
return "bg-gradient-to-br from-pink-400 to-red-500";
|
||
case "line":
|
||
return "bg-gradient-to-br from-blue-400 to-cyan-400";
|
||
default:
|
||
return "bg-gray-200";
|
||
}
|
||
} else if (element.type === "widget") {
|
||
switch (element.subtype) {
|
||
case "exchange":
|
||
return "bg-gradient-to-br from-pink-400 to-yellow-400";
|
||
case "weather":
|
||
return "bg-gradient-to-br from-cyan-400 to-indigo-800";
|
||
case "clock":
|
||
return "bg-gradient-to-br from-teal-400 to-cyan-600";
|
||
default:
|
||
return "bg-gray-200";
|
||
}
|
||
}
|
||
return "bg-gray-200";
|
||
};
|
||
|
||
// 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용
|
||
const displayPosition = tempPosition || element.position;
|
||
const displaySize = tempSize || element.size;
|
||
|
||
return (
|
||
<div
|
||
ref={elementRef}
|
||
className={`absolute min-h-[120px] min-w-[120px] cursor-move rounded-lg border-2 bg-white shadow-lg ${isSelected ? "border-blue-500 shadow-blue-200" : "border-gray-400"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
|
||
style={{
|
||
left: displayPosition.x,
|
||
top: displayPosition.y,
|
||
width: displaySize.width,
|
||
height: displaySize.height,
|
||
padding: `${GRID_CONFIG.ELEMENT_PADDING}px`,
|
||
boxSizing: "border-box",
|
||
}}
|
||
onMouseDown={handleMouseDown}
|
||
>
|
||
{/* 헤더 */}
|
||
<div className="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3">
|
||
<span className="text-sm font-bold text-gray-800">{element.title}</span>
|
||
<div className="flex gap-1">
|
||
{/* 설정 버튼 (시계 위젯은 자체 설정 UI 사용) */}
|
||
{onConfigure && !(element.type === "widget" && element.subtype === "clock") && (
|
||
<button
|
||
className="hover:bg-accent0 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
|
||
onClick={() => onConfigure(element)}
|
||
title="설정"
|
||
>
|
||
⚙️
|
||
</button>
|
||
)}
|
||
{/* 삭제 버튼 */}
|
||
<button
|
||
className="element-close hover:bg-destructive/100 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
|
||
onClick={handleRemove}
|
||
title="삭제"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 내용 */}
|
||
<div className="relative h-[calc(100%-45px)]">
|
||
{element.type === "chart" ? (
|
||
// 차트 렌더링
|
||
<div className="h-full w-full bg-white">
|
||
{isLoadingData ? (
|
||
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
||
<div className="text-center">
|
||
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||
<div className="text-sm">데이터 로딩 중...</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<ChartRenderer
|
||
element={element}
|
||
data={chartData}
|
||
width={element.size.width}
|
||
height={element.size.height - 45}
|
||
/>
|
||
)}
|
||
</div>
|
||
) : element.type === "widget" && element.subtype === "weather" ? (
|
||
// 날씨 위젯 렌더링
|
||
<div className="widget-interactive-area h-full w-full">
|
||
<WeatherWidget city={element.config?.city || "서울"} refreshInterval={600000} />
|
||
</div>
|
||
) : element.type === "widget" && element.subtype === "exchange" ? (
|
||
// 환율 위젯 렌더링
|
||
<div className="widget-interactive-area h-full w-full">
|
||
<ExchangeWidget
|
||
baseCurrency={element.config?.baseCurrency || "KRW"}
|
||
targetCurrency={element.config?.targetCurrency || "USD"}
|
||
refreshInterval={600000}
|
||
/>
|
||
</div>
|
||
) : element.type === "widget" && element.subtype === "clock" ? (
|
||
// 시계 위젯 렌더링
|
||
<div className="h-full w-full">
|
||
<ClockWidget
|
||
element={element}
|
||
onConfigUpdate={(newConfig) => {
|
||
onUpdate(element.id, { clockConfig: newConfig });
|
||
}}
|
||
/>
|
||
</div>
|
||
) : element.type === "widget" && element.subtype === "calculator" ? (
|
||
// 계산기 위젯 렌더링
|
||
<div className="widget-interactive-area h-full w-full">
|
||
<CalculatorWidget />
|
||
</div>
|
||
) : element.type === "widget" && element.subtype === "vehicle-map" ? (
|
||
// 차량 위치 지도 위젯 렌더링
|
||
<div className="widget-interactive-area h-full w-full">
|
||
<VehicleMapWidget />
|
||
</div>
|
||
) : (
|
||
// 기타 위젯 렌더링
|
||
<div
|
||
className={`flex h-full w-full items-center justify-center p-5 text-center text-sm font-medium text-white ${getContentClass()} `}
|
||
>
|
||
<div>
|
||
<div className="mb-2 text-4xl">🔧</div>
|
||
<div className="whitespace-pre-line">{element.content}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 리사이즈 핸들 (선택된 요소에만 표시) */}
|
||
{isSelected && (
|
||
<>
|
||
<ResizeHandle position="nw" onMouseDown={handleResizeMouseDown} />
|
||
<ResizeHandle position="ne" onMouseDown={handleResizeMouseDown} />
|
||
<ResizeHandle position="sw" onMouseDown={handleResizeMouseDown} />
|
||
<ResizeHandle position="se" onMouseDown={handleResizeMouseDown} />
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface ResizeHandleProps {
|
||
position: "nw" | "ne" | "sw" | "se";
|
||
onMouseDown: (e: React.MouseEvent, handle: string) => void;
|
||
}
|
||
|
||
/**
|
||
* 크기 조절 핸들 컴포넌트
|
||
*/
|
||
function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) {
|
||
const getPositionClass = () => {
|
||
switch (position) {
|
||
case "nw":
|
||
return "top-[-5px] left-[-5px] cursor-nw-resize";
|
||
case "ne":
|
||
return "top-[-5px] right-[-5px] cursor-ne-resize";
|
||
case "sw":
|
||
return "bottom-[-5px] left-[-5px] cursor-sw-resize";
|
||
case "se":
|
||
return "bottom-[-5px] right-[-5px] cursor-se-resize";
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className={`resize-handle absolute h-3 w-3 border border-white bg-green-500 ${getPositionClass()} `}
|
||
onMouseDown={(e) => onMouseDown(e, position)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 샘플 데이터 생성 함수 (실제 API 호출 대신 사용)
|
||
*/
|
||
function generateSampleData(query: string, chartType: string): QueryResult {
|
||
// 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성
|
||
const isMonthly = query.toLowerCase().includes("month");
|
||
const isSales = query.toLowerCase().includes("sales") || query.toLowerCase().includes("매출");
|
||
const isUsers = query.toLowerCase().includes("users") || query.toLowerCase().includes("사용자");
|
||
const isProducts = query.toLowerCase().includes("product") || query.toLowerCase().includes("상품");
|
||
|
||
let columns: string[];
|
||
let rows: Record<string, any>[];
|
||
|
||
if (isMonthly && isSales) {
|
||
// 월별 매출 데이터
|
||
columns = ["month", "sales", "order_count"];
|
||
rows = [
|
||
{ month: "2024-01", sales: 1200000, order_count: 45 },
|
||
{ month: "2024-02", sales: 1350000, order_count: 52 },
|
||
{ month: "2024-03", sales: 1180000, order_count: 41 },
|
||
{ month: "2024-04", sales: 1420000, order_count: 58 },
|
||
{ month: "2024-05", sales: 1680000, order_count: 67 },
|
||
{ month: "2024-06", sales: 1540000, order_count: 61 },
|
||
];
|
||
} else if (isUsers) {
|
||
// 사용자 가입 추이
|
||
columns = ["week", "new_users"];
|
||
rows = [
|
||
{ week: "2024-W10", new_users: 23 },
|
||
{ week: "2024-W11", new_users: 31 },
|
||
{ week: "2024-W12", new_users: 28 },
|
||
{ week: "2024-W13", new_users: 35 },
|
||
{ week: "2024-W14", new_users: 42 },
|
||
{ week: "2024-W15", new_users: 38 },
|
||
];
|
||
} else if (isProducts) {
|
||
// 상품별 판매량
|
||
columns = ["product_name", "total_sold", "revenue"];
|
||
rows = [
|
||
{ product_name: "스마트폰", total_sold: 156, revenue: 234000000 },
|
||
{ product_name: "노트북", total_sold: 89, revenue: 178000000 },
|
||
{ product_name: "태블릿", total_sold: 134, revenue: 67000000 },
|
||
{ product_name: "이어폰", total_sold: 267, revenue: 26700000 },
|
||
{ product_name: "스마트워치", total_sold: 98, revenue: 49000000 },
|
||
];
|
||
} else {
|
||
// 기본 샘플 데이터
|
||
columns = ["category", "value", "count"];
|
||
rows = [
|
||
{ category: "A", value: 100, count: 10 },
|
||
{ category: "B", value: 150, count: 15 },
|
||
{ category: "C", value: 120, count: 12 },
|
||
{ category: "D", value: 180, count: 18 },
|
||
{ category: "E", value: 90, count: 9 },
|
||
];
|
||
}
|
||
|
||
return {
|
||
columns,
|
||
rows,
|
||
totalRows: rows.length,
|
||
executionTime: Math.floor(Math.random() * 100) + 50, // 50-150ms
|
||
};
|
||
}
|