530 lines
17 KiB
TypeScript
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;
|