Merge branch 'main' into lhj

충돌 해결:
- CanvasElement.tsx: CustomMetricWidget(main) + 통합 TaskWidget(lhj) 모두 유지
- DashboardTopMenu.tsx: lhj의 '일정관리 위젯' 유지
- DashboardSidebar.tsx: main의 삭제 수락
This commit is contained in:
leeheejin 2025-10-23 15:14:47 +09:00
commit 6a5cf839a7
11 changed files with 775 additions and 280 deletions

View File

@ -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,

View File

@ -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 {

View File

@ -121,6 +121,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>,
});
// 사용자 커스텀 카드 위젯
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 {
element: DashboardElement;
isSelected: boolean;
@ -910,8 +916,13 @@ export function CanvasElement({
<div className="h-full w-full">
<CustomStatsWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "custom-metric" ? (
// 사용자 커스텀 카드 위젯 렌더링 (main에서 추가)
<div className="h-full w-full">
<CustomMetricWidget element={element} />
</div>
) : element.type === "widget" && (element.subtype === "todo" || element.subtype === "maintenance") ? (
// Task 위젯 렌더링 (To-Do + 정비 일정 통합)
// Task 위젯 렌더링 (To-Do + 정비 일정 통합, lhj)
<div className="widget-interactive-area h-full w-full">
<TaskWidget element={element} />
</div>

View File

@ -462,6 +462,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
chartConfig: el.chartConfig,
listConfig: el.listConfig,
yardConfig: el.yardConfig,
customMetricConfig: el.customMetricConfig,
};
});

View File

@ -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 (
<div className="w-[370px] overflow-y-auto border-l border-border bg-background p-5">
{/* 차트 섹션 */}
<div className="mb-5">
<button
onClick={() => toggleSection("charts")}
className="mb-3 flex w-full items-center justify-between px-1 py-2.5 text-xl font-bold text-foreground transition-colors hover:text-primary"
>
<span> </span>
{expandedSections.charts ? (
<ChevronDown className="h-5 w-5 text-muted-foreground transition-transform" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground transition-transform" />
)}
</button>
{expandedSections.charts && (
<div className="space-y-2">
<DraggableItem
title="바 차트"
type="chart"
subtype="bar"
onDragStart={handleDragStart}
/>
<DraggableItem
title="수평 바 차트"
type="chart"
subtype="horizontal-bar"
onDragStart={handleDragStart}
/>
<DraggableItem
title="누적 바 차트"
type="chart"
subtype="stacked-bar"
onDragStart={handleDragStart}
/>
<DraggableItem
title="꺾은선 차트"
type="chart"
subtype="line"
onDragStart={handleDragStart}
/>
<DraggableItem
title="영역 차트"
type="chart"
subtype="area"
onDragStart={handleDragStart}
/>
<DraggableItem
title="원형 차트"
type="chart"
subtype="pie"
onDragStart={handleDragStart}
/>
<DraggableItem
title="도넛 차트"
type="chart"
subtype="donut"
onDragStart={handleDragStart}
/>
<DraggableItem
title="콤보 차트"
type="chart"
subtype="combo"
onDragStart={handleDragStart}
/>
</div>
)}
</div>
{/* 위젯 섹션 */}
<div className="mb-5">
<button
onClick={() => toggleSection("widgets")}
className="mb-3 flex w-full items-center justify-between px-1 py-2.5 text-xl font-bold text-foreground transition-colors hover:text-primary"
>
<span> </span>
{expandedSections.widgets ? (
<ChevronDown className="h-5 w-5 text-muted-foreground transition-transform" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground transition-transform" />
)}
</button>
{expandedSections.widgets && (
<div className="space-y-2">
<DraggableItem
title="환율 위젯"
type="widget"
subtype="exchange"
onDragStart={handleDragStart}
/>
<DraggableItem
title="날씨 위젯"
type="widget"
subtype="weather"
onDragStart={handleDragStart}
/>
{/* <DraggableItem
title="날씨 지도 위젯"
type="widget"
subtype="weather-map"
onDragStart={handleDragStart}
/> */}
<DraggableItem
title="계산기 위젯"
type="widget"
subtype="calculator"
onDragStart={handleDragStart}
/>
<DraggableItem
title="시계 위젯"
type="widget"
subtype="clock"
onDragStart={handleDragStart}
/>
<DraggableItem
title="커스텀 지도 카드"
type="widget"
subtype="map-summary"
onDragStart={handleDragStart}
/>
{/* <DraggableItem
title="커스텀 목록 카드"
type="widget"
subtype="list-summary"
onDragStart={handleDragStart}
/> */}
<DraggableItem
title="리스크/알림 위젯"
type="widget"
subtype="risk-alert"
onDragStart={handleDragStart}
/>
<DraggableItem
title="To-Do / 긴급 지시"
type="widget"
subtype="todo"
onDragStart={handleDragStart}
/>
<DraggableItem
title="달력 위젯"
type="widget"
subtype="calendar"
onDragStart={handleDragStart}
/>
<DraggableItem
title="커스텀 상태 카드"
type="widget"
subtype="status-summary"
onDragStart={handleDragStart}
/>
</div>
)}
</div>
{/* 운영/작업 지원 섹션 */}
<div className="mb-5">
<button
onClick={() => toggleSection("operations")}
className="mb-3 flex w-full items-center justify-between px-1 py-2.5 text-xl font-bold text-foreground transition-colors hover:text-primary"
>
<span>/ </span>
{expandedSections.operations ? (
<ChevronDown className="h-5 w-5 text-muted-foreground transition-transform" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground transition-transform" />
)}
</button>
{expandedSections.operations && (
<div className="space-y-2">
<DraggableItem
title="To-Do / 긴급 지시"
type="widget"
subtype="todo"
onDragStart={handleDragStart}
/>
{/* 예약알림 위젯 - 필요시 주석 해제 */}
{/* <DraggableItem
title="예약 요청 알림"
type="widget"
subtype="booking-alert"
onDragStart={handleDragStart}
/> */}
{/* 정비 일정 관리 위젯 제거 - 커스텀 목록 카드로 대체 가능 */}
<DraggableItem
title="문서 다운로드"
type="widget"
subtype="document"
onDragStart={handleDragStart}
/>
<DraggableItem
title="리스트 위젯"
type="widget"
subtype="list"
onDragStart={handleDragStart}
/>
<DraggableItem
title="작업 이력"
type="widget"
subtype="work-history"
onDragStart={handleDragStart}
/>
<DraggableItem
title="커스텀 통계 카드"
type="widget"
subtype="transport-stats"
onDragStart={handleDragStart}
/>
</div>
)}
</div>
</div>
);
}
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 (
<div
draggable
className="cursor-move rounded-md border border-border bg-card px-4 py-2.5 text-sm font-medium text-card-foreground transition-all duration-150 hover:border-primary hover:bg-accent"
onDragStart={(e) => onDragStart(e, type, subtype)}
>
{title}
</div>
);
}

