통계 카드 작동하도록 고침

This commit is contained in:
dohyeons
2025-10-31 18:27:43 +09:00
parent 6d9c7ed7bf
commit 21f4f30859
5 changed files with 641 additions and 1114 deletions

View File

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