집계 위젯 필터링 기능 추가: AggregationWidgetComponent와 AggregationWidgetConfigPanel에서 필터 조건을 적용하여 데이터를 필터링할 수 있는 기능을 구현하였습니다. 필터 조건 추가, 수정, 삭제 기능을 포함하여 다양한 데이터 소스에서 필터링을 지원하도록 개선하였습니다. 또한, 필터 연산자 및 값 소스 타입에 대한 라벨을 추가하여 사용자 경험을 향상시켰습니다.
This commit is contained in:
@@ -1,16 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType } from "./types";
|
||||
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType, FilterCondition, DataSourceType } from "./types";
|
||||
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface AggregationWidgetComponentProps extends ComponentRendererProps {
|
||||
config?: AggregationWidgetConfig;
|
||||
// 외부에서 데이터를 직접 전달받을 수 있음
|
||||
externalData?: any[];
|
||||
// 폼 데이터 (필터 조건용)
|
||||
formData?: Record<string, any>;
|
||||
// 선택된 행 데이터
|
||||
selectedRows?: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 조건을 적용하여 데이터 필터링
|
||||
*/
|
||||
function applyFilters(
|
||||
data: any[],
|
||||
filters: FilterCondition[],
|
||||
filterLogic: "AND" | "OR",
|
||||
formData: Record<string, any>,
|
||||
selectedRows: any[]
|
||||
): any[] {
|
||||
if (!filters || filters.length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const enabledFilters = filters.filter((f) => f.enabled && f.columnName);
|
||||
|
||||
if (enabledFilters.length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return data.filter((row) => {
|
||||
const results = enabledFilters.map((filter) => {
|
||||
const rowValue = row[filter.columnName];
|
||||
|
||||
// 값 소스에 따라 비교 값 결정
|
||||
let compareValue: any;
|
||||
switch (filter.valueSourceType) {
|
||||
case "static":
|
||||
compareValue = filter.staticValue;
|
||||
break;
|
||||
case "formField":
|
||||
compareValue = formData?.[filter.formFieldName || ""];
|
||||
break;
|
||||
case "selection":
|
||||
// 선택된 행에서 값 가져오기 (첫 번째 선택 행 기준)
|
||||
compareValue = selectedRows?.[0]?.[filter.sourceColumnName || ""];
|
||||
break;
|
||||
case "urlParam":
|
||||
if (typeof window !== "undefined") {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
compareValue = urlParams.get(filter.urlParamName || "");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 연산자에 따른 비교
|
||||
switch (filter.operator) {
|
||||
case "eq":
|
||||
return rowValue == compareValue;
|
||||
case "neq":
|
||||
return rowValue != compareValue;
|
||||
case "gt":
|
||||
return Number(rowValue) > Number(compareValue);
|
||||
case "gte":
|
||||
return Number(rowValue) >= Number(compareValue);
|
||||
case "lt":
|
||||
return Number(rowValue) < Number(compareValue);
|
||||
case "lte":
|
||||
return Number(rowValue) <= Number(compareValue);
|
||||
case "like":
|
||||
return String(rowValue || "").toLowerCase().includes(String(compareValue || "").toLowerCase());
|
||||
case "in":
|
||||
const inValues = String(compareValue || "").split(",").map((v) => v.trim());
|
||||
return inValues.includes(String(rowValue));
|
||||
case "isNull":
|
||||
return rowValue === null || rowValue === undefined || rowValue === "";
|
||||
case "isNotNull":
|
||||
return rowValue !== null && rowValue !== undefined && rowValue !== "";
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// AND/OR 논리 적용
|
||||
return filterLogic === "AND" ? results.every((r) => r) : results.some((r) => r);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,12 +105,14 @@ export function AggregationWidgetComponent({
|
||||
isDesignMode = false,
|
||||
config: propsConfig,
|
||||
externalData,
|
||||
formData = {},
|
||||
selectedRows = [],
|
||||
}: AggregationWidgetComponentProps) {
|
||||
// 다국어 지원
|
||||
const { getText } = useScreenMultiLang();
|
||||
|
||||
const componentConfig: AggregationWidgetConfig = {
|
||||
dataSourceType: "manual",
|
||||
dataSourceType: "table",
|
||||
items: [],
|
||||
layout: "horizontal",
|
||||
showLabels: true,
|
||||
@@ -51,6 +136,11 @@ export function AggregationWidgetComponent({
|
||||
const {
|
||||
dataSourceType,
|
||||
dataSourceComponentId,
|
||||
tableName,
|
||||
customTableName,
|
||||
useCustomTable,
|
||||
filters,
|
||||
filterLogic = "AND",
|
||||
items,
|
||||
layout,
|
||||
showLabels,
|
||||
@@ -64,26 +154,169 @@ export function AggregationWidgetComponent({
|
||||
valueFontSize,
|
||||
labelColor,
|
||||
valueColor,
|
||||
autoRefresh,
|
||||
refreshInterval,
|
||||
refreshOnFormChange,
|
||||
} = componentConfig;
|
||||
|
||||
// 데이터 상태
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 사용할 테이블명 결정
|
||||
const effectiveTableName = useCustomTable && customTableName ? customTableName : tableName;
|
||||
|
||||
// Refs로 최신 값 참조 (의존성 배열에서 제외하여 무한 루프 방지)
|
||||
const filtersRef = React.useRef(filters);
|
||||
const formDataRef = React.useRef(formData);
|
||||
const selectedRowsRef = React.useRef(selectedRows);
|
||||
|
||||
// 값이 변경될 때마다 ref 업데이트
|
||||
React.useEffect(() => {
|
||||
filtersRef.current = filters;
|
||||
}, [filters]);
|
||||
|
||||
React.useEffect(() => {
|
||||
formDataRef.current = formData;
|
||||
}, [formData]);
|
||||
|
||||
React.useEffect(() => {
|
||||
selectedRowsRef.current = selectedRows;
|
||||
}, [selectedRows]);
|
||||
|
||||
// 테이블에서 데이터 조회 (dataSourceType === "table"일 때)
|
||||
const fetchTableData = useCallback(async () => {
|
||||
if (isDesignMode || !effectiveTableName || dataSourceType !== "table") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 테이블 데이터 조회 API 호출
|
||||
// 멀티테넌시: company_code 자동 필터링 활성화
|
||||
const response = await apiClient.post(`/table-management/tables/${effectiveTableName}/data`, {
|
||||
size: 10000, // 집계용이므로 충분한 데이터 조회
|
||||
page: 1,
|
||||
autoFilter: {
|
||||
enabled: true,
|
||||
filterColumn: "company_code",
|
||||
userField: "companyCode",
|
||||
},
|
||||
});
|
||||
|
||||
// 응답 구조: { success: true, data: { data: [...], total: ... } }
|
||||
const raw = response.data?.data || response.data;
|
||||
const rows = raw?.data || raw || [];
|
||||
|
||||
if (Array.isArray(rows)) {
|
||||
// 필터 적용
|
||||
const filteredData = applyFilters(
|
||||
rows,
|
||||
filtersRef.current || [],
|
||||
filterLogic,
|
||||
formDataRef.current,
|
||||
selectedRowsRef.current
|
||||
);
|
||||
setData(filteredData);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("집계 위젯 데이터 조회 오류:", err);
|
||||
setError(err.message || "데이터 조회 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [effectiveTableName, dataSourceType, isDesignMode, filterLogic]);
|
||||
|
||||
// 테이블 데이터 조회 (초기 로드)
|
||||
useEffect(() => {
|
||||
if (dataSourceType === "table" && effectiveTableName && !isDesignMode) {
|
||||
fetchTableData();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataSourceType, effectiveTableName, isDesignMode]);
|
||||
|
||||
// 폼 데이터 변경 시 재조회 (refreshOnFormChange가 true일 때)
|
||||
const formDataKey = JSON.stringify(formData);
|
||||
useEffect(() => {
|
||||
if (dataSourceType === "table" && refreshOnFormChange && !isDesignMode && effectiveTableName) {
|
||||
// 초기 로드 후에만 재조회
|
||||
const timeoutId = setTimeout(() => {
|
||||
fetchTableData();
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formDataKey, refreshOnFormChange]);
|
||||
|
||||
// 자동 새로고침
|
||||
useEffect(() => {
|
||||
if (dataSourceType === "table" && autoRefresh && refreshInterval && !isDesignMode) {
|
||||
const interval = setInterval(fetchTableData, refreshInterval * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [dataSourceType, autoRefresh, refreshInterval, isDesignMode, fetchTableData]);
|
||||
|
||||
// 선택된 행 집계 (dataSourceType === "selection"일 때)
|
||||
const selectedRowsKey = JSON.stringify(selectedRows);
|
||||
useEffect(() => {
|
||||
if (dataSourceType === "selection" && Array.isArray(selectedRows) && selectedRows.length > 0) {
|
||||
setData(selectedRows);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataSourceType, selectedRowsKey]);
|
||||
|
||||
// 외부 데이터가 있으면 사용
|
||||
const externalDataKey = externalData ? JSON.stringify(externalData.slice(0, 5)) : null; // 첫 5개만 비교
|
||||
useEffect(() => {
|
||||
if (externalData && Array.isArray(externalData)) {
|
||||
setData(externalData);
|
||||
// 필터 적용
|
||||
const filteredData = applyFilters(
|
||||
externalData,
|
||||
filtersRef.current || [],
|
||||
filterLogic,
|
||||
formDataRef.current,
|
||||
selectedRowsRef.current
|
||||
);
|
||||
setData(filteredData);
|
||||
}
|
||||
}, [externalData]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [externalDataKey, filterLogic]);
|
||||
|
||||
// 컴포넌트 데이터 변경 이벤트 리스닝
|
||||
// 컴포넌트 데이터 변경 이벤트 리스닝 (dataSourceType === "component"일 때)
|
||||
useEffect(() => {
|
||||
if (!dataSourceComponentId || isDesignMode) return;
|
||||
if (dataSourceType !== "component" || !dataSourceComponentId || isDesignMode) return;
|
||||
|
||||
const handleDataChange = (event: CustomEvent) => {
|
||||
const { componentId, data: eventData } = event.detail || {};
|
||||
if (componentId === dataSourceComponentId && Array.isArray(eventData)) {
|
||||
setData(eventData);
|
||||
// 필터 적용
|
||||
const filteredData = applyFilters(
|
||||
eventData,
|
||||
filtersRef.current || [],
|
||||
filterLogic,
|
||||
formDataRef.current,
|
||||
selectedRowsRef.current
|
||||
);
|
||||
setData(filteredData);
|
||||
}
|
||||
};
|
||||
|
||||
// 선택 변경 이벤트 (체크박스 선택 등)
|
||||
const handleSelectionChange = (event: CustomEvent) => {
|
||||
const { componentId, selectedData } = event.detail || {};
|
||||
if (componentId === dataSourceComponentId && Array.isArray(selectedData)) {
|
||||
// 선택된 데이터만 집계
|
||||
const filteredData = applyFilters(
|
||||
selectedData,
|
||||
filtersRef.current || [],
|
||||
filterLogic,
|
||||
formDataRef.current,
|
||||
selectedRowsRef.current
|
||||
);
|
||||
setData(filteredData);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,12 +324,15 @@ export function AggregationWidgetComponent({
|
||||
window.addEventListener("repeaterDataChange" as any, handleDataChange);
|
||||
// 테이블 리스트 데이터 변경 이벤트
|
||||
window.addEventListener("tableListDataChange" as any, handleDataChange);
|
||||
// 선택 변경 이벤트
|
||||
window.addEventListener("selectionChange" as any, handleSelectionChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("repeaterDataChange" as any, handleDataChange);
|
||||
window.removeEventListener("tableListDataChange" as any, handleDataChange);
|
||||
window.removeEventListener("selectionChange" as any, handleSelectionChange);
|
||||
};
|
||||
}, [dataSourceComponentId, isDesignMode]);
|
||||
}, [dataSourceType, dataSourceComponentId, isDesignMode, filterLogic]);
|
||||
|
||||
// 집계 계산
|
||||
const aggregationResults = useMemo((): AggregationResult[] => {
|
||||
@@ -108,10 +344,12 @@ export function AggregationWidgetComponent({
|
||||
const values = data
|
||||
.map((row) => {
|
||||
const val = row[item.columnName];
|
||||
return typeof val === "number" ? val : parseFloat(val) || 0;
|
||||
const parsed = typeof val === "number" ? val : parseFloat(val) || 0;
|
||||
return parsed;
|
||||
})
|
||||
.filter((v) => !isNaN(v));
|
||||
|
||||
|
||||
let value: number = 0;
|
||||
|
||||
switch (item.type) {
|
||||
@@ -192,6 +430,20 @@ export function AggregationWidgetComponent({
|
||||
}
|
||||
};
|
||||
|
||||
// 데이터 소스 타입 라벨
|
||||
const getDataSourceLabel = (type: DataSourceType) => {
|
||||
switch (type) {
|
||||
case "table":
|
||||
return "테이블";
|
||||
case "component":
|
||||
return "컴포넌트";
|
||||
case "selection":
|
||||
return "선택 데이터";
|
||||
default:
|
||||
return "수동";
|
||||
}
|
||||
};
|
||||
|
||||
// 디자인 모드 미리보기
|
||||
if (isDesignMode) {
|
||||
const previewItems: AggregationResult[] =
|
||||
@@ -210,46 +462,80 @@ export function AggregationWidgetComponent({
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-md border bg-slate-50 p-3",
|
||||
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap"
|
||||
)}
|
||||
style={{
|
||||
gap: gap || "12px",
|
||||
backgroundColor: backgroundColor || undefined,
|
||||
borderRadius: borderRadius || undefined,
|
||||
padding: padding || undefined,
|
||||
fontSize: fontSize || undefined,
|
||||
}}
|
||||
>
|
||||
{previewItems.map((result, index) => (
|
||||
<div
|
||||
key={result.id || index}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
|
||||
layout === "vertical" ? "w-full justify-between" : ""
|
||||
)}
|
||||
>
|
||||
{showIcons && (
|
||||
<span className="text-muted-foreground">{getIcon(result.type)}</span>
|
||||
)}
|
||||
{showLabels && (
|
||||
<span
|
||||
className="text-muted-foreground text-xs"
|
||||
style={{ fontSize: labelFontSize, color: labelColor }}
|
||||
>
|
||||
{result.label} ({getTypeLabel(result.type)}):
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ fontSize: valueFontSize, color: valueColor }}
|
||||
<div className="space-y-1">
|
||||
{/* 디자인 모드에서 데이터 소스 표시 */}
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<span>[{getDataSourceLabel(dataSourceType)}]</span>
|
||||
{dataSourceType === "table" && effectiveTableName && (
|
||||
<span>{effectiveTableName}</span>
|
||||
)}
|
||||
{dataSourceType === "component" && dataSourceComponentId && (
|
||||
<span>{dataSourceComponentId}</span>
|
||||
)}
|
||||
{(filters || []).length > 0 && (
|
||||
<span className="text-blue-500">필터 {filters?.length}개</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-md border bg-slate-50 p-3",
|
||||
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap"
|
||||
)}
|
||||
style={{
|
||||
gap: gap || "12px",
|
||||
backgroundColor: backgroundColor || undefined,
|
||||
borderRadius: borderRadius || undefined,
|
||||
padding: padding || undefined,
|
||||
fontSize: fontSize || undefined,
|
||||
}}
|
||||
>
|
||||
{previewItems.map((result, index) => (
|
||||
<div
|
||||
key={result.id || index}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
|
||||
layout === "vertical" ? "w-full justify-between" : ""
|
||||
)}
|
||||
>
|
||||
{result.formattedValue}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{showIcons && (
|
||||
<span className="text-muted-foreground">{getIcon(result.type)}</span>
|
||||
)}
|
||||
{showLabels && (
|
||||
<span
|
||||
className="text-muted-foreground text-xs"
|
||||
style={{ fontSize: labelFontSize, color: labelColor }}
|
||||
>
|
||||
{result.label} ({getTypeLabel(result.type)}):
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ fontSize: valueFontSize, color: valueColor }}
|
||||
>
|
||||
{result.formattedValue}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 로딩 상태
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-md border bg-slate-50 p-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">데이터 집계 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-md border border-destructive bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user