ERP-node/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetComponent.tsx

676 lines
22 KiB
TypeScript

"use client";
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { ComponentRendererProps } from "@/types/component";
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);
});
}
/**
* 집계 위젯 컴포넌트
* 연결된 테이블 리스트나 리피터의 데이터를 집계하여 표시
*/
export function AggregationWidgetComponent({
component,
isDesignMode = false,
config: propsConfig,
externalData,
formData = {},
selectedRows = [],
}: AggregationWidgetComponentProps) {
// 다국어 지원
const { getText } = useScreenMultiLang();
const componentConfig: AggregationWidgetConfig = {
dataSourceType: "table",
items: [],
layout: "horizontal",
showLabels: true,
showIcons: true,
gap: "16px",
...propsConfig,
...component?.config,
};
// 다국어 라벨 가져오기
const getItemLabel = (item: AggregationItem): string => {
if (item.labelLangKey) {
const translated = getText(item.labelLangKey);
if (translated && translated !== item.labelLangKey) {
return translated;
}
}
return item.columnLabel || item.columnName || "컬럼";
};
const {
dataSourceType,
dataSourceComponentId,
tableName,
customTableName,
useCustomTable,
filters,
filterLogic = "AND",
items,
layout,
showLabels,
showIcons,
gap,
backgroundColor,
borderRadius,
padding,
fontSize,
labelFontSize,
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"일 때)
// props로 전달된 selectedRows 사용
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]);
// 전역 선택 이벤트 수신 (dataSourceType === "selection"일 때)
useEffect(() => {
if (dataSourceType !== "selection" || isDesignMode) return;
// 테이블리스트에서 발생하는 선택 이벤트 수신
// tableListDataChange 이벤트의 data가 선택된 행들임
const handleTableListDataChange = (event: CustomEvent) => {
const { data: eventData, selectedRows: eventSelectedRows } = event.detail || {};
// data가 선택된 행 데이터 배열
const rows = eventData || [];
if (Array.isArray(rows)) {
// 필터 적용
const filteredData = applyFilters(
rows,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
};
// 리피터에서 발생하는 이벤트
const handleRepeaterDataChange = (event: CustomEvent) => {
const { data: eventData, selectedData } = event.detail || {};
const rows = selectedData || eventData || [];
if (Array.isArray(rows)) {
const filteredData = applyFilters(
rows,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
};
// 일반 선택 이벤트
const handleSelectionChange = (event: CustomEvent) => {
const { selectedRows: eventSelectedRows, selectedData, checkedRows, selectedItems } = event.detail || {};
const rows = selectedData || eventSelectedRows || checkedRows || selectedItems || [];
if (Array.isArray(rows)) {
const filteredData = applyFilters(
rows,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
};
// 다양한 선택 이벤트 수신
window.addEventListener("tableListDataChange" as any, handleTableListDataChange);
window.addEventListener("repeaterDataChange" as any, handleRepeaterDataChange);
window.addEventListener("selectionChange" as any, handleSelectionChange);
window.addEventListener("tableSelectionChange" as any, handleSelectionChange);
window.addEventListener("rowSelectionChange" as any, handleSelectionChange);
window.addEventListener("checkboxSelectionChange" as any, handleSelectionChange);
return () => {
window.removeEventListener("tableListDataChange" as any, handleTableListDataChange);
window.removeEventListener("repeaterDataChange" as any, handleRepeaterDataChange);
window.removeEventListener("selectionChange" as any, handleSelectionChange);
window.removeEventListener("tableSelectionChange" as any, handleSelectionChange);
window.removeEventListener("rowSelectionChange" as any, handleSelectionChange);
window.removeEventListener("checkboxSelectionChange" as any, handleSelectionChange);
};
}, [dataSourceType, isDesignMode, filterLogic]);
// 외부 데이터가 있으면 사용
const externalDataKey = externalData ? JSON.stringify(externalData.slice(0, 5)) : null; // 첫 5개만 비교
useEffect(() => {
if (externalData && Array.isArray(externalData)) {
// 필터 적용
const filteredData = applyFilters(
externalData,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalDataKey, filterLogic]);
// 컴포넌트 데이터 변경 이벤트 리스닝 (dataSourceType === "component"일 때)
useEffect(() => {
if (dataSourceType !== "component" || !dataSourceComponentId || isDesignMode) return;
const handleDataChange = (event: CustomEvent) => {
const { componentId, data: eventData } = event.detail || {};
if (componentId === dataSourceComponentId && Array.isArray(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);
}
};
// 리피터 데이터 변경 이벤트
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);
};
}, [dataSourceType, dataSourceComponentId, isDesignMode, filterLogic]);
// 집계 계산
const aggregationResults = useMemo((): AggregationResult[] => {
if (!items || items.length === 0) {
return [];
}
return items.map((item) => {
const values = data
.map((row) => {
const val = row[item.columnName];
const parsed = typeof val === "number" ? val : parseFloat(val) || 0;
return parsed;
})
.filter((v) => !isNaN(v));
let value: number = 0;
switch (item.type) {
case "sum":
value = values.reduce((acc, v) => acc + v, 0);
break;
case "avg":
value = values.length > 0 ? values.reduce((acc, v) => acc + v, 0) / values.length : 0;
break;
case "count":
value = data.length;
break;
case "max":
value = values.length > 0 ? Math.max(...values) : 0;
break;
case "min":
value = values.length > 0 ? Math.min(...values) : 0;
break;
}
// 포맷팅
let formattedValue = value.toFixed(item.decimalPlaces ?? 0);
if (item.format === "currency") {
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
} else if (item.format === "percent") {
formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`;
} else if (item.format === "number") {
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
}
if (item.prefix) {
formattedValue = `${item.prefix}${formattedValue}`;
}
if (item.suffix) {
formattedValue = `${formattedValue}${item.suffix}`;
}
return {
id: item.id,
label: getItemLabel(item),
value,
formattedValue,
type: item.type,
};
});
}, [data, items, getText]);
// 집계 타입에 따른 아이콘
const getIcon = (type: AggregationType) => {
switch (type) {
case "sum":
return <Calculator className="h-4 w-4" />;
case "avg":
return <TrendingUp className="h-4 w-4" />;
case "count":
return <Hash className="h-4 w-4" />;
case "max":
return <ArrowUp className="h-4 w-4" />;
case "min":
return <ArrowDown className="h-4 w-4" />;
}
};
// 집계 타입 라벨
const getTypeLabel = (type: AggregationType) => {
switch (type) {
case "sum":
return "합계";
case "avg":
return "평균";
case "count":
return "개수";
case "max":
return "최대";
case "min":
return "최소";
}
};
// 데이터 소스 타입 라벨
const getDataSourceLabel = (type: DataSourceType) => {
switch (type) {
case "table":
return "테이블";
case "component":
return "컴포넌트";
case "selection":
return "선택 데이터";
default:
return "수동";
}
};
// 디자인 모드 미리보기
if (isDesignMode) {
const previewItems: AggregationResult[] =
items.length > 0
? items.map((item) => ({
id: item.id,
label: getItemLabel(item),
value: 0,
formattedValue: item.prefix ? `${item.prefix}0${item.suffix || ""}` : `0${item.suffix || ""}`,
type: item.type,
}))
: [
{ id: "1", label: "총 수량", value: 150, formattedValue: "150", type: "sum" },
{ id: "2", label: "총 금액", value: 1500000, formattedValue: "₩1,500,000", type: "sum" },
{ id: "3", label: "건수", value: 5, formattedValue: "5건", type: "count" },
];
return (
<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" : ""
)}
>
{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>
);
}
// 실제 렌더링
if (aggregationResults.length === 0) {
return (
<div className="flex items-center justify-center rounded-md border border-dashed bg-slate-50 p-4 text-sm text-muted-foreground">
</div>
);
}
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,
}}
>
{aggregationResults.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 }}
>
{result.formattedValue}
</span>
</div>
))}
</div>
);
}
export const AggregationWidgetWrapper = AggregationWidgetComponent;