docs: V2 컴포넌트 목록 및 v2-table-grouped 개발 상태 업데이트
- V2 컴포넌트 목록에 `v2-table-grouped`를 추가하여 총 18개로 업데이트하였습니다. - `v2-table-grouped`의 구현 완료 상태를 체크리스트에 반영하였으며, 관련 문서화 작업도 완료하였습니다. - 생산계획관리 화면의 신규 컴포넌트 개발 상태를 업데이트하여 `v2-table-grouped`의 완료를 명시하였습니다.
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import { Loader2, FoldVertical, UnfoldVertical } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TableGroupedComponentProps } from "./types";
|
||||
import { useGroupedData } from "./hooks/useGroupedData";
|
||||
import { GroupHeader } from "./components/GroupHeader";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
|
||||
|
||||
/**
|
||||
* v2-table-grouped 메인 컴포넌트
|
||||
*
|
||||
* 데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능을 제공합니다.
|
||||
*/
|
||||
export function TableGroupedComponent({
|
||||
config,
|
||||
isDesignMode = false,
|
||||
formData,
|
||||
onSelectionChange,
|
||||
onGroupToggle,
|
||||
onRowClick,
|
||||
externalData,
|
||||
isLoading: externalLoading,
|
||||
error: externalError,
|
||||
componentId,
|
||||
}: TableGroupedComponentProps) {
|
||||
// 화면 컨텍스트 (데이터 제공자로 등록)
|
||||
const screenContext = useScreenContextOptional();
|
||||
|
||||
// TableOptions Context (검색필터 연동)
|
||||
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
|
||||
|
||||
// 연결된 필터 상태 (다른 컴포넌트 값으로 필터링)
|
||||
const [linkedFilterValues, setLinkedFilterValues] = useState<Record<string, any>>({});
|
||||
|
||||
// 필터 및 그룹 설정 상태 (검색필터 연동용)
|
||||
const [filters, setFilters] = useState<any[]>([]);
|
||||
const [grouping, setGrouping] = useState<string[]>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<any[]>([]);
|
||||
|
||||
// 그룹화 데이터 훅 (검색 필터 전달)
|
||||
const {
|
||||
groups,
|
||||
isLoading: hookLoading,
|
||||
error: hookError,
|
||||
toggleGroup,
|
||||
expandAll,
|
||||
collapseAll,
|
||||
toggleItemSelection,
|
||||
toggleGroupSelection,
|
||||
toggleAllSelection,
|
||||
selectedItems,
|
||||
isAllSelected,
|
||||
isIndeterminate,
|
||||
refresh,
|
||||
rawData,
|
||||
totalCount,
|
||||
groupCount,
|
||||
} = useGroupedData(config, externalData, linkedFilterValues);
|
||||
|
||||
const isLoading = externalLoading ?? hookLoading;
|
||||
const error = externalError ?? hookError;
|
||||
|
||||
// 필터링된 데이터 (훅에서 이미 필터 적용됨)
|
||||
const filteredData = rawData;
|
||||
|
||||
// 연결된 필터 감시
|
||||
useEffect(() => {
|
||||
const linkedFilters = config.linkedFilters;
|
||||
|
||||
if (!linkedFilters || linkedFilters.length === 0 || !screenContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 연결된 소스 컴포넌트들의 값을 주기적으로 확인
|
||||
const checkLinkedFilters = () => {
|
||||
const newFilterValues: Record<string, any> = {};
|
||||
let hasChanges = false;
|
||||
|
||||
linkedFilters.forEach((filter) => {
|
||||
if (filter.enabled === false) return;
|
||||
|
||||
const sourceProvider = screenContext.getDataProvider(filter.sourceComponentId);
|
||||
if (sourceProvider) {
|
||||
const selectedData = sourceProvider.getSelectedData();
|
||||
if (selectedData && selectedData.length > 0) {
|
||||
const sourceField = filter.sourceField || "value";
|
||||
const value = selectedData[0][sourceField];
|
||||
|
||||
if (value !== linkedFilterValues[filter.targetColumn]) {
|
||||
newFilterValues[filter.targetColumn] = value;
|
||||
hasChanges = true;
|
||||
} else {
|
||||
newFilterValues[filter.targetColumn] = linkedFilterValues[filter.targetColumn];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
setLinkedFilterValues(newFilterValues);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 확인
|
||||
checkLinkedFilters();
|
||||
|
||||
// 주기적 확인 (100ms 간격)
|
||||
const intervalId = setInterval(checkLinkedFilters, 100);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [screenContext, config.linkedFilters, linkedFilterValues]);
|
||||
|
||||
// DataProvidable 인터페이스 구현
|
||||
const dataProvider: DataProvidable = useMemo(
|
||||
() => ({
|
||||
componentId: componentId || "",
|
||||
componentType: "table-grouped",
|
||||
|
||||
getSelectedData: () => {
|
||||
return selectedItems;
|
||||
},
|
||||
|
||||
getAllData: () => {
|
||||
return filteredData;
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
toggleAllSelection();
|
||||
},
|
||||
}),
|
||||
[componentId, selectedItems, filteredData, toggleAllSelection]
|
||||
);
|
||||
|
||||
// DataReceivable 인터페이스 구현
|
||||
const dataReceiver: DataReceivable = useMemo(
|
||||
() => ({
|
||||
componentId: componentId || "",
|
||||
componentType: "table-grouped",
|
||||
|
||||
receiveData: async (_receivedData: any[], _config: DataReceiverConfig) => {
|
||||
// 현재는 외부 데이터 수신 시 새로고침만 수행
|
||||
refresh();
|
||||
},
|
||||
|
||||
clearData: async () => {
|
||||
// 데이터 클리어 시 새로고침
|
||||
refresh();
|
||||
},
|
||||
|
||||
getConfig: () => {
|
||||
return {
|
||||
targetComponentId: componentId || "",
|
||||
mode: "replace" as const,
|
||||
};
|
||||
},
|
||||
}),
|
||||
[componentId, refresh]
|
||||
);
|
||||
|
||||
// 화면 컨텍스트에 데이터 제공자/수신자로 등록
|
||||
useEffect(() => {
|
||||
if (screenContext && componentId) {
|
||||
screenContext.registerDataProvider(componentId, dataProvider);
|
||||
screenContext.registerDataReceiver(componentId, dataReceiver);
|
||||
|
||||
return () => {
|
||||
screenContext.unregisterDataProvider(componentId);
|
||||
screenContext.unregisterDataReceiver(componentId);
|
||||
};
|
||||
}
|
||||
}, [screenContext, componentId, dataProvider, dataReceiver]);
|
||||
|
||||
// 테이블 ID (검색필터 연동용)
|
||||
const tableId = componentId || `table-grouped-${config.selectedTable || "default"}`;
|
||||
|
||||
// TableOptionsContext에 테이블 등록 (검색필터가 테이블을 찾을 수 있도록)
|
||||
useEffect(() => {
|
||||
if (isDesignMode || !config.selectedTable) return;
|
||||
|
||||
const columnsToRegister = config.columns || [];
|
||||
|
||||
// 고유 값 조회 함수
|
||||
const getColumnUniqueValues = async (columnName: string) => {
|
||||
const uniqueValues = new Set<string>();
|
||||
rawData.forEach((row) => {
|
||||
const value = row[columnName];
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
uniqueValues.add(String(value));
|
||||
}
|
||||
});
|
||||
return Array.from(uniqueValues)
|
||||
.map((value) => ({ value, label: value }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
};
|
||||
|
||||
const registration = {
|
||||
tableId,
|
||||
label: config.selectedTable,
|
||||
tableName: config.selectedTable,
|
||||
dataCount: totalCount,
|
||||
columns: columnsToRegister.map((col) => ({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnName,
|
||||
inputType: "text",
|
||||
visible: col.visible !== false,
|
||||
width: col.width || 150,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
})),
|
||||
onFilterChange: setFilters,
|
||||
onGroupChange: setGrouping,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getColumnUniqueValues,
|
||||
};
|
||||
|
||||
registerTable(registration);
|
||||
|
||||
return () => {
|
||||
unregisterTable(tableId);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tableId, config.selectedTable, config.columns, totalCount, rawData, registerTable, isDesignMode]);
|
||||
|
||||
// 데이터 건수 변경 시 업데이트
|
||||
useEffect(() => {
|
||||
if (!isDesignMode && config.selectedTable) {
|
||||
updateTableDataCount(tableId, totalCount);
|
||||
}
|
||||
}, [tableId, totalCount, updateTableDataCount, config.selectedTable, isDesignMode]);
|
||||
|
||||
// 필터 변경 시 검색 조건 적용
|
||||
useEffect(() => {
|
||||
if (filters.length > 0) {
|
||||
const newFilterValues: Record<string, any> = {};
|
||||
filters.forEach((filter: any) => {
|
||||
if (filter.value) {
|
||||
newFilterValues[filter.columnName] = filter.value;
|
||||
}
|
||||
});
|
||||
setLinkedFilterValues((prev) => ({ ...prev, ...newFilterValues }));
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
// 컬럼 설정
|
||||
const columns = config.columns || [];
|
||||
const visibleColumns = columns.filter((col) => col.visible !== false);
|
||||
|
||||
// 체크박스 컬럼 포함 시 총 컬럼 수
|
||||
const totalColumnCount = visibleColumns.length + (config.showCheckbox ? 1 : 0);
|
||||
|
||||
// 아이템 ID 추출 함수
|
||||
const getItemId = useCallback(
|
||||
(item: any): string => {
|
||||
if (item.id !== undefined) return String(item.id);
|
||||
const firstCol = columns[0]?.columnName;
|
||||
if (firstCol && item[firstCol] !== undefined) return String(item[firstCol]);
|
||||
return JSON.stringify(item);
|
||||
},
|
||||
[columns]
|
||||
);
|
||||
|
||||
// 선택 변경 시 콜백
|
||||
useEffect(() => {
|
||||
if (onSelectionChange && selectedItems.length >= 0) {
|
||||
onSelectionChange({
|
||||
selectedGroups: groups
|
||||
.filter((g) => g.selected)
|
||||
.map((g) => g.groupKey),
|
||||
selectedItems,
|
||||
isAllSelected,
|
||||
});
|
||||
}
|
||||
}, [selectedItems, groups, isAllSelected, onSelectionChange]);
|
||||
|
||||
// 그룹 토글 핸들러
|
||||
const handleGroupToggle = useCallback(
|
||||
(groupKey: string) => {
|
||||
toggleGroup(groupKey);
|
||||
if (onGroupToggle) {
|
||||
const group = groups.find((g) => g.groupKey === groupKey);
|
||||
onGroupToggle({
|
||||
groupKey,
|
||||
expanded: !group?.expanded,
|
||||
});
|
||||
}
|
||||
},
|
||||
[toggleGroup, onGroupToggle, groups]
|
||||
);
|
||||
|
||||
// 행 클릭 핸들러
|
||||
const handleRowClick = useCallback(
|
||||
(row: any, groupKey: string, indexInGroup: number) => {
|
||||
if (!config.rowClickable) return;
|
||||
if (onRowClick) {
|
||||
onRowClick({ row, groupKey, indexInGroup });
|
||||
}
|
||||
},
|
||||
[config.rowClickable, onRowClick]
|
||||
);
|
||||
|
||||
// refreshTable 이벤트 구독
|
||||
useEffect(() => {
|
||||
const handleRefresh = () => {
|
||||
refresh();
|
||||
};
|
||||
|
||||
window.addEventListener("refreshTable", handleRefresh);
|
||||
return () => {
|
||||
window.removeEventListener("refreshTable", handleRefresh);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
// 디자인 모드 렌더링
|
||||
if (isDesignMode) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/20 p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FoldVertical className="h-4 w-4" />
|
||||
<span>그룹화 테이블</span>
|
||||
{config.groupConfig?.groupByColumn && (
|
||||
<span className="text-xs">
|
||||
(그룹: {config.groupConfig.groupByColumn})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
테이블: {config.useCustomTable ? config.customTableName : config.selectedTable || "(미설정)"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 없음
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
{config.emptyMessage || "데이터가 없습니다."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="v2-table-grouped flex flex-col"
|
||||
style={{
|
||||
height: config.height,
|
||||
maxHeight: config.maxHeight,
|
||||
}}
|
||||
>
|
||||
{/* 툴바 */}
|
||||
{config.showExpandAllButton && (
|
||||
<div className="flex items-center justify-between border-b bg-muted/30 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={expandAll}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<UnfoldVertical className="mr-1 h-3 w-3" />
|
||||
전체 펼치기
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={collapseAll}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<FoldVertical className="mr-1 h-3 w-3" />
|
||||
전체 접기
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{groupCount}개 그룹 | 총 {totalCount}건
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
{/* 테이블 헤더 */}
|
||||
<thead className="sticky top-0 z-10 bg-muted">
|
||||
<tr>
|
||||
{/* 전체 선택 체크박스 */}
|
||||
{config.showCheckbox && (
|
||||
<th className="w-10 whitespace-nowrap border-b px-3 py-2 text-left">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={toggleAllSelection}
|
||||
className={cn(isIndeterminate && "data-[state=checked]:bg-muted")}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{/* 컬럼 헤더 */}
|
||||
{visibleColumns.map((col) => (
|
||||
<th
|
||||
key={col.columnName}
|
||||
className={cn(
|
||||
"whitespace-nowrap border-b px-3 py-2 font-medium text-muted-foreground",
|
||||
col.align === "center" && "text-center",
|
||||
col.align === "right" && "text-right"
|
||||
)}
|
||||
style={{ width: col.width ? `${col.width}px` : "auto" }}
|
||||
>
|
||||
{col.displayName || col.columnName}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* 테이블 바디 */}
|
||||
<tbody>
|
||||
{groups.map((group) => (
|
||||
<React.Fragment key={group.groupKey}>
|
||||
{/* 그룹 헤더 */}
|
||||
<GroupHeader
|
||||
group={group}
|
||||
config={config}
|
||||
onToggle={() => handleGroupToggle(group.groupKey)}
|
||||
onSelectToggle={
|
||||
config.showCheckbox
|
||||
? () => toggleGroupSelection(group.groupKey)
|
||||
: undefined
|
||||
}
|
||||
style={config.groupHeaderStyle}
|
||||
columnCount={totalColumnCount}
|
||||
/>
|
||||
|
||||
{/* 그룹 아이템 (펼쳐진 경우만) */}
|
||||
{group.expanded &&
|
||||
group.items.map((item, idx) => {
|
||||
const itemId = getItemId(item);
|
||||
const isSelected = group.selectedItemIds?.includes(itemId);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={itemId}
|
||||
className={cn(
|
||||
"border-b transition-colors",
|
||||
config.rowClickable && "cursor-pointer hover:bg-muted/50",
|
||||
isSelected && "bg-primary/10"
|
||||
)}
|
||||
onClick={() => handleRowClick(item, group.groupKey, idx)}
|
||||
>
|
||||
{/* 체크박스 */}
|
||||
{config.showCheckbox && (
|
||||
<td
|
||||
className="px-3 py-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() =>
|
||||
toggleItemSelection(group.groupKey, itemId)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
|
||||
{/* 데이터 컬럼 */}
|
||||
{visibleColumns.map((col) => {
|
||||
const value = item[col.columnName];
|
||||
let displayValue: React.ReactNode = value;
|
||||
|
||||
// 포맷 적용
|
||||
if (col.format === "number" && typeof value === "number") {
|
||||
displayValue = value.toLocaleString();
|
||||
} else if (col.format === "currency" && typeof value === "number") {
|
||||
displayValue = `₩${value.toLocaleString()}`;
|
||||
} else if (col.format === "date" && value) {
|
||||
displayValue = new Date(value).toLocaleDateString("ko-KR");
|
||||
} else if (col.format === "boolean") {
|
||||
displayValue = value ? "예" : "아니오";
|
||||
}
|
||||
|
||||
return (
|
||||
<td
|
||||
key={col.columnName}
|
||||
className={cn(
|
||||
"px-3 py-2",
|
||||
col.align === "center" && "text-center",
|
||||
col.align === "right" && "text-right"
|
||||
)}
|
||||
>
|
||||
{displayValue ?? "-"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TableGroupedComponent;
|
||||
Reference in New Issue
Block a user