517 lines
22 KiB
TypeScript
517 lines
22 KiB
TypeScript
"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 [groupByMode, setGroupByMode] = useState<boolean>(element.customMetricConfig?.groupByMode || false);
|
|
const [groupByDataSource, setGroupByDataSource] = useState<ChartDataSource | undefined>(
|
|
element.customMetricConfig?.groupByDataSource,
|
|
);
|
|
const [groupByQueryColumns, setGroupByQueryColumns] = useState<string[]>([]);
|
|
|
|
// 쿼리 실행 결과 처리 (일반 지표용)
|
|
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 handleGroupByQueryTest = (result: any) => {
|
|
if (result.success && result.data?.columns) {
|
|
setGroupByQueryColumns(result.data.columns);
|
|
} else if (result.columns && Array.isArray(result.columns)) {
|
|
setGroupByQueryColumns(result.columns);
|
|
} else {
|
|
setGroupByQueryColumns([]);
|
|
}
|
|
};
|
|
|
|
// 메트릭 추가
|
|
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 handleGroupByDataSourceUpdate = (updates: Partial<ChartDataSource>) => {
|
|
const newDataSource = { ...groupByDataSource, ...updates } as ChartDataSource;
|
|
setGroupByDataSource(newDataSource);
|
|
};
|
|
|
|
// 저장
|
|
const handleSave = () => {
|
|
onApply({
|
|
customTitle: customTitle,
|
|
showHeader: showHeader,
|
|
customMetricConfig: {
|
|
groupByMode,
|
|
groupByDataSource: groupByMode ? groupByDataSource : undefined,
|
|
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-muted transition-transform duration-300 ease-in-out",
|
|
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
|
)}
|
|
>
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between bg-background 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-foreground">커스텀 카드 설정</span>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-muted"
|
|
>
|
|
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 본문: 스크롤 가능 영역 */}
|
|
<div className="flex-1 overflow-y-auto p-3">
|
|
<div className="space-y-3">
|
|
{/* 헤더 설정 */}
|
|
<div className="rounded-lg bg-background p-3 shadow-sm">
|
|
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">헤더 설정</div>
|
|
<div className="space-y-2">
|
|
{/* 제목 입력 */}
|
|
<div>
|
|
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground">제목</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-muted-foreground">헤더 표시</label>
|
|
<button
|
|
onClick={() => setShowHeader(!showHeader)}
|
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
|
showHeader ? "bg-primary" : "bg-muted"
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
|
showHeader ? "translate-x-5" : "translate-x-0.5"
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 데이터 소스 타입 선택 */}
|
|
<div className="rounded-lg bg-background p-3 shadow-sm">
|
|
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground 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-border bg-muted text-foreground hover:border-border"
|
|
}`}
|
|
>
|
|
<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-border bg-muted text-foreground hover:border-border"
|
|
}`}
|
|
>
|
|
<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} />
|
|
)}
|
|
|
|
{/* 일반 지표 설정 (항상 표시) */}
|
|
<div className="rounded-lg bg-background p-3 shadow-sm">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<div className="text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">일반 지표</div>
|
|
{queryColumns.length > 0 && (
|
|
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={addMetric}>
|
|
<Plus className="h-3 w-3" />
|
|
추가
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{queryColumns.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">먼저 쿼리를 실행하세요</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{metrics.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">추가된 지표가 없습니다</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-background 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-muted-foreground" />
|
|
</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-foreground">
|
|
{metric.label || "새 지표"}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">{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-muted"
|
|
>
|
|
{expandedMetric === metric.id ? (
|
|
<ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 설정 영역 */}
|
|
{expandedMetric === metric.id && (
|
|
<div className="mt-2 space-y-1.5 border-t border-border pt-2">
|
|
{/* 2열 그리드 레이아웃 */}
|
|
<div className="grid grid-cols-2 gap-1.5">
|
|
{/* 컬럼 */}
|
|
<div>
|
|
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground">컬럼</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-muted-foreground">집계</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-muted-foreground">단위</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-muted-foreground">소수점</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-muted-foreground">표시 이름</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-border 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 className="rounded-lg bg-background p-3 shadow-sm">
|
|
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">표시 모드</div>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<label className="text-xs font-medium text-foreground">그룹별 카드 생성</label>
|
|
<p className="mt-0.5 text-[9px] text-muted-foreground">
|
|
쿼리 결과의 각 행을 개별 카드로 표시
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setGroupByMode(!groupByMode);
|
|
if (!groupByMode && !groupByDataSource) {
|
|
// 그룹별 모드 활성화 시 기본 데이터 소스 초기화
|
|
setGroupByDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
|
|
}
|
|
}}
|
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
|
groupByMode ? "bg-primary" : "bg-muted"
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
|
groupByMode ? "translate-x-5" : "translate-x-0.5"
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
{groupByMode && (
|
|
<div className="rounded-md bg-primary/10 p-2 text-[9px] text-primary">
|
|
<p className="font-medium">💡 사용 방법</p>
|
|
<ul className="mt-1 space-y-0.5 pl-3 text-[8px]">
|
|
<li>• 첫 번째 컬럼: 카드 제목</li>
|
|
<li>• 두 번째 컬럼: 카드 값</li>
|
|
<li>• 예: SELECT status, COUNT(*) FROM drivers GROUP BY status</li>
|
|
<li>• <strong>아래 별도 쿼리로 설정</strong> (일반 지표와 독립적)</li>
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 그룹별 카드 전용 쿼리 (활성화 시에만 표시) */}
|
|
{groupByMode && groupByDataSource && (
|
|
<div className="rounded-lg bg-background p-3 shadow-sm">
|
|
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">
|
|
그룹별 카드 쿼리
|
|
</div>
|
|
<DatabaseConfig dataSource={groupByDataSource} onChange={handleGroupByDataSourceUpdate} />
|
|
<QueryEditor
|
|
dataSource={groupByDataSource}
|
|
onDataSourceChange={handleGroupByDataSourceUpdate}
|
|
onQueryTest={handleGroupByQueryTest}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 푸터 */}
|
|
<div className="flex gap-2 border-t bg-background 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>
|
|
);
|
|
}
|