diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index 68cc582f..92b5ed39 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -63,9 +63,9 @@ export class DashboardService { id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, title, custom_title, show_header, content, data_source_config, chart_config, - list_config, yard_config, + list_config, yard_config, custom_metric_config, display_order, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) `, [ elementId, @@ -84,6 +84,7 @@ export class DashboardService { JSON.stringify(element.chartConfig || {}), JSON.stringify(element.listConfig || null), JSON.stringify(element.yardConfig || null), + JSON.stringify(element.customMetricConfig || null), i, now, now, @@ -391,6 +392,11 @@ export class DashboardService { ? JSON.parse(row.yard_config) : row.yard_config : undefined, + customMetricConfig: row.custom_metric_config + ? typeof row.custom_metric_config === "string" + ? JSON.parse(row.custom_metric_config) + : row.custom_metric_config + : undefined, }) ); @@ -514,9 +520,9 @@ export class DashboardService { id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, title, custom_title, show_header, content, data_source_config, chart_config, - list_config, yard_config, + list_config, yard_config, custom_metric_config, display_order, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) `, [ elementId, @@ -535,6 +541,7 @@ export class DashboardService { JSON.stringify(element.chartConfig || {}), JSON.stringify(element.listConfig || null), JSON.stringify(element.yardConfig || null), + JSON.stringify(element.customMetricConfig || null), i, now, now, diff --git a/backend-node/src/types/dashboard.ts b/backend-node/src/types/dashboard.ts index b03acbff..7d6267a7 100644 --- a/backend-node/src/types/dashboard.ts +++ b/backend-node/src/types/dashboard.ts @@ -45,6 +45,17 @@ export interface DashboardElement { layoutId: number; layoutName?: string; }; + customMetricConfig?: { + metrics: Array<{ + id: string; + field: string; + label: string; + aggregation: "count" | "sum" | "avg" | "min" | "max"; + unit: string; + color: "indigo" | "green" | "blue" | "purple" | "orange" | "gray"; + decimals: number; + }>; + }; } export interface Dashboard { diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 35eb134d..530e510e 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -121,6 +121,12 @@ const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/C loading: () =>
로딩 중...
, }); +// 사용자 커스텀 카드 위젯 +const CustomMetricWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + interface CanvasElementProps { element: DashboardElement; isSelected: boolean; @@ -910,8 +916,13 @@ export function CanvasElement({
+ ) : element.type === "widget" && element.subtype === "custom-metric" ? ( + // 사용자 커스텀 카드 위젯 렌더링 (main에서 추가) +
+ +
) : element.type === "widget" && (element.subtype === "todo" || element.subtype === "maintenance") ? ( - // Task 위젯 렌더링 (To-Do + 정비 일정 통합) + // Task 위젯 렌더링 (To-Do + 정비 일정 통합, lhj)
diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 7d7e3cd6..5b39a8f7 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -462,6 +462,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D chartConfig: el.chartConfig, listConfig: el.listConfig, yardConfig: el.yardConfig, + customMetricConfig: el.customMetricConfig, }; }); diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx deleted file mode 100644 index e81b26d2..00000000 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ /dev/null @@ -1,270 +0,0 @@ -"use client"; - -import React, { useState } from "react"; -import { DragData, ElementType, ElementSubtype } from "./types"; -import { ChevronDown, ChevronRight } from "lucide-react"; - -/** - * 대시보드 사이드바 컴포넌트 - * - 드래그 가능한 차트/위젯 목록 - * - 아코디언 방식으로 카테고리별 구분 - */ -export function DashboardSidebar() { - const [expandedSections, setExpandedSections] = useState({ - charts: true, - widgets: true, - operations: true, - }); - - // 섹션 토글 - const toggleSection = (section: keyof typeof expandedSections) => { - setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] })); - }; - - // 드래그 시작 처리 - const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => { - const dragData: DragData = { type, subtype }; - e.dataTransfer.setData("application/json", JSON.stringify(dragData)); - e.dataTransfer.effectAllowed = "copy"; - }; - - return ( -
- {/* 차트 섹션 */} -
- - - {expandedSections.charts && ( -
- - - - - - - - -
- )} -
- - {/* 위젯 섹션 */} -
- - - {expandedSections.widgets && ( -
- - - {/* */} - - - - {/* */} - - - - -
- )} -
- - {/* 운영/작업 지원 섹션 */} -
- - - {expandedSections.operations && ( -
- - {/* 예약알림 위젯 - 필요시 주석 해제 */} - {/* */} - {/* 정비 일정 관리 위젯 제거 - 커스텀 목록 카드로 대체 가능 */} - - - - -
- )} -
-
- ); -} - -interface DraggableItemProps { - icon?: string; - title: string; - type: ElementType; - subtype: ElementSubtype; - className?: string; - onDragStart: (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => void; -} - -/** - * 드래그 가능한 아이템 컴포넌트 - */ -function DraggableItem({ title, type, subtype, className = "", onDragStart }: DraggableItemProps) { - return ( -
onDragStart(e, type, subtype)} - > - {title} -
- ); -} diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index 5c292f19..6392b355 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -181,12 +181,11 @@ export function DashboardTopMenu({ 데이터 위젯 리스트 위젯 + 사용자 커스텀 카드 야드 관리 3D - 커스텀 통계 카드 - {/* 지도 */} + {/* 커스텀 통계 카드 */} 커스텀 지도 카드 - {/* 커스텀 목록 카드 */} - 커스텀 상태 카드 + {/* 커스텀 상태 카드 */} 일반 위젯 diff --git a/frontend/components/admin/dashboard/ElementConfigSidebar.tsx b/frontend/components/admin/dashboard/ElementConfigSidebar.tsx index 97332944..e661eead 100644 --- a/frontend/components/admin/dashboard/ElementConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/ElementConfigSidebar.tsx @@ -12,6 +12,7 @@ import { YardWidgetConfigSidebar } from "./widgets/YardWidgetConfigSidebar"; 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"; interface ElementConfigSidebarProps { element: DashboardElement | null; @@ -145,6 +146,20 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem ); } + // 사용자 커스텀 카드 위젯은 사이드바로 처리 + if (element.subtype === "custom-metric") { + return ( + { + onApply({ ...element, ...updates }); + }} + /> + ); + } + // 차트 설정이 필요 없는 위젯 (쿼리/API만 필요) const isSimpleWidget = element.subtype === "todo" || diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 65ec916d..a07b5247 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -39,7 +39,8 @@ export type ElementSubtype = | "list" | "yard-management-3d" // 야드 관리 3D 위젯 | "work-history" // 작업 이력 위젯 - | "transport-stats"; // 커스텀 통계 카드 위젯 + | "transport-stats" // 커스텀 통계 카드 위젯 + | "custom-metric"; // 사용자 커스텀 카드 위젯 export interface Position { x: number; @@ -69,6 +70,7 @@ export interface DashboardElement { driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정 listConfig?: ListWidgetConfig; // 리스트 위젯 설정 yardConfig?: YardManagementConfig; // 야드 관리 3D 설정 + customMetricConfig?: CustomMetricConfig; // 사용자 커스텀 카드 설정 } export interface DragData { @@ -285,3 +287,16 @@ export interface YardManagementConfig { layoutId: number; // 선택된 야드 레이아웃 ID layoutName?: string; // 레이아웃 이름 (표시용) } + +// 사용자 커스텀 카드 설정 +export interface CustomMetricConfig { + metrics: Array<{ + id: string; // 고유 ID + field: string; // 집계할 컬럼명 + label: string; // 표시할 라벨 + aggregation: "count" | "sum" | "avg" | "min" | "max"; // 집계 함수 + unit: string; // 단위 (%, 건, 일, km, 톤 등) + color: "indigo" | "green" | "blue" | "purple" | "orange" | "gray"; + decimals: number; // 소수점 자릿수 + }>; +} diff --git a/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx b/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx new file mode 100644 index 00000000..0a1dd39b --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx @@ -0,0 +1,429 @@ +"use client"; + +import React, { useState } from "react"; +import { DashboardElement, CustomMetricConfig } from "@/components/admin/dashboard/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { GripVertical, Plus, Trash2, ChevronDown, ChevronUp, X } from "lucide-react"; +import { DatabaseConfig } from "../../data-sources/DatabaseConfig"; +import { ChartDataSource } from "../../types"; +import { ApiConfig } from "../../data-sources/ApiConfig"; +import { QueryEditor } from "../../QueryEditor"; +import { v4 as uuidv4 } from "uuid"; +import { cn } from "@/lib/utils"; + +interface CustomMetricConfigSidebarProps { + element: DashboardElement; + isOpen: boolean; + onClose: () => void; + onApply: (updates: Partial) => void; +} + +export default function CustomMetricConfigSidebar({ + element, + isOpen, + onClose, + onApply, +}: CustomMetricConfigSidebarProps) { + const [metrics, setMetrics] = useState(element.customMetricConfig?.metrics || []); + const [expandedMetric, setExpandedMetric] = useState(null); + const [queryColumns, setQueryColumns] = useState([]); + const [dataSourceType, setDataSourceType] = useState<"database" | "api">(element.dataSource?.type || "database"); + const [dataSource, setDataSource] = useState( + element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }, + ); + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + const [customTitle, setCustomTitle] = useState(element.customTitle || element.title || ""); + const [showHeader, setShowHeader] = useState(element.showHeader !== false); + + // 쿼리 실행 결과 처리 + const handleQueryTest = (result: any) => { + // QueryEditor에서 오는 경우: { success: true, data: { columns: [...], rows: [...] } } + if (result.success && result.data?.columns) { + setQueryColumns(result.data.columns); + } + // ApiConfig에서 오는 경우: { columns: [...], data: [...] } 또는 { success: true, columns: [...] } + else if (result.columns && Array.isArray(result.columns)) { + setQueryColumns(result.columns); + } + // 오류 처리 + else { + setQueryColumns([]); + } + }; + + // 메트릭 추가 + const addMetric = () => { + const newMetric = { + id: uuidv4(), + field: "", + label: "새 지표", + aggregation: "count" as const, + unit: "", + color: "gray" as const, + decimals: 1, + }; + setMetrics([...metrics, newMetric]); + setExpandedMetric(newMetric.id); + }; + + // 메트릭 삭제 + const deleteMetric = (id: string) => { + setMetrics(metrics.filter((m) => m.id !== id)); + if (expandedMetric === id) { + setExpandedMetric(null); + } + }; + + // 메트릭 업데이트 + const updateMetric = (id: string, field: string, value: any) => { + setMetrics(metrics.map((m) => (m.id === id ? { ...m, [field]: value } : m))); + }; + + // 메트릭 순서 변경 + // 드래그 앤 드롭 핸들러 + const handleDragStart = (index: number) => { + setDraggedIndex(index); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + setDragOverIndex(index); + }; + + const handleDrop = (e: React.DragEvent, dropIndex: number) => { + e.preventDefault(); + if (draggedIndex === null || draggedIndex === dropIndex) { + setDraggedIndex(null); + setDragOverIndex(null); + return; + } + + const newMetrics = [...metrics]; + const [draggedItem] = newMetrics.splice(draggedIndex, 1); + newMetrics.splice(dropIndex, 0, draggedItem); + + setMetrics(newMetrics); + setDraggedIndex(null); + setDragOverIndex(null); + }; + + const handleDragEnd = () => { + setDraggedIndex(null); + setDragOverIndex(null); + }; + + // 데이터 소스 업데이트 + const handleDataSourceUpdate = (updates: Partial) => { + const newDataSource = { ...dataSource, ...updates }; + setDataSource(newDataSource); + onApply({ dataSource: newDataSource }); + }; + + // 데이터 소스 타입 변경 + const handleDataSourceTypeChange = (type: "database" | "api") => { + setDataSourceType(type); + const newDataSource: ChartDataSource = + type === "database" + ? { type: "database", connectionType: "current", refreshInterval: 0 } + : { type: "api", method: "GET", refreshInterval: 0 }; + + setDataSource(newDataSource); + onApply({ dataSource: newDataSource }); + setQueryColumns([]); + }; + + // 저장 + const handleSave = () => { + onApply({ + customTitle: customTitle, + showHeader: showHeader, + customMetricConfig: { + metrics, + }, + }); + }; + + if (!isOpen) return null; + + return ( +
+ {/* 헤더 */} +
+
+
+ 📊 +
+ 커스텀 카드 설정 +
+ +
+ + {/* 본문: 스크롤 가능 영역 */} +
+
+ {/* 헤더 설정 */} +
+
헤더 설정
+
+ {/* 제목 입력 */} +
+ + setCustomTitle(e.target.value)} + placeholder="위젯 제목을 입력하세요" + className="h-8 text-xs" + style={{ fontSize: "12px" }} + /> +
+ + {/* 헤더 표시 여부 */} +
+ + +
+
+
+ + {/* 데이터 소스 타입 선택 */} +
+
데이터 소스 타입
+
+ + +
+
+ + {/* 데이터 소스 설정 */} + {dataSourceType === "database" ? ( + <> + + + + ) : ( + + )} + + {/* 지표 설정 섹션 - 쿼리 실행 후에만 표시 */} + {queryColumns.length > 0 && ( +
+
+
지표
+ +
+ +
+ {metrics.length === 0 ? ( +

추가된 지표가 없습니다

+ ) : ( + metrics.map((metric, index) => ( +
handleDragOver(e, index)} + onDrop={(e) => handleDrop(e, index)} + className={cn( + "rounded-md border bg-white p-2 transition-all", + draggedIndex === index && "opacity-50", + dragOverIndex === index && draggedIndex !== index && "border-primary border-2", + )} + > + {/* 헤더 */} +
+
handleDragStart(index)} + onDragEnd={handleDragEnd} + className="cursor-grab active:cursor-grabbing" + > + +
+
+ + {metric.label || "새 지표"} + + {metric.aggregation.toUpperCase()} + +
+
+ + {/* 설정 영역 */} + {expandedMetric === metric.id && ( +
+ {/* 2열 그리드 레이아웃 */} +
+ {/* 컬럼 */} +
+ + +
+ + {/* 집계 함수 */} +
+ + +
+ + {/* 단위 */} +
+ + updateMetric(metric.id, "unit", e.target.value)} + className="h-6 w-full text-[10px]" + placeholder="건, %, km" + /> +
+ + {/* 소수점 */} +
+ + +
+
+ + {/* 표시 이름 (전체 너비) */} +
+ + updateMetric(metric.id, "label", e.target.value)} + className="h-6 w-full text-[10px]" + placeholder="라벨" + /> +
+ + {/* 삭제 버튼 */} +
+ +
+
+ )} +
+ )) + )} +
+
+ )} +
+
+ + {/* 푸터 */} +
+ + +
+
+ ); +} diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index e9e522af..4bbca728 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -51,6 +51,10 @@ const CustomStatsWidget = dynamic(() => import("./widgets/CustomStatsWidget"), { ssr: false, }); +const CustomMetricWidget = dynamic(() => import("./widgets/CustomMetricWidget"), { + ssr: false, +}); + /** * 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리 * ViewerElement에서 사용하기 위해 컴포넌트 외부에 정의 @@ -78,6 +82,8 @@ function renderWidget(element: DashboardElement) { return ; case "status-summary": return ; + case "custom-metric": + return ; // === 운영/작업 지원 === case "todo": diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx new file mode 100644 index 00000000..4dfc289e --- /dev/null +++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx @@ -0,0 +1,271 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; + +interface CustomMetricWidgetProps { + element?: DashboardElement; +} + +// 집계 함수 실행 +const calculateMetric = (rows: any[], field: string, aggregation: string): number => { + if (rows.length === 0) return 0; + + switch (aggregation) { + case "count": + return rows.length; + case "sum": { + return rows.reduce((sum, row) => sum + (parseFloat(row[field]) || 0), 0); + } + case "avg": { + const sum = rows.reduce((s, row) => s + (parseFloat(row[field]) || 0), 0); + return rows.length > 0 ? sum / rows.length : 0; + } + case "min": { + return Math.min(...rows.map((row) => parseFloat(row[field]) || 0)); + } + case "max": { + return Math.max(...rows.map((row) => parseFloat(row[field]) || 0)); + } + default: + return 0; + } +}; + +// 색상 스타일 매핑 +const colorMap = { + indigo: { bg: "bg-indigo-50", text: "text-indigo-600", border: "border-indigo-200" }, + green: { bg: "bg-green-50", text: "text-green-600", border: "border-green-200" }, + blue: { bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-200" }, + purple: { bg: "bg-purple-50", text: "text-purple-600", border: "border-purple-200" }, + orange: { bg: "bg-orange-50", text: "text-orange-600", border: "border-orange-200" }, + gray: { bg: "bg-gray-50", text: "text-gray-600", border: "border-gray-200" }, +}; + +export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) { + const [metrics, setMetrics] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + loadData(); + + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + }, [element]); + + const loadData = async () => { + try { + setLoading(true); + setError(null); + + // 데이터 소스 타입 확인 + const dataSourceType = element?.dataSource?.type; + + // 설정이 없으면 초기 상태로 반환 + if (!element?.customMetricConfig?.metrics) { + setMetrics([]); + setLoading(false); + return; + } + + // Database 타입 + if (dataSourceType === "database") { + if (!element?.dataSource?.query) { + setMetrics([]); + setLoading(false); + return; + } + + const token = localStorage.getItem("authToken"); + const response = await fetch("/api/dashboards/execute-query", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: element.dataSource.query, + connectionType: element.dataSource.connectionType || "current", + connectionId: element.dataSource.connectionId, + }), + }); + + if (!response.ok) throw new Error("데이터 로딩 실패"); + + const result = await response.json(); + + if (result.success && result.data?.rows) { + const rows = result.data.rows; + + const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => { + const value = calculateMetric(rows, metric.field, metric.aggregation); + return { + ...metric, + calculatedValue: value, + }; + }); + + setMetrics(calculatedMetrics); + } else { + throw new Error(result.message || "데이터 로드 실패"); + } + } + // API 타입 + else if (dataSourceType === "api") { + if (!element?.dataSource?.endpoint) { + setMetrics([]); + setLoading(false); + return; + } + + const token = localStorage.getItem("authToken"); + const response = await fetch("/api/dashboards/fetch-external-api", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + method: element.dataSource.method || "GET", + url: element.dataSource.endpoint, + headers: element.dataSource.headers || {}, + body: element.dataSource.body, + authType: element.dataSource.authType, + authConfig: element.dataSource.authConfig, + }), + }); + + if (!response.ok) throw new Error("API 호출 실패"); + + const result = await response.json(); + + if (result.success && result.data) { + // API 응답 데이터 구조 확인 및 처리 + let rows: any[] = []; + + // result.data가 배열인 경우 + if (Array.isArray(result.data)) { + rows = result.data; + } + // result.data.results가 배열인 경우 (일반적인 API 응답 구조) + else if (result.data.results && Array.isArray(result.data.results)) { + rows = result.data.results; + } + // result.data.items가 배열인 경우 + else if (result.data.items && Array.isArray(result.data.items)) { + rows = result.data.items; + } + // result.data.data가 배열인 경우 + else if (result.data.data && Array.isArray(result.data.data)) { + rows = result.data.data; + } + // 그 외의 경우 단일 객체를 배열로 래핑 + else { + rows = [result.data]; + } + + const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => { + const value = calculateMetric(rows, metric.field, metric.aggregation); + return { + ...metric, + calculatedValue: value, + }; + }); + + setMetrics(calculatedMetrics); + } else { + throw new Error("API 응답 형식 오류"); + } + } else { + setMetrics([]); + setLoading(false); + } + } catch (err) { + console.error("메트릭 로드 실패:", err); + setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다"); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + // 데이터 소스가 없거나 설정이 없는 경우 + const hasDataSource = + (element?.dataSource?.type === "database" && element?.dataSource?.query) || + (element?.dataSource?.type === "api" && element?.dataSource?.endpoint); + + if (!hasDataSource || !element?.customMetricConfig?.metrics || metrics.length === 0) { + return ( +
+
+

사용자 커스텀 카드

+
+

📊 맞춤형 지표 위젯

+
    +
  • • SQL 쿼리로 데이터를 불러옵니다
  • +
  • • 선택한 컬럼의 데이터로 지표를 계산합니다
  • +
  • • COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원
  • +
  • • 사용자 정의 단위 설정 가능
  • +
+
+
+

⚙️ 설정 방법

+

SQL 쿼리를 입력하고 지표를 추가하세요

+
+
+
+ ); + } + + return ( +
+ {/* 스크롤 가능한 콘텐츠 영역 */} +
+
+ {metrics.map((metric) => { + const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray; + const formattedValue = metric.calculatedValue.toFixed(metric.decimals); + + return ( +
+
{metric.label}
+
+ {formattedValue} + {metric.unit} +
+
+ ); + })} +
+
+
+ ); +}