ERP-node/frontend/lib/registry/components/v2-table-grouped/TableGroupedComponent.tsx

530 lines
17 KiB
TypeScript

"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;