"use client"; import { useState, useCallback, useMemo, useEffect } from "react"; import { TableGroupedConfig, GroupState, GroupSummary, UseGroupedDataResult, } from "../types"; import { apiClient } from "@/lib/api/client"; /** * 그룹 요약 데이터 계산 */ function calculateSummary( items: any[], config: TableGroupedConfig ): GroupSummary { const summary: GroupSummary = { count: items.length, }; const summaryConfig = config.groupConfig?.summary; if (!summaryConfig) return summary; // 합계 계산 if (summaryConfig.sumColumns && summaryConfig.sumColumns.length > 0) { summary.sum = {}; for (const col of summaryConfig.sumColumns) { summary.sum[col] = items.reduce((acc, item) => { const val = parseFloat(item[col]); return acc + (isNaN(val) ? 0 : val); }, 0); } } // 평균 계산 if (summaryConfig.avgColumns && summaryConfig.avgColumns.length > 0) { summary.avg = {}; for (const col of summaryConfig.avgColumns) { const validItems = items.filter( (item) => item[col] !== null && item[col] !== undefined ); const sum = validItems.reduce((acc, item) => { const val = parseFloat(item[col]); return acc + (isNaN(val) ? 0 : val); }, 0); summary.avg[col] = validItems.length > 0 ? sum / validItems.length : 0; } } // 최대값 계산 if (summaryConfig.maxColumns && summaryConfig.maxColumns.length > 0) { summary.max = {}; for (const col of summaryConfig.maxColumns) { const values = items .map((item) => parseFloat(item[col])) .filter((v) => !isNaN(v)); summary.max[col] = values.length > 0 ? Math.max(...values) : 0; } } // 최소값 계산 if (summaryConfig.minColumns && summaryConfig.minColumns.length > 0) { summary.min = {}; for (const col of summaryConfig.minColumns) { const values = items .map((item) => parseFloat(item[col])) .filter((v) => !isNaN(v)); summary.min[col] = values.length > 0 ? Math.min(...values) : 0; } } return summary; } /** * 그룹 라벨 포맷팅 */ function formatGroupLabel( groupValue: any, item: any, format?: string ): string { if (!format) { return String(groupValue ?? "(빈 값)"); } // {value}를 그룹 값으로 치환 let label = format.replace("{value}", String(groupValue ?? "(빈 값)")); // {컬럼명} 패턴을 해당 컬럼 값으로 치환 const columnPattern = /\{([^}]+)\}/g; label = label.replace(columnPattern, (match, columnName) => { if (columnName === "value") return String(groupValue ?? ""); return String(item?.[columnName] ?? ""); }); return label; } /** * 데이터를 그룹화하는 훅 */ export function useGroupedData( config: TableGroupedConfig, externalData?: any[], searchFilters?: Record ): UseGroupedDataResult { // 원본 데이터 const [rawData, setRawData] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); // 그룹 펼침 상태 관리 const [expandedGroups, setExpandedGroups] = useState>(new Set()); // 사용자가 수동으로 펼침/접기를 조작했는지 여부 const [isManuallyControlled, setIsManuallyControlled] = useState(false); // 선택 상태 관리 const [selectedItemIds, setSelectedItemIds] = useState>( new Set() ); // 테이블명 결정 const tableName = config.useCustomTable ? config.customTableName : config.selectedTable; // 데이터 로드 const fetchData = useCallback(async () => { if (externalData) { setRawData(externalData); return; } if (!tableName) { setRawData([]); return; } setIsLoading(true); setError(null); try { const response = await apiClient.post( `/table-management/tables/${tableName}/data`, { page: 1, size: 10000, // 그룹화를 위해 전체 데이터 로드 autoFilter: true, search: searchFilters || {}, } ); const responseData = response.data?.data?.data || response.data?.data || []; setRawData(Array.isArray(responseData) ? responseData : []); } catch (err: any) { setError(err.message || "데이터 로드 중 오류 발생"); setRawData([]); } finally { setIsLoading(false); } }, [tableName, externalData, searchFilters]); // 초기 데이터 로드 useEffect(() => { fetchData(); }, [fetchData]); // 외부 데이터 변경 시 동기화 useEffect(() => { if (externalData) { setRawData(externalData); } }, [externalData]); // 그룹화된 데이터 계산 const groups = useMemo((): GroupState[] => { const groupByColumn = config.groupConfig?.groupByColumn; if (!groupByColumn || rawData.length === 0) { return []; } // 데이터를 그룹별로 분류 const groupMap = new Map(); for (const item of rawData) { const groupValue = item[groupByColumn]; const groupKey = String(groupValue ?? "__null__"); if (!groupMap.has(groupKey)) { groupMap.set(groupKey, []); } groupMap.get(groupKey)!.push(item); } // 그룹 배열 생성 const groupArray: GroupState[] = []; const defaultExpanded = config.groupConfig?.defaultExpanded ?? true; for (const [groupKey, items] of groupMap.entries()) { const firstItem = items[0]; const groupValue = groupKey === "__null__" ? null : firstItem[groupByColumn]; // 펼침 상태 결정: 수동 조작 전에는 defaultExpanded, 수동 조작 후에는 expandedGroups 참조 const isExpanded = isManuallyControlled ? expandedGroups.has(groupKey) : defaultExpanded; groupArray.push({ groupKey, groupLabel: formatGroupLabel( groupValue, firstItem, config.groupConfig?.groupLabelFormat ), expanded: isExpanded, items, summary: calculateSummary(items, config), selected: items.every((item) => selectedItemIds.has(getItemId(item, config)) ), selectedItemIds: items .filter((item) => selectedItemIds.has(getItemId(item, config))) .map((item) => getItemId(item, config)), }); } // 정렬 const sortDirection = config.groupConfig?.sortDirection ?? "asc"; groupArray.sort((a, b) => { const comparison = a.groupLabel.localeCompare(b.groupLabel, "ko"); return sortDirection === "asc" ? comparison : -comparison; }); return groupArray; }, [rawData, config, expandedGroups, selectedItemIds, isManuallyControlled]); // 아이템 ID 추출 function getItemId(item: any, cfg: TableGroupedConfig): string { // id 또는 첫 번째 컬럼을 ID로 사용 if (item.id !== undefined) return String(item.id); const firstCol = cfg.columns?.[0]?.columnName; if (firstCol && item[firstCol] !== undefined) return String(item[firstCol]); return JSON.stringify(item); } // 그룹 토글 const toggleGroup = useCallback((groupKey: string) => { setIsManuallyControlled(true); setExpandedGroups((prev) => { const next = new Set(prev); if (next.has(groupKey)) { next.delete(groupKey); } else { next.add(groupKey); } return next; }); }, []); // 전체 펼치기 const expandAll = useCallback(() => { setIsManuallyControlled(true); setExpandedGroups(new Set(groups.map((g) => g.groupKey))); }, [groups]); // 전체 접기 const collapseAll = useCallback(() => { setIsManuallyControlled(true); setExpandedGroups(new Set()); }, []); // 아이템 선택 토글 const toggleItemSelection = useCallback( (groupKey: string, itemId: string) => { setSelectedItemIds((prev) => { const next = new Set(prev); if (next.has(itemId)) { next.delete(itemId); } else { // 단일 선택 모드 if (config.checkboxMode === "single") { next.clear(); } next.add(itemId); } return next; }); }, [config.checkboxMode] ); // 그룹 전체 선택 토글 const toggleGroupSelection = useCallback( (groupKey: string) => { const group = groups.find((g) => g.groupKey === groupKey); if (!group) return; setSelectedItemIds((prev) => { const next = new Set(prev); const groupItemIds = group.items.map((item) => getItemId(item, config)); const allSelected = groupItemIds.every((id) => next.has(id)); if (allSelected) { // 전체 해제 for (const id of groupItemIds) { next.delete(id); } } else { // 전체 선택 if (config.checkboxMode === "single") { next.clear(); next.add(groupItemIds[0]); } else { for (const id of groupItemIds) { next.add(id); } } } return next; }); }, [groups, config] ); // 전체 선택 토글 const toggleAllSelection = useCallback(() => { const allItemIds = rawData.map((item) => getItemId(item, config)); const allSelected = allItemIds.every((id) => selectedItemIds.has(id)); if (allSelected) { setSelectedItemIds(new Set()); } else { if (config.checkboxMode === "single" && allItemIds.length > 0) { setSelectedItemIds(new Set([allItemIds[0]])); } else { setSelectedItemIds(new Set(allItemIds)); } } }, [rawData, config, selectedItemIds]); // 선택된 아이템 목록 const selectedItems = useMemo(() => { return rawData.filter((item) => selectedItemIds.has(getItemId(item, config)) ); }, [rawData, selectedItemIds, config]); // 모두 선택 여부 const isAllSelected = useMemo(() => { if (rawData.length === 0) return false; return rawData.every((item) => selectedItemIds.has(getItemId(item, config)) ); }, [rawData, selectedItemIds, config]); // 일부 선택 여부 const isIndeterminate = useMemo(() => { if (rawData.length === 0) return false; const selectedCount = rawData.filter((item) => selectedItemIds.has(getItemId(item, config)) ).length; return selectedCount > 0 && selectedCount < rawData.length; }, [rawData, selectedItemIds, config]); return { groups, isLoading, error, toggleGroup, expandAll, collapseAll, toggleItemSelection, toggleGroupSelection, toggleAllSelection, selectedItems, isAllSelected, isIndeterminate, refresh: fetchData, rawData, totalCount: rawData.length, groupCount: groups.length, }; } export default useGroupedData;