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}
+
+
+ );
+ })}
+
+
+
+ );
+}