사용자 커스텀 카드 위젯 구현
This commit is contained in:
parent
60ef6a6a95
commit
6422dac2a4
|
|
@ -63,9 +63,9 @@ export class DashboardService {
|
||||||
id, dashboard_id, element_type, element_subtype,
|
id, dashboard_id, element_type, element_subtype,
|
||||||
position_x, position_y, width, height,
|
position_x, position_y, width, height,
|
||||||
title, custom_title, show_header, content, data_source_config, chart_config,
|
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
|
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,
|
elementId,
|
||||||
|
|
@ -84,6 +84,7 @@ export class DashboardService {
|
||||||
JSON.stringify(element.chartConfig || {}),
|
JSON.stringify(element.chartConfig || {}),
|
||||||
JSON.stringify(element.listConfig || null),
|
JSON.stringify(element.listConfig || null),
|
||||||
JSON.stringify(element.yardConfig || null),
|
JSON.stringify(element.yardConfig || null),
|
||||||
|
JSON.stringify(element.customMetricConfig || null),
|
||||||
i,
|
i,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
|
|
@ -391,6 +392,11 @@ export class DashboardService {
|
||||||
? JSON.parse(row.yard_config)
|
? JSON.parse(row.yard_config)
|
||||||
: row.yard_config
|
: row.yard_config
|
||||||
: undefined,
|
: 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,
|
id, dashboard_id, element_type, element_subtype,
|
||||||
position_x, position_y, width, height,
|
position_x, position_y, width, height,
|
||||||
title, custom_title, show_header, content, data_source_config, chart_config,
|
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
|
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,
|
elementId,
|
||||||
|
|
@ -535,6 +541,7 @@ export class DashboardService {
|
||||||
JSON.stringify(element.chartConfig || {}),
|
JSON.stringify(element.chartConfig || {}),
|
||||||
JSON.stringify(element.listConfig || null),
|
JSON.stringify(element.listConfig || null),
|
||||||
JSON.stringify(element.yardConfig || null),
|
JSON.stringify(element.yardConfig || null),
|
||||||
|
JSON.stringify(element.customMetricConfig || null),
|
||||||
i,
|
i,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,17 @@ export interface DashboardElement {
|
||||||
layoutId: number;
|
layoutId: number;
|
||||||
layoutName?: string;
|
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 {
|
export interface Dashboard {
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,12 @@ const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/C
|
||||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 사용자 커스텀 카드 위젯
|
||||||
|
const CustomMetricWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricWidget"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||||
|
});
|
||||||
|
|
||||||
interface CanvasElementProps {
|
interface CanvasElementProps {
|
||||||
element: DashboardElement;
|
element: DashboardElement;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
|
@ -915,6 +921,11 @@ export function CanvasElement({
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<CustomStatsWidget element={element} />
|
<CustomStatsWidget element={element} />
|
||||||
</div>
|
</div>
|
||||||
|
) : element.type === "widget" && element.subtype === "custom-metric" ? (
|
||||||
|
// 사용자 커스텀 카드 위젯 렌더링
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<CustomMetricWidget element={element} />
|
||||||
|
</div>
|
||||||
) : element.type === "widget" && element.subtype === "todo" ? (
|
) : element.type === "widget" && element.subtype === "todo" ? (
|
||||||
// To-Do 위젯 렌더링
|
// To-Do 위젯 렌더링
|
||||||
<div className="widget-interactive-area h-full w-full">
|
<div className="widget-interactive-area h-full w-full">
|
||||||
|
|
|
||||||
|
|
@ -462,6 +462,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
chartConfig: el.chartConfig,
|
chartConfig: el.chartConfig,
|
||||||
listConfig: el.listConfig,
|
listConfig: el.listConfig,
|
||||||
yardConfig: el.yardConfig,
|
yardConfig: el.yardConfig,
|
||||||
|
customMetricConfig: el.customMetricConfig,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -181,12 +181,11 @@ export function DashboardTopMenu({
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel>데이터 위젯</SelectLabel>
|
<SelectLabel>데이터 위젯</SelectLabel>
|
||||||
<SelectItem value="list">리스트 위젯</SelectItem>
|
<SelectItem value="list">리스트 위젯</SelectItem>
|
||||||
|
<SelectItem value="custom-metric">사용자 커스텀 카드</SelectItem>
|
||||||
<SelectItem value="yard-management-3d">야드 관리 3D</SelectItem>
|
<SelectItem value="yard-management-3d">야드 관리 3D</SelectItem>
|
||||||
<SelectItem value="transport-stats">커스텀 통계 카드</SelectItem>
|
{/* <SelectItem value="transport-stats">커스텀 통계 카드</SelectItem> */}
|
||||||
{/* <SelectItem value="map">지도</SelectItem> */}
|
|
||||||
<SelectItem value="map-summary">커스텀 지도 카드</SelectItem>
|
<SelectItem value="map-summary">커스텀 지도 카드</SelectItem>
|
||||||
{/* <SelectItem value="list-summary">커스텀 목록 카드</SelectItem> */}
|
{/* <SelectItem value="status-summary">커스텀 상태 카드</SelectItem> */}
|
||||||
<SelectItem value="status-summary">커스텀 상태 카드</SelectItem>
|
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel>일반 위젯</SelectLabel>
|
<SelectLabel>일반 위젯</SelectLabel>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { YardWidgetConfigSidebar } from "./widgets/YardWidgetConfigSidebar";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import CustomMetricConfigSidebar from "./widgets/custom-metric/CustomMetricConfigSidebar";
|
||||||
|
|
||||||
interface ElementConfigSidebarProps {
|
interface ElementConfigSidebarProps {
|
||||||
element: DashboardElement | null;
|
element: DashboardElement | null;
|
||||||
|
|
@ -145,6 +146,20 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사용자 커스텀 카드 위젯은 사이드바로 처리
|
||||||
|
if (element.subtype === "custom-metric") {
|
||||||
|
return (
|
||||||
|
<CustomMetricConfigSidebar
|
||||||
|
element={element}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
onApply={(updates) => {
|
||||||
|
onApply({ ...element, ...updates });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
|
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
|
||||||
const isSimpleWidget =
|
const isSimpleWidget =
|
||||||
element.subtype === "todo" ||
|
element.subtype === "todo" ||
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@ export type ElementSubtype =
|
||||||
| "list"
|
| "list"
|
||||||
| "yard-management-3d" // 야드 관리 3D 위젯
|
| "yard-management-3d" // 야드 관리 3D 위젯
|
||||||
| "work-history" // 작업 이력 위젯
|
| "work-history" // 작업 이력 위젯
|
||||||
| "transport-stats"; // 커스텀 통계 카드 위젯
|
| "transport-stats" // 커스텀 통계 카드 위젯
|
||||||
|
| "custom-metric"; // 사용자 커스텀 카드 위젯
|
||||||
|
|
||||||
export interface Position {
|
export interface Position {
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -68,6 +69,7 @@ export interface DashboardElement {
|
||||||
driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정
|
driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정
|
||||||
listConfig?: ListWidgetConfig; // 리스트 위젯 설정
|
listConfig?: ListWidgetConfig; // 리스트 위젯 설정
|
||||||
yardConfig?: YardManagementConfig; // 야드 관리 3D 설정
|
yardConfig?: YardManagementConfig; // 야드 관리 3D 설정
|
||||||
|
customMetricConfig?: CustomMetricConfig; // 사용자 커스텀 카드 설정
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DragData {
|
export interface DragData {
|
||||||
|
|
@ -282,3 +284,16 @@ export interface YardManagementConfig {
|
||||||
layoutId: number; // 선택된 야드 레이아웃 ID
|
layoutId: number; // 선택된 야드 레이아웃 ID
|
||||||
layoutName?: string; // 레이아웃 이름 (표시용)
|
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; // 소수점 자릿수
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,392 @@
|
||||||
|
"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<DashboardElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomMetricConfigSidebar({
|
||||||
|
element,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onApply,
|
||||||
|
}: CustomMetricConfigSidebarProps) {
|
||||||
|
const [metrics, setMetrics] = useState<CustomMetricConfig["metrics"]>(element.customMetricConfig?.metrics || []);
|
||||||
|
const [expandedMetric, setExpandedMetric] = useState<string | null>(null);
|
||||||
|
const [queryColumns, setQueryColumns] = useState<string[]>([]);
|
||||||
|
const [dataSourceType, setDataSourceType] = useState<"database" | "api">(element.dataSource?.type || "database");
|
||||||
|
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
||||||
|
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
||||||
|
);
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 쿼리 실행 결과 처리
|
||||||
|
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<ChartDataSource>) => {
|
||||||
|
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({
|
||||||
|
customMetricConfig: {
|
||||||
|
metrics,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
|
||||||
|
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
||||||
|
<span className="text-primary text-xs font-bold">📊</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-gray-900">커스텀 카드 설정</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문: 스크롤 가능 영역 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 데이터 소스 타입 선택 */}
|
||||||
|
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||||
|
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">데이터 소스 타입</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDataSourceTypeChange("database")}
|
||||||
|
className={`flex h-20 flex-col items-center justify-center gap-2 rounded border transition-all ${
|
||||||
|
dataSourceType === "database"
|
||||||
|
? "border-primary bg-primary/5 text-primary"
|
||||||
|
: "border-gray-200 bg-gray-50 text-gray-600 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">🗄️</span>
|
||||||
|
<span className="text-xs font-medium">데이터베이스</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDataSourceTypeChange("api")}
|
||||||
|
className={`flex h-20 flex-col items-center justify-center gap-2 rounded border transition-all ${
|
||||||
|
dataSourceType === "api"
|
||||||
|
? "border-primary bg-primary/5 text-primary"
|
||||||
|
: "border-gray-200 bg-gray-50 text-gray-600 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">🌐</span>
|
||||||
|
<span className="text-xs font-medium">REST API</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 소스 설정 */}
|
||||||
|
{dataSourceType === "database" ? (
|
||||||
|
<>
|
||||||
|
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
||||||
|
<QueryEditor
|
||||||
|
dataSource={dataSource}
|
||||||
|
onDataSourceChange={handleDataSourceUpdate}
|
||||||
|
onQueryTest={handleQueryTest}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 지표 설정 섹션 - 쿼리 실행 후에만 표시 */}
|
||||||
|
{queryColumns.length > 0 && (
|
||||||
|
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="text-[10px] font-semibold tracking-wide text-gray-500 uppercase">지표</div>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={addMetric}>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{metrics.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-500">추가된 지표가 없습니다</p>
|
||||||
|
) : (
|
||||||
|
metrics.map((metric, index) => (
|
||||||
|
<div
|
||||||
|
key={metric.id}
|
||||||
|
onDragOver={(e) => 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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex w-full items-center gap-2">
|
||||||
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
className="cursor-grab active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4 shrink-0 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div className="grid min-w-0 flex-1 grid-cols-[1fr,auto,auto] items-center gap-2">
|
||||||
|
<span className="truncate text-xs font-medium text-gray-900">
|
||||||
|
{metric.label || "새 지표"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-500">{metric.aggregation.toUpperCase()}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedMetric(expandedMetric === metric.id ? null : metric.id)}
|
||||||
|
className="flex items-center justify-center rounded p-0.5 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
{expandedMetric === metric.id ? (
|
||||||
|
<ChevronUp className="h-3.5 w-3.5 text-gray-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-gray-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설정 영역 */}
|
||||||
|
{expandedMetric === metric.id && (
|
||||||
|
<div className="mt-2 space-y-1.5 border-t border-gray-200 pt-2">
|
||||||
|
{/* 2열 그리드 레이아웃 */}
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{/* 컬럼 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-0.5 block text-[9px] font-medium text-gray-500">컬럼</label>
|
||||||
|
<Select
|
||||||
|
value={metric.field}
|
||||||
|
onValueChange={(value) => updateMetric(metric.id, "field", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-full text-[10px]">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{queryColumns.map((col) => (
|
||||||
|
<SelectItem key={col} value={col}>
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 집계 함수 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-0.5 block text-[9px] font-medium text-gray-500">집계</label>
|
||||||
|
<Select
|
||||||
|
value={metric.aggregation}
|
||||||
|
onValueChange={(value: any) => updateMetric(metric.id, "aggregation", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-full text-[10px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="count">COUNT</SelectItem>
|
||||||
|
<SelectItem value="sum">SUM</SelectItem>
|
||||||
|
<SelectItem value="avg">AVG</SelectItem>
|
||||||
|
<SelectItem value="min">MIN</SelectItem>
|
||||||
|
<SelectItem value="max">MAX</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 단위 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-0.5 block text-[9px] font-medium text-gray-500">단위</label>
|
||||||
|
<Input
|
||||||
|
value={metric.unit}
|
||||||
|
onChange={(e) => updateMetric(metric.id, "unit", e.target.value)}
|
||||||
|
className="h-6 w-full text-[10px]"
|
||||||
|
placeholder="건, %, km"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 소수점 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-0.5 block text-[9px] font-medium text-gray-500">소수점</label>
|
||||||
|
<Select
|
||||||
|
value={String(metric.decimals)}
|
||||||
|
onValueChange={(value) => updateMetric(metric.id, "decimals", parseInt(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-full text-[10px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[0, 1, 2].map((num) => (
|
||||||
|
<SelectItem key={num} value={String(num)}>
|
||||||
|
{num}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 표시 이름 (전체 너비) */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-0.5 block text-[9px] font-medium text-gray-500">표시 이름</label>
|
||||||
|
<Input
|
||||||
|
value={metric.label}
|
||||||
|
onChange={(e) => updateMetric(metric.id, "label", e.target.value)}
|
||||||
|
className="h-6 w-full text-[10px]"
|
||||||
|
placeholder="라벨"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<div className="border-t border-gray-200 pt-1.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-6 w-full gap-1 text-[10px]"
|
||||||
|
onClick={() => deleteMetric(metric.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div className="flex gap-2 border-t bg-white p-3 shadow-sm">
|
||||||
|
<Button variant="outline" className="h-8 flex-1 text-xs" onClick={onClose}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button className="focus:ring-primary/20 h-8 flex-1 text-xs" onClick={handleSave}>
|
||||||
|
적용
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -51,6 +51,10 @@ const CustomStatsWidget = dynamic(() => import("./widgets/CustomStatsWidget"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const CustomMetricWidget = dynamic(() => import("./widgets/CustomMetricWidget"), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리
|
* 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리
|
||||||
* ViewerElement에서 사용하기 위해 컴포넌트 외부에 정의
|
* ViewerElement에서 사용하기 위해 컴포넌트 외부에 정의
|
||||||
|
|
@ -76,6 +80,8 @@ function renderWidget(element: DashboardElement) {
|
||||||
return <CalendarWidget element={element} />;
|
return <CalendarWidget element={element} />;
|
||||||
case "status-summary":
|
case "status-summary":
|
||||||
return <StatusSummaryWidget element={element} />;
|
return <StatusSummaryWidget element={element} />;
|
||||||
|
case "custom-metric":
|
||||||
|
return <CustomMetricWidget element={element} />;
|
||||||
|
|
||||||
// === 운영/작업 지원 ===
|
// === 운영/작업 지원 ===
|
||||||
case "todo":
|
case "todo":
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
"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<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("🎯 CustomMetricWidget mounted, element:", element);
|
||||||
|
console.log("📊 dataSource:", element?.dataSource);
|
||||||
|
console.log("📈 customMetricConfig:", element?.customMetricConfig);
|
||||||
|
loadData();
|
||||||
|
|
||||||
|
// 자동 새로고침 (30초마다)
|
||||||
|
const interval = setInterval(loadData, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [element]);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// 쿼리나 설정이 없으면 초기 상태로 반환
|
||||||
|
if (!element?.dataSource?.query || !element?.customMetricConfig?.metrics) {
|
||||||
|
console.log("⚠️ 쿼리 또는 지표 설정이 없습니다");
|
||||||
|
console.log("- dataSource.query:", element?.dataSource?.query);
|
||||||
|
console.log("- customMetricConfig.metrics:", element?.customMetricConfig?.metrics);
|
||||||
|
setMetrics([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ 쿼리 실행 시작:", element.dataSource.query);
|
||||||
|
|
||||||
|
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 || "데이터 로드 실패");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("메트릭 로드 실패:", err);
|
||||||
|
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center bg-white">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
|
<p className="mt-2 text-sm text-gray-500">데이터 로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center bg-white p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-red-600">⚠️ {error}</p>
|
||||||
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||||
|
>
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!element?.dataSource?.query || !element?.customMetricConfig?.metrics || metrics.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center bg-white p-4">
|
||||||
|
<div className="max-w-xs space-y-2 text-center">
|
||||||
|
<h3 className="text-sm font-bold text-gray-900">사용자 커스텀 카드</h3>
|
||||||
|
<div className="space-y-1.5 text-xs text-gray-600">
|
||||||
|
<p className="font-medium">📊 맞춤형 지표 위젯</p>
|
||||||
|
<ul className="space-y-0.5 text-left">
|
||||||
|
<li>• SQL 쿼리로 데이터를 불러옵니다</li>
|
||||||
|
<li>• 선택한 컬럼의 데이터로 지표를 계산합니다</li>
|
||||||
|
<li>• COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원</li>
|
||||||
|
<li>• 사용자 정의 단위 설정 가능</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
|
||||||
|
<p className="font-medium">⚙️ 설정 방법</p>
|
||||||
|
<p>SQL 쿼리를 입력하고 지표를 추가하세요</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col overflow-hidden bg-white p-4">
|
||||||
|
{/* 스크롤 가능한 콘텐츠 영역 */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="grid w-full gap-4" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))" }}>
|
||||||
|
{metrics.map((metric) => {
|
||||||
|
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
|
||||||
|
const formattedValue = metric.calculatedValue.toFixed(metric.decimals);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={metric.id} className={`rounded-lg border ${colors.bg} ${colors.border} p-4 text-center`}>
|
||||||
|
<div className="text-sm text-gray-600">{metric.label}</div>
|
||||||
|
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>
|
||||||
|
{formattedValue}
|
||||||
|
<span className="ml-1 text-lg">{metric.unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue