"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"; // V2 이벤트 시스템 import { V2_EVENTS, subscribeV2Event, type TableListDataChangeDetail, type RepeaterDataChangeDetail } from "@/types/component-events"; interface AggregationWidgetComponentProps extends ComponentRendererProps { config?: AggregationWidgetConfig; // 외부에서 데이터를 직접 전달받을 수 있음 externalData?: any[]; // 폼 데이터 (필터 조건용) formData?: Record; // 선택된 행 데이터 selectedRows?: any[]; } /** * 필터 조건을 적용하여 데이터 필터링 */ function applyFilters( data: any[], filters: FilterCondition[], filterLogic: "AND" | "OR", formData: Record, 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([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(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"일 때) // V2 표준 이벤트만 사용 (중복 이벤트 제거됨) // ============================================================ useEffect(() => { if (dataSourceType !== "selection" || isDesignMode) return; // 테이블리스트 데이터 변경 이벤트 (V2 표준) const handleTableListDataChange = (event: CustomEvent) => { const { data: eventData } = event.detail || {}; const rows = eventData || []; if (Array.isArray(rows)) { const filteredData = applyFilters( rows, filtersRef.current || [], filterLogic, formDataRef.current, selectedRowsRef.current ); setData(filteredData); } }; // 리피터 데이터 변경 이벤트 (V2 표준) 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); } }; // V2 표준 이벤트만 구독 (중복 이벤트 제거) const unsubscribeTableList = subscribeV2Event(V2_EVENTS.TABLE_LIST_DATA_CHANGE, handleTableListDataChange); const unsubscribeRepeater = subscribeV2Event(V2_EVENTS.REPEATER_DATA_CHANGE, handleRepeaterDataChange); return () => { unsubscribeTableList(); unsubscribeRepeater(); }; }, [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"일 때) // V2 표준 이벤트만 사용 // ============================================================ useEffect(() => { if (dataSourceType !== "component" || !dataSourceComponentId || isDesignMode) return; // 테이블 리스트 데이터 변경 const handleTableListDataChange = (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 handleRepeaterDataChange = (event: CustomEvent) => { const { componentId, data: eventData, selectedData } = event.detail || {}; if (componentId === dataSourceComponentId) { const rows = selectedData || eventData || []; if (Array.isArray(rows)) { const filteredData = applyFilters( rows, filtersRef.current || [], filterLogic, formDataRef.current, selectedRowsRef.current ); setData(filteredData); } } }; // V2 표준 이벤트만 구독 const unsubscribeTableList = subscribeV2Event(V2_EVENTS.TABLE_LIST_DATA_CHANGE, handleTableListDataChange); const unsubscribeRepeater = subscribeV2Event(V2_EVENTS.REPEATER_DATA_CHANGE, handleRepeaterDataChange); return () => { unsubscribeTableList(); unsubscribeRepeater(); }; }, [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 ; case "avg": return ; case "count": return ; case "max": return ; case "min": return ; } }; // 집계 타입 라벨 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 (
{/* 디자인 모드에서 데이터 소스 표시 */}
[{getDataSourceLabel(dataSourceType)}] {dataSourceType === "table" && effectiveTableName && ( {effectiveTableName} )} {dataSourceType === "component" && dataSourceComponentId && ( {dataSourceComponentId} )} {(filters || []).length > 0 && ( 필터 {filters?.length}개 )}
{previewItems.map((result, index) => (
{showIcons && ( {getIcon(result.type)} )} {showLabels && ( {result.label} ({getTypeLabel(result.type)}): )} {result.formattedValue}
))}
); } // 로딩 상태 if (loading) { return (
데이터 집계 중...
); } // 에러 상태 if (error) { return (
{error}
); } // 실제 렌더링 if (aggregationResults.length === 0) { return (
집계 항목을 설정해주세요
); } return (
{aggregationResults.map((result, index) => (
{showIcons && ( {getIcon(result.type)} )} {showLabels && ( {result.label} ({getTypeLabel(result.type)}): )} {result.formattedValue}
))}
); } export const AggregationWidgetWrapper = AggregationWidgetComponent;