통계 카드 작동하도록 고침
This commit is contained in:
parent
6d9c7ed7bf
commit
21f4f30859
|
|
@ -144,7 +144,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
const [chartConfig, setChartConfig] = useState<ChartConfig>({});
|
||||
|
||||
// 커스텀 메트릭 설정
|
||||
const [customMetricConfig, setCustomMetricConfig] = useState<CustomMetricConfig>({ metrics: [] });
|
||||
const [customMetricConfig, setCustomMetricConfig] = useState<CustomMetricConfig>({});
|
||||
|
||||
// 사이드바 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
|
|
@ -175,7 +175,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
setChartConfig(element.chartConfig || {});
|
||||
|
||||
// 커스텀 메트릭 설정 초기화
|
||||
setCustomMetricConfig(element.customMetricConfig || { metrics: [] });
|
||||
setCustomMetricConfig(element.customMetricConfig || {});
|
||||
} else if (!isOpen) {
|
||||
// 사이드바 닫힐 때 초기화
|
||||
setCustomTitle("");
|
||||
|
|
@ -194,7 +194,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
cardColumns: 3,
|
||||
});
|
||||
setChartConfig({});
|
||||
setCustomMetricConfig({ metrics: [] });
|
||||
setCustomMetricConfig({});
|
||||
}
|
||||
}, [isOpen, element]);
|
||||
|
||||
|
|
@ -336,6 +336,12 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
: {}),
|
||||
};
|
||||
|
||||
console.log("🔧 [WidgetConfigSidebar] handleApply 호출:", {
|
||||
subtype: element.subtype,
|
||||
customMetricConfig,
|
||||
updatedElement,
|
||||
});
|
||||
|
||||
onApply(updatedElement);
|
||||
onClose();
|
||||
}, [
|
||||
|
|
@ -431,9 +437,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
<TabsContent value="data" className="mt-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-3">
|
||||
{/* 데이터 소스 선택 - 단일 데이터 소스 위젯에만 표시 */}
|
||||
{!["map-summary-v2", "chart", "list-v2", "custom-metric-v2", "risk-alert-v2"].includes(
|
||||
element.subtype,
|
||||
) && (
|
||||
{!["map-summary-v2", "chart", "risk-alert-v2"].includes(element.subtype) && (
|
||||
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||
<Label className="mb-2 block text-xs font-semibold">데이터 소스</Label>
|
||||
|
||||
|
|
@ -478,9 +482,9 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
)}
|
||||
|
||||
{/* 다중 데이터 소스 설정 */}
|
||||
{["map-summary-v2", "chart", "list-v2", "custom-metric-v2", "risk-alert-v2"].includes(
|
||||
element.subtype,
|
||||
) && <MultiDataSourceConfig dataSources={dataSources} onChange={handleDataSourcesChange} />}
|
||||
{["map-summary-v2", "chart", "risk-alert-v2"].includes(element.subtype) && (
|
||||
<MultiDataSourceConfig dataSources={dataSources} onChange={handleDataSourcesChange} />
|
||||
)}
|
||||
|
||||
{/* 위젯별 커스텀 섹션 */}
|
||||
{element.subtype === "list-v2" && (
|
||||
|
|
|
|||
|
|
@ -380,15 +380,18 @@ export interface YardManagementConfig {
|
|||
|
||||
// 사용자 커스텀 카드 설정
|
||||
export interface CustomMetricConfig {
|
||||
groupByMode?: boolean; // 그룹별 카드 생성 모드 (기본: false)
|
||||
groupByDataSource?: ChartDataSource; // 그룹별 카드 전용 데이터 소스 (선택사항)
|
||||
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; // 소수점 자릿수
|
||||
// 단일 통계 카드 설정
|
||||
valueColumn?: string; // 계산할 컬럼명
|
||||
aggregation?: "sum" | "avg" | "count" | "min" | "max"; // 계산 방식
|
||||
title?: string; // 카드 제목
|
||||
unit?: string; // 표시 단위 (원, 건, % 등)
|
||||
color?: "indigo" | "green" | "blue" | "purple" | "orange" | "gray"; // 카드 색상
|
||||
decimals?: number; // 소수점 자릿수 (기본: 0)
|
||||
|
||||
// 필터 조건
|
||||
filters?: Array<{
|
||||
column: string; // 필터 컬럼명
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "contains" | "not_contains"; // 조건 연산자
|
||||
value: string; // 비교값
|
||||
}>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import React from "react";
|
|||
import { CustomMetricConfig, QueryResult } from "../types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { AlertCircle, Plus, X } from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface CustomMetricSectionProps {
|
||||
queryResult: QueryResult | null;
|
||||
|
|
@ -14,36 +17,244 @@ interface CustomMetricSectionProps {
|
|||
|
||||
/**
|
||||
* 통계 카드 설정 섹션
|
||||
* - 메트릭 설정, 아이콘, 색상
|
||||
*
|
||||
* TODO: 상세 설정 UI 추가 필요
|
||||
* - 쿼리 결과를 받아서 어떻게 통계를 낼지 설정
|
||||
* - 컬럼 선택, 계산 방식(합계/평균/개수 등), 표시 방식
|
||||
* - 필터 조건 추가 가능
|
||||
*/
|
||||
export function CustomMetricSection({ queryResult, config, onConfigChange }: CustomMetricSectionProps) {
|
||||
// 쿼리 결과가 없으면 안내 메시지 표시
|
||||
console.log("⚙️ [CustomMetricSection] 렌더링:", { config, queryResult });
|
||||
|
||||
// 초기값 설정 (aggregation이 없으면 기본값 "sum" 설정)
|
||||
React.useEffect(() => {
|
||||
if (queryResult && queryResult.columns && queryResult.columns.length > 0 && !config.aggregation) {
|
||||
console.log("🔧 기본 aggregation 설정: sum");
|
||||
onConfigChange({ aggregation: "sum" });
|
||||
}
|
||||
}, [queryResult, config.aggregation, onConfigChange]);
|
||||
|
||||
// 쿼리 결과가 없으면 안내 메시지
|
||||
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||
<Label className="mb-2 block text-xs font-semibold">통계 카드 설정</Label>
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요.
|
||||
먼저 데이터 소스 탭에서 쿼리를 실행하고 결과를 확인해주세요.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 필터 추가
|
||||
const addFilter = () => {
|
||||
const newFilters = [
|
||||
...(config.filters || []),
|
||||
{ column: queryResult.columns[0] || "", operator: "=" as const, value: "" },
|
||||
];
|
||||
onConfigChange({ filters: newFilters });
|
||||
};
|
||||
|
||||
// 필터 제거
|
||||
const removeFilter = (index: number) => {
|
||||
const newFilters = [...(config.filters || [])];
|
||||
newFilters.splice(index, 1);
|
||||
onConfigChange({ filters: newFilters });
|
||||
};
|
||||
|
||||
// 필터 업데이트
|
||||
const updateFilter = (index: number, field: string, value: string) => {
|
||||
const newFilters = [...(config.filters || [])];
|
||||
newFilters[index] = { ...newFilters[index], [field]: value };
|
||||
onConfigChange({ filters: newFilters });
|
||||
};
|
||||
|
||||
// 통계 설정
|
||||
return (
|
||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||
<Label className="mb-2 block text-xs font-semibold">통계 카드 설정</Label>
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
통계 카드 상세 설정 UI는 추후 추가 예정입니다.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="bg-background space-y-4 rounded-lg p-3 shadow-sm">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-semibold">통계 카드 설정</Label>
|
||||
<p className="text-muted-foreground text-xs">쿼리 결과를 바탕으로 통계를 계산하고 표시합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 1. 필터 조건 (선택사항) */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">필터 조건 (선택사항)</Label>
|
||||
<Button onClick={addFilter} variant="outline" size="sm" className="h-7 gap-1 text-xs">
|
||||
<Plus className="h-3 w-3" />
|
||||
필터 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{config.filters && config.filters.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{config.filters.map((filter, index) => (
|
||||
<div key={index} className="bg-muted/50 flex items-center gap-2 rounded-md border p-2">
|
||||
{/* 컬럼 선택 */}
|
||||
<Select value={filter.column} onValueChange={(value) => updateFilter(index, "column", value)}>
|
||||
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{queryResult.columns.map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<Select value={filter.operator} onValueChange={(value) => updateFilter(index, "operator", value)}>
|
||||
<SelectTrigger className="h-8 w-[100px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="=" className="text-xs">
|
||||
같음 (=)
|
||||
</SelectItem>
|
||||
<SelectItem value="!=" className="text-xs">
|
||||
다름 (≠)
|
||||
</SelectItem>
|
||||
<SelectItem value=">" className="text-xs">
|
||||
큼 (>)
|
||||
</SelectItem>
|
||||
<SelectItem value="<" className="text-xs">
|
||||
작음 (<)
|
||||
</SelectItem>
|
||||
<SelectItem value=">=" className="text-xs">
|
||||
크거나 같음 (≥)
|
||||
</SelectItem>
|
||||
<SelectItem value="<=" className="text-xs">
|
||||
작거나 같음 (≤)
|
||||
</SelectItem>
|
||||
<SelectItem value="contains" className="text-xs">
|
||||
포함
|
||||
</SelectItem>
|
||||
<SelectItem value="not_contains" className="text-xs">
|
||||
미포함
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 값 입력 */}
|
||||
<Input
|
||||
value={filter.value}
|
||||
onChange={(e) => updateFilter(index, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button onClick={() => removeFilter(index)} variant="ghost" size="icon" className="h-8 w-8">
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">필터 없음 (전체 데이터 사용)</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2. 계산할 컬럼 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">계산 컬럼</Label>
|
||||
<Select value={config.valueColumn || ""} onValueChange={(value) => onConfigChange({ valueColumn: value })}>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{queryResult.columns.map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 3. 계산 방식 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">계산 방식</Label>
|
||||
<Select
|
||||
value={config.aggregation || "sum"}
|
||||
onValueChange={(value) => {
|
||||
console.log("📐 계산 방식 변경:", value);
|
||||
onConfigChange({ aggregation: value as "sum" | "avg" | "count" | "min" | "max" });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder="계산 방식" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sum" className="text-xs">
|
||||
합계 (SUM)
|
||||
</SelectItem>
|
||||
<SelectItem value="avg" className="text-xs">
|
||||
평균 (AVG)
|
||||
</SelectItem>
|
||||
<SelectItem value="count" className="text-xs">
|
||||
개수 (COUNT)
|
||||
</SelectItem>
|
||||
<SelectItem value="min" className="text-xs">
|
||||
최소값 (MIN)
|
||||
</SelectItem>
|
||||
<SelectItem value="max" className="text-xs">
|
||||
최대값 (MAX)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 4. 카드 제목 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">카드 제목</Label>
|
||||
<Input
|
||||
value={config.title || ""}
|
||||
onChange={(e) => onConfigChange({ title: e.target.value })}
|
||||
placeholder="예: 총 매출액"
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 5. 표시 단위 (선택사항) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">표시 단위 (선택사항)</Label>
|
||||
<Input
|
||||
value={config.unit || ""}
|
||||
onChange={(e) => onConfigChange({ unit: e.target.value })}
|
||||
placeholder="예: 원, 건, %"
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
{config.valueColumn && config.aggregation && (
|
||||
<div className="bg-muted/50 space-y-1 rounded-md border p-3">
|
||||
<p className="text-muted-foreground text-xs font-semibold">설정 미리보기</p>
|
||||
|
||||
{/* 필터 조건 표시 */}
|
||||
{config.filters && config.filters.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">필터:</p>
|
||||
{config.filters.map((filter, idx) => (
|
||||
<p key={idx} className="text-muted-foreground text-xs">
|
||||
· {filter.column} {filter.operator} "{filter.value}"
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 계산 표시 */}
|
||||
<p className="text-xs font-medium">
|
||||
{config.title || "통계 제목"}: {config.aggregation?.toUpperCase()}({config.valueColumn})
|
||||
{config.unit ? ` ${config.unit}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -8,6 +8,39 @@ interface CustomMetricWidgetProps {
|
|||
element?: DashboardElement;
|
||||
}
|
||||
|
||||
// 필터 적용 함수
|
||||
const applyFilters = (rows: any[], filters?: Array<{ column: string; operator: string; value: string }>): any[] => {
|
||||
if (!filters || filters.length === 0) return rows;
|
||||
|
||||
return rows.filter((row) => {
|
||||
return filters.every((filter) => {
|
||||
const cellValue = String(row[filter.column] || "");
|
||||
const filterValue = filter.value;
|
||||
|
||||
switch (filter.operator) {
|
||||
case "=":
|
||||
return cellValue === filterValue;
|
||||
case "!=":
|
||||
return cellValue !== filterValue;
|
||||
case ">":
|
||||
return parseFloat(cellValue) > parseFloat(filterValue);
|
||||
case "<":
|
||||
return parseFloat(cellValue) < parseFloat(filterValue);
|
||||
case ">=":
|
||||
return parseFloat(cellValue) >= parseFloat(filterValue);
|
||||
case "<=":
|
||||
return parseFloat(cellValue) <= parseFloat(filterValue);
|
||||
case "contains":
|
||||
return cellValue.includes(filterValue);
|
||||
case "not_contains":
|
||||
return !cellValue.includes(filterValue);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 집계 함수 실행
|
||||
const calculateMetric = (rows: any[], field: string, aggregation: string): number => {
|
||||
if (rows.length === 0) return 0;
|
||||
|
|
@ -33,22 +66,12 @@ const calculateMetric = (rows: any[], field: string, aggregation: string): numbe
|
|||
}
|
||||
};
|
||||
|
||||
// 색상 스타일 매핑
|
||||
const colorMap = {
|
||||
indigo: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
green: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
blue: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
purple: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
orange: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
gray: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
};
|
||||
|
||||
export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) {
|
||||
const [metrics, setMetrics] = useState<any[]>([]);
|
||||
const [groupedCards, setGroupedCards] = useState<Array<{ label: string; value: number }>>([]);
|
||||
const [value, setValue] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isGroupByMode = element?.customMetricConfig?.groupByMode || false;
|
||||
|
||||
const config = element?.customMetricConfig;
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
|
@ -64,14 +87,111 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 그룹별 카드 데이터 로드
|
||||
if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) {
|
||||
await loadGroupByData();
|
||||
}
|
||||
const dataSourceType = element?.dataSource?.type;
|
||||
|
||||
// 일반 지표 데이터 로드
|
||||
if (element?.customMetricConfig?.metrics && element?.customMetricConfig.metrics.length > 0) {
|
||||
await loadMetricsData();
|
||||
// Database 타입
|
||||
if (dataSourceType === "database") {
|
||||
if (!element?.dataSource?.query) {
|
||||
setValue(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch(getApiUrl("/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 as any).connectionId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("데이터 로딩 실패");
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data?.rows) {
|
||||
let rows = result.data.rows;
|
||||
|
||||
// 필터 적용
|
||||
if (config?.filters && config.filters.length > 0) {
|
||||
rows = applyFilters(rows, config.filters);
|
||||
}
|
||||
|
||||
// 집계 계산
|
||||
if (config?.valueColumn && config?.aggregation) {
|
||||
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
|
||||
setValue(calculatedValue);
|
||||
} else {
|
||||
setValue(0);
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.message || "데이터 로드 실패");
|
||||
}
|
||||
}
|
||||
// API 타입
|
||||
else if (dataSourceType === "api") {
|
||||
if (!element?.dataSource?.endpoint) {
|
||||
setValue(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
method: (element.dataSource as any).method || "GET",
|
||||
url: element.dataSource.endpoint,
|
||||
headers: (element.dataSource as any).headers || {},
|
||||
body: (element.dataSource as any).body,
|
||||
authType: (element.dataSource as any).authType,
|
||||
authConfig: (element.dataSource as any).authConfig,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("API 호출 실패");
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
let rows: any[] = [];
|
||||
|
||||
// API 응답 데이터 구조 확인 및 처리
|
||||
if (Array.isArray(result.data)) {
|
||||
rows = result.data;
|
||||
} else if (result.data.results && Array.isArray(result.data.results)) {
|
||||
rows = result.data.results;
|
||||
} else if (result.data.items && Array.isArray(result.data.items)) {
|
||||
rows = result.data.items;
|
||||
} else if (result.data.data && Array.isArray(result.data.data)) {
|
||||
rows = result.data.data;
|
||||
} else {
|
||||
rows = [result.data];
|
||||
}
|
||||
|
||||
// 필터 적용
|
||||
if (config?.filters && config.filters.length > 0) {
|
||||
rows = applyFilters(rows, config.filters);
|
||||
}
|
||||
|
||||
// 집계 계산
|
||||
if (config?.valueColumn && config?.aggregation) {
|
||||
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
|
||||
setValue(calculatedValue);
|
||||
} else {
|
||||
setValue(0);
|
||||
}
|
||||
} else {
|
||||
throw new Error("API 응답 형식 오류");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("데이터 로드 실패:", err);
|
||||
|
|
@ -81,221 +201,6 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
}
|
||||
};
|
||||
|
||||
// 그룹별 카드 데이터 로드
|
||||
const loadGroupByData = async () => {
|
||||
const groupByDS = element?.customMetricConfig?.groupByDataSource;
|
||||
if (!groupByDS) return;
|
||||
|
||||
const dataSourceType = groupByDS.type;
|
||||
|
||||
// Database 타입
|
||||
if (dataSourceType === "database") {
|
||||
if (!groupByDS.query) return;
|
||||
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: groupByDS.query,
|
||||
connectionType: groupByDS.connectionType || "current",
|
||||
connectionId: (groupByDS as any).connectionId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("그룹별 카드 데이터 로딩 실패");
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data?.rows) {
|
||||
const rows = result.data.rows;
|
||||
if (rows.length > 0) {
|
||||
const columns = result.data.columns || Object.keys(rows[0]);
|
||||
const labelColumn = columns[0];
|
||||
const valueColumn = columns[1];
|
||||
|
||||
const cards = rows.map((row: any) => ({
|
||||
label: String(row[labelColumn] || ""),
|
||||
value: parseFloat(row[valueColumn]) || 0,
|
||||
}));
|
||||
|
||||
setGroupedCards(cards);
|
||||
}
|
||||
}
|
||||
}
|
||||
// API 타입
|
||||
else if (dataSourceType === "api") {
|
||||
if (!groupByDS.endpoint) return;
|
||||
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
method: (groupByDS as any).method || "GET",
|
||||
url: groupByDS.endpoint,
|
||||
headers: (groupByDS as any).headers || {},
|
||||
body: (groupByDS as any).body,
|
||||
authType: (groupByDS as any).authType,
|
||||
authConfig: (groupByDS as any).authConfig,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("그룹별 카드 API 호출 실패");
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
let rows: any[] = [];
|
||||
if (Array.isArray(result.data)) {
|
||||
rows = result.data;
|
||||
} else if (result.data.results && Array.isArray(result.data.results)) {
|
||||
rows = result.data.results;
|
||||
} else if (result.data.items && Array.isArray(result.data.items)) {
|
||||
rows = result.data.items;
|
||||
} else if (result.data.data && Array.isArray(result.data.data)) {
|
||||
rows = result.data.data;
|
||||
} else {
|
||||
rows = [result.data];
|
||||
}
|
||||
|
||||
if (rows.length > 0) {
|
||||
const columns = Object.keys(rows[0]);
|
||||
const labelColumn = columns[0];
|
||||
const valueColumn = columns[1];
|
||||
|
||||
const cards = rows.map((row: any) => ({
|
||||
label: String(row[labelColumn] || ""),
|
||||
value: parseFloat(row[valueColumn]) || 0,
|
||||
}));
|
||||
|
||||
setGroupedCards(cards);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 일반 지표 데이터 로드
|
||||
const loadMetricsData = async () => {
|
||||
const dataSourceType = element?.dataSource?.type;
|
||||
|
||||
// Database 타입
|
||||
if (dataSourceType === "database") {
|
||||
if (!element?.dataSource?.query) {
|
||||
setMetrics([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch(getApiUrl("/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 as any).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([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
method: (element.dataSource as any).method || "GET",
|
||||
url: element.dataSource.endpoint,
|
||||
headers: (element.dataSource as any).headers || {},
|
||||
body: (element.dataSource as any).body,
|
||||
authType: (element.dataSource as any).authType,
|
||||
authConfig: (element.dataSource as any).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 응답 형식 오류");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background">
|
||||
|
|
@ -323,103 +228,64 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
);
|
||||
}
|
||||
|
||||
// 데이터 소스 체크
|
||||
const hasMetricsDataSource =
|
||||
// 설정 체크
|
||||
const hasDataSource =
|
||||
(element?.dataSource?.type === "database" && element?.dataSource?.query) ||
|
||||
(element?.dataSource?.type === "api" && element?.dataSource?.endpoint);
|
||||
|
||||
const hasGroupByDataSource =
|
||||
isGroupByMode &&
|
||||
element?.customMetricConfig?.groupByDataSource &&
|
||||
((element.customMetricConfig.groupByDataSource.type === "database" &&
|
||||
element.customMetricConfig.groupByDataSource.query) ||
|
||||
(element.customMetricConfig.groupByDataSource.type === "api" &&
|
||||
element.customMetricConfig.groupByDataSource.endpoint));
|
||||
const hasConfig = config?.valueColumn && config?.aggregation;
|
||||
|
||||
const hasMetricsConfig = element?.customMetricConfig?.metrics && element.customMetricConfig.metrics.length > 0;
|
||||
|
||||
// 둘 다 없으면 빈 화면 표시
|
||||
const shouldShowEmpty =
|
||||
(!hasGroupByDataSource && !hasMetricsConfig) || (!hasGroupByDataSource && !hasMetricsDataSource);
|
||||
|
||||
if (shouldShowEmpty) {
|
||||
// 설정이 없으면 안내 화면
|
||||
if (!hasDataSource || !hasConfig) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background p-4">
|
||||
<div className="max-w-xs space-y-2 text-center">
|
||||
<h3 className="text-sm font-bold text-foreground">사용자 커스텀 카드</h3>
|
||||
<h3 className="text-sm font-bold text-foreground">통계 카드</h3>
|
||||
<div className="space-y-1.5 text-xs text-foreground">
|
||||
<p className="font-medium">📊 맞춤형 지표 위젯</p>
|
||||
<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>
|
||||
<li>
|
||||
• <strong>그룹별 카드 생성 모드</strong>로 간편하게 사용 가능
|
||||
</li>
|
||||
<li>• 데이터 소스에서 쿼리를 실행합니다</li>
|
||||
<li>• 필터 조건으로 데이터를 필터링합니다</li>
|
||||
<li>• 선택한 컬럼에 집계 함수를 적용합니다</li>
|
||||
<li>• COUNT, SUM, AVG, MIN, MAX 지원</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-primary/10 p-2 text-[10px] text-primary">
|
||||
<p className="font-medium">⚙️ 설정 방법</p>
|
||||
<p className="mb-1">
|
||||
{isGroupByMode
|
||||
? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)"
|
||||
: "SQL 쿼리를 입력하고 지표를 추가하세요"}
|
||||
</p>
|
||||
{isGroupByMode && <p className="text-[9px]">💡 첫 번째 컬럼: 카드 제목, 두 번째 컬럼: 카드 값</p>}
|
||||
<p>1. 데이터 탭에서 쿼리 실행</p>
|
||||
<p>2. 필터 조건 추가 (선택사항)</p>
|
||||
<p>3. 계산 컬럼 및 방식 선택</p>
|
||||
<p>4. 제목 및 단위 입력</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 위젯 높이에 따라 레이아웃 결정 (세로 1칸이면 가로, 2칸 이상이면 세로)
|
||||
// 실제 측정된 1칸 높이: 119px
|
||||
const isHorizontalLayout = element?.size?.height && element.size.height <= 130; // 1칸 여유 (119px + 약간의 마진)
|
||||
// 소수점 자릿수 (기본: 0)
|
||||
const decimals = config?.decimals ?? 0;
|
||||
const formattedValue = value.toFixed(decimals);
|
||||
|
||||
// 통계 카드 렌더링
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full w-full overflow-hidden bg-background p-0.5 ${
|
||||
isHorizontalLayout ? "flex-row gap-0.5" : "flex-col gap-0.5"
|
||||
}`}
|
||||
>
|
||||
{/* 그룹별 카드 (활성화 시) */}
|
||||
{isGroupByMode &&
|
||||
groupedCards.map((card, index) => {
|
||||
// 색상 순환 (6가지 색상)
|
||||
const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>;
|
||||
const colorKey = colorKeys[index % colorKeys.length];
|
||||
const colors = colorMap[colorKey];
|
||||
<div className="flex h-full w-full items-center justify-center bg-background p-4">
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border bg-card p-6 text-center shadow-sm">
|
||||
{/* 제목 */}
|
||||
<div className="text-muted-foreground mb-2 text-sm font-medium">{config?.title || "통계"}</div>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`group-${index}`}
|
||||
className={`flex flex-1 flex-col items-center justify-center rounded border ${colors.bg} ${colors.border} p-0.5`}
|
||||
>
|
||||
<div className="text-[8px] leading-tight text-foreground">{card.label}</div>
|
||||
<div className={`mt-0 text-xs leading-none font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* 값 */}
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
|
||||
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
|
||||
</div>
|
||||
|
||||
{/* 일반 지표 카드 (항상 표시) */}
|
||||
{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={`flex flex-1 flex-col items-center justify-center rounded border ${colors.bg} ${colors.border} p-0.5`}
|
||||
>
|
||||
<div className="text-[8px] leading-tight text-foreground">{metric.label}</div>
|
||||
<div className={`mt-0 text-xs leading-none font-bold ${colors.text}`}>
|
||||
{formattedValue}
|
||||
<span className="ml-0 text-[8px]">{metric.unit}</span>
|
||||
</div>
|
||||
{/* 필터 표시 (디버깅용, 작게) */}
|
||||
{config?.filters && config.filters.length > 0 && (
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
필터: {config.filters.length}개 적용됨
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue