390 lines
11 KiB
TypeScript
390 lines
11 KiB
TypeScript
"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<string, any>
|
|
): UseGroupedDataResult {
|
|
// 원본 데이터
|
|
const [rawData, setRawData] = useState<any[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// 그룹 펼침 상태 관리
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
|
// 사용자가 수동으로 펼침/접기를 조작했는지 여부
|
|
const [isManuallyControlled, setIsManuallyControlled] = useState(false);
|
|
|
|
// 선택 상태 관리
|
|
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(
|
|
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<string, any[]>();
|
|
|
|
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;
|