docs: V2 컴포넌트 목록 및 v2-table-grouped 개발 상태 업데이트
- V2 컴포넌트 목록에 `v2-table-grouped`를 추가하여 총 18개로 업데이트하였습니다. - `v2-table-grouped`의 구현 완료 상태를 체크리스트에 반영하였으며, 관련 문서화 작업도 완료하였습니다. - 생산계획관리 화면의 신규 컴포넌트 개발 상태를 업데이트하여 `v2-table-grouped`의 완료를 명시하였습니다.
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
"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;
|
||||
Reference in New Issue
Block a user