View File

@ -181,12 +181,11 @@ export function DashboardTopMenu({
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="list"> </SelectItem>
<SelectItem value="custom-metric"> </SelectItem>
<SelectItem value="yard-management-3d"> 3D</SelectItem>
<SelectItem value="transport-stats"> </SelectItem>
{/* <SelectItem value="map">지도</SelectItem> */}
{/* <SelectItem value="transport-stats">커스텀 통계 카드</SelectItem> */}
<SelectItem value="map-summary"> </SelectItem>
{/* <SelectItem value="list-summary">커스텀 목록 카드</SelectItem> */}
<SelectItem value="status-summary"> </SelectItem>
{/* <SelectItem value="status-summary">커스텀 상태 카드</SelectItem> */}
</SelectGroup>
<SelectGroup>
<SelectLabel> </SelectLabel>

View File

@ -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 (
<CustomMetricConfigSidebar
element={element}
isOpen={isOpen}
onClose={onClose}
onApply={(updates) => {
onApply({ ...element, ...updates });
}}
/>
);
}
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
const isSimpleWidget =
element.subtype === "todo" ||

View File

@ -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; // 소수점 자릿수
}>;
}

View File

@ -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<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 [customTitle, setCustomTitle] = useState<string>(element.customTitle || element.title || "");
const [showHeader, setShowHeader] = useState<boolean>(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<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({
customTitle: customTitle,
showHeader: showHeader,
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="space-y-2">
{/* 제목 입력 */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-gray-500"></label>
<Input
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder="위젯 제목을 입력하세요"
className="h-8 text-xs"
style={{ fontSize: "12px" }}
/>
</div>
{/* 헤더 표시 여부 */}
<div className="flex items-center justify-between">
<label className="text-[9px] font-medium text-gray-500"> </label>
<button
onClick={() => setShowHeader(!showHeader)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
showHeader ? "bg-primary" : "bg-gray-300"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
showHeader ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
</div>
</div>
</div>
{/* 데이터 소스 타입 선택 */}
<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-16 items-center justify-center 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-sm font-medium"></span>
</button>
<button
onClick={() => handleDataSourceTypeChange("api")}
className={`flex h-16 items-center justify-center 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-sm 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>
);
}

View File

@ -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 <CalendarWidget element={element} />;
case "status-summary":
return <StatusSummaryWidget element={element} />;
case "custom-metric":
return <CustomMetricWidget element={element} />;
// === 운영/작업 지원 ===
case "todo":

View File

@ -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<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<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>
);
}
// 데이터 소스가 없거나 설정이 없는 경우
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 (
<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>
);
}