feat: TableListComponent에 그룹핑 기능 구현
- 다중 컬럼 선택으로 계층적 그룹화 지원 - 그룹 설정 다이얼로그 추가 - 그룹별 데이터 펼치기/접기 기능 - 그룹 헤더에 항목 개수 표시 - localStorage에 그룹 설정 저장/복원 - 그룹 해제 버튼 추가 - 그룹 표시 배지 UI 주요 기능: - 사용자가 원하는 컬럼(들)을 선택하여 그룹화 - 그룹 키: '통화:KRW > 단위:EA' 형식으로 표시 - 그룹 헤더 클릭으로 펼치기/접기 - 그룹 없을 때는 기존 렌더링 방식 유지
This commit is contained in:
parent
8248c8dc96
commit
b607ef0aa0
|
|
@ -19,6 +19,8 @@ import {
|
||||||
TableIcon,
|
TableIcon,
|
||||||
Settings,
|
Settings,
|
||||||
X,
|
X,
|
||||||
|
Layers,
|
||||||
|
ChevronDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -36,6 +38,18 @@ import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearc
|
||||||
import { SingleTableWithSticky } from "./SingleTableWithSticky";
|
import { SingleTableWithSticky } from "./SingleTableWithSticky";
|
||||||
import { CardModeRenderer } from "./CardModeRenderer";
|
import { CardModeRenderer } from "./CardModeRenderer";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 인터페이스
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// 그룹화된 데이터 인터페이스
|
||||||
|
interface GroupedData {
|
||||||
|
groupKey: string;
|
||||||
|
groupValues: Record<string, any>;
|
||||||
|
items: any[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 캐시 및 유틸리티
|
// 캐시 및 유틸리티
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -255,6 +269,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
|
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
|
||||||
const [visibleFilterColumns, setVisibleFilterColumns] = useState<Set<string>>(new Set());
|
const [visibleFilterColumns, setVisibleFilterColumns] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 그룹 설정 관련 상태
|
||||||
|
const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false);
|
||||||
|
const [groupByColumns, setGroupByColumns] = useState<string[]>([]);
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, {
|
const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, {
|
||||||
enableBatchLoading: true,
|
enableBatchLoading: true,
|
||||||
preloadCommonCodes: true,
|
preloadCommonCodes: true,
|
||||||
|
|
@ -715,6 +734,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return `tableList_filterSettings_${tableConfig.selectedTable}`;
|
return `tableList_filterSettings_${tableConfig.selectedTable}`;
|
||||||
}, [tableConfig.selectedTable]);
|
}, [tableConfig.selectedTable]);
|
||||||
|
|
||||||
|
// 그룹 설정 localStorage 키 생성
|
||||||
|
const groupSettingKey = useMemo(() => {
|
||||||
|
if (!tableConfig.selectedTable) return null;
|
||||||
|
return `tableList_groupSettings_${tableConfig.selectedTable}`;
|
||||||
|
}, [tableConfig.selectedTable]);
|
||||||
|
|
||||||
// 저장된 필터 설정 불러오기
|
// 저장된 필터 설정 불러오기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filterSettingKey || visibleColumns.length === 0) return;
|
if (!filterSettingKey || visibleColumns.length === 0) return;
|
||||||
|
|
@ -789,6 +814,105 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}));
|
}));
|
||||||
}, [visibleColumns, visibleFilterColumns, columnLabels]);
|
}, [visibleColumns, visibleFilterColumns, columnLabels]);
|
||||||
|
|
||||||
|
// 그룹 설정 저장
|
||||||
|
const saveGroupSettings = useCallback(() => {
|
||||||
|
if (!groupSettingKey) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
|
||||||
|
setIsGroupSettingOpen(false);
|
||||||
|
toast.success("그룹 설정이 저장되었습니다");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("그룹 설정 저장 실패:", error);
|
||||||
|
toast.error("설정 저장에 실패했습니다");
|
||||||
|
}
|
||||||
|
}, [groupSettingKey, groupByColumns]);
|
||||||
|
|
||||||
|
// 그룹 컬럼 토글
|
||||||
|
const toggleGroupColumn = useCallback((columnName: string) => {
|
||||||
|
setGroupByColumns((prev) => {
|
||||||
|
if (prev.includes(columnName)) {
|
||||||
|
return prev.filter((col) => col !== columnName);
|
||||||
|
} else {
|
||||||
|
return [...prev, columnName];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 그룹 펼치기/접기 토글
|
||||||
|
const toggleGroupCollapse = useCallback((groupKey: string) => {
|
||||||
|
setCollapsedGroups((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(groupKey)) {
|
||||||
|
newSet.delete(groupKey);
|
||||||
|
} else {
|
||||||
|
newSet.add(groupKey);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 그룹 해제
|
||||||
|
const clearGrouping = useCallback(() => {
|
||||||
|
setGroupByColumns([]);
|
||||||
|
setCollapsedGroups(new Set());
|
||||||
|
if (groupSettingKey) {
|
||||||
|
localStorage.removeItem(groupSettingKey);
|
||||||
|
}
|
||||||
|
toast.success("그룹이 해제되었습니다");
|
||||||
|
}, [groupSettingKey]);
|
||||||
|
|
||||||
|
// 데이터 그룹화
|
||||||
|
const groupedData = useMemo((): GroupedData[] => {
|
||||||
|
if (groupByColumns.length === 0 || data.length === 0) return [];
|
||||||
|
|
||||||
|
const grouped = new Map<string, any[]>();
|
||||||
|
|
||||||
|
data.forEach((item) => {
|
||||||
|
// 그룹 키 생성: "통화:KRW > 단위:EA"
|
||||||
|
const keyParts = groupByColumns.map((col) => {
|
||||||
|
const value = item[col];
|
||||||
|
const label = columnLabels[col] || col;
|
||||||
|
return `${label}:${value !== null && value !== undefined ? value : "-"}`;
|
||||||
|
});
|
||||||
|
const groupKey = keyParts.join(" > ");
|
||||||
|
|
||||||
|
if (!grouped.has(groupKey)) {
|
||||||
|
grouped.set(groupKey, []);
|
||||||
|
}
|
||||||
|
grouped.get(groupKey)!.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(grouped.entries()).map(([groupKey, items]) => {
|
||||||
|
const groupValues: Record<string, any> = {};
|
||||||
|
groupByColumns.forEach((col) => {
|
||||||
|
groupValues[col] = items[0]?.[col];
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupKey,
|
||||||
|
groupValues,
|
||||||
|
items,
|
||||||
|
count: items.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [data, groupByColumns, columnLabels]);
|
||||||
|
|
||||||
|
// 저장된 그룹 설정 불러오기
|
||||||
|
useEffect(() => {
|
||||||
|
if (!groupSettingKey || visibleColumns.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(groupSettingKey);
|
||||||
|
if (saved) {
|
||||||
|
const savedGroups = JSON.parse(saved);
|
||||||
|
setGroupByColumns(savedGroups);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("그룹 설정 불러오기 실패:", error);
|
||||||
|
}
|
||||||
|
}, [groupSettingKey, visibleColumns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchColumnLabels();
|
fetchColumnLabels();
|
||||||
fetchTableLabel();
|
fetchTableLabel();
|
||||||
|
|
@ -980,15 +1104,52 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
onClear={handleClearAdvancedFilters}
|
onClear={handleClearAdvancedFilters}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => setIsFilterSettingOpen(true)}
|
size="sm"
|
||||||
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
onClick={() => setIsFilterSettingOpen(true)}
|
||||||
|
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||||
|
>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
필터 설정
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsGroupSettingOpen(true)}
|
||||||
|
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||||
|
>
|
||||||
|
<Layers className="mr-2 h-4 w-4" />
|
||||||
|
그룹 설정
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 그룹 표시 배지 */}
|
||||||
|
{groupByColumns.length > 0 && (
|
||||||
|
<div className="px-4 py-2 border-b border-border bg-muted/30 sm:px-6">
|
||||||
|
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
||||||
|
<span className="text-muted-foreground">그룹:</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{groupByColumns.map((col, idx) => (
|
||||||
|
<span key={col} className="flex items-center">
|
||||||
|
{idx > 0 && <span className="text-muted-foreground mx-1">→</span>}
|
||||||
|
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs font-medium">
|
||||||
|
{columnLabels[col] || col}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={clearGrouping}
|
||||||
|
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
|
||||||
|
title="그룹 해제"
|
||||||
>
|
>
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
필터 설정
|
</button>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1045,15 +1206,52 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
onClear={handleClearAdvancedFilters}
|
onClear={handleClearAdvancedFilters}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => setIsFilterSettingOpen(true)}
|
size="sm"
|
||||||
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
onClick={() => setIsFilterSettingOpen(true)}
|
||||||
|
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||||
|
>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
필터 설정
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsGroupSettingOpen(true)}
|
||||||
|
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||||
|
>
|
||||||
|
<Layers className="mr-2 h-4 w-4" />
|
||||||
|
그룹 설정
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 그룹 표시 배지 */}
|
||||||
|
{groupByColumns.length > 0 && (
|
||||||
|
<div className="px-4 py-2 border-b border-border bg-muted/30 sm:px-6">
|
||||||
|
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
||||||
|
<span className="text-muted-foreground">그룹:</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{groupByColumns.map((col, idx) => (
|
||||||
|
<span key={col} className="flex items-center">
|
||||||
|
{idx > 0 && <span className="text-muted-foreground mx-1">→</span>}
|
||||||
|
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs font-medium">
|
||||||
|
{columnLabels[col] || col}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={clearGrouping}
|
||||||
|
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
|
||||||
|
title="그룹 해제"
|
||||||
>
|
>
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
필터 설정
|
</button>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1209,7 +1407,81 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
) : groupByColumns.length > 0 && groupedData.length > 0 ? (
|
||||||
|
// 그룹화된 렌더링
|
||||||
|
groupedData.map((group) => {
|
||||||
|
const isCollapsed = collapsedGroups.has(group.groupKey);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={group.groupKey}>
|
||||||
|
{/* 그룹 헤더 */}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={visibleColumns.length}
|
||||||
|
className="bg-muted/50 border-b border-border sticky top-[48px] z-[5]"
|
||||||
|
style={{ top: "48px" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-muted"
|
||||||
|
onClick={() => toggleGroupCollapse(group.groupKey)}
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-sm flex-1">{group.groupKey}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">({group.count}건)</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/* 그룹 데이터 */}
|
||||||
|
{!isCollapsed &&
|
||||||
|
group.items.map((row, index) => (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
draggable={!isDesignMode}
|
||||||
|
onDragStart={(e) => handleRowDragStart(e, row, index)}
|
||||||
|
onDragEnd={handleRowDragEnd}
|
||||||
|
className={cn(
|
||||||
|
"h-14 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-16"
|
||||||
|
)}
|
||||||
|
onClick={() => handleRowClick(row)}
|
||||||
|
>
|
||||||
|
{visibleColumns.map((column) => {
|
||||||
|
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
||||||
|
const cellValue = row[mappedColumnName];
|
||||||
|
|
||||||
|
const meta = columnMeta[column.columnName];
|
||||||
|
const inputType = meta?.inputType || column.inputType;
|
||||||
|
const isNumeric = inputType === "number" || inputType === "decimal";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={column.columnName}
|
||||||
|
className={cn(
|
||||||
|
"h-14 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-16 sm:text-sm",
|
||||||
|
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
textAlign: column.columnName === "__checkbox__" ? "center" : (isNumeric ? "right" : (column.align || "left")),
|
||||||
|
width: column.columnName === "__checkbox__" ? '48px' : `${100 / visibleColumns.length}%`,
|
||||||
|
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||||
|
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{column.columnName === "__checkbox__"
|
||||||
|
? renderCheckboxCell(row, index)
|
||||||
|
: formatCellValue(cellValue, column, row)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
|
// 일반 렌더링 (그룹 없음)
|
||||||
data.map((row, index) => (
|
data.map((row, index) => (
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
|
|
@ -1340,6 +1612,68 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 그룹 설정 다이얼로그 */}
|
||||||
|
<Dialog open={isGroupSettingOpen} onOpenChange={setIsGroupSettingOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">그룹 설정</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
{/* 컬럼 목록 */}
|
||||||
|
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded border p-2">
|
||||||
|
{visibleColumns
|
||||||
|
.filter((col) => col.columnName !== "__checkbox__")
|
||||||
|
.map((col) => (
|
||||||
|
<div key={col.columnName} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`group-${col.columnName}`}
|
||||||
|
checked={groupByColumns.includes(col.columnName)}
|
||||||
|
onCheckedChange={() => toggleGroupColumn(col.columnName)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`group-${col.columnName}`}
|
||||||
|
className="flex-1 cursor-pointer text-xs font-normal sm:text-sm"
|
||||||
|
>
|
||||||
|
{columnLabels[col.columnName] || col.displayName || col.columnName}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선택된 그룹 안내 */}
|
||||||
|
<div className="text-muted-foreground bg-muted/30 rounded p-3 text-xs">
|
||||||
|
{groupByColumns.length === 0 ? (
|
||||||
|
<span>그룹화할 컬럼을 선택하세요</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
선택된 그룹:{" "}
|
||||||
|
<span className="text-primary font-semibold">
|
||||||
|
{groupByColumns.map((col) => columnLabels[col] || col).join(" → ")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsGroupSettingOpen(false)}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveGroupSettings} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
|
적용
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,365 @@
|
||||||
|
# 테이블 그룹핑 기능 구현 계획서
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
테이블 리스트 컴포넌트와 플로우 위젯에 그룹핑 기능을 추가하여, 사용자가 선택한 컬럼(들)을 기준으로 데이터를 그룹화하여 표시합니다.
|
||||||
|
|
||||||
|
## 🎯 핵심 요구사항
|
||||||
|
|
||||||
|
### 1. 기능 요구사항
|
||||||
|
- ✅ 그룹핑할 컬럼을 다중 선택 가능
|
||||||
|
- ✅ 선택한 컬럼 순서대로 계층적 그룹화
|
||||||
|
- ✅ 그룹 헤더에 그룹 정보와 데이터 개수 표시
|
||||||
|
- ✅ 그룹 펼치기/접기 기능
|
||||||
|
- ✅ localStorage에 그룹 설정 저장/복원
|
||||||
|
- ✅ 그룹 해제 기능
|
||||||
|
|
||||||
|
### 2. 적용 대상
|
||||||
|
- TableListComponent (`frontend/lib/registry/components/table-list/TableListComponent.tsx`)
|
||||||
|
- FlowWidget (`frontend/components/screen/widgets/FlowWidget.tsx`)
|
||||||
|
|
||||||
|
## 🎨 UI 디자인
|
||||||
|
|
||||||
|
### 그룹 설정 다이얼로그
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ 📊 그룹 설정 │
|
||||||
|
│ 데이터를 그룹화할 컬럼을 선택하세요 │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ [x] 통화 │
|
||||||
|
│ [ ] 단위 │
|
||||||
|
│ [ ] 품목코드 │
|
||||||
|
│ [ ] 품목명 │
|
||||||
|
│ [ ] 규격 │
|
||||||
|
│ │
|
||||||
|
│ 💡 선택된 그룹: 통화 │
|
||||||
|
│ │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ [취소] [적용] │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 그룹화된 테이블 표시
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 📦 판매품목 목록 총 3개 [🎨 그룹: 통화 ×] │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ▼ 통화: KRW > 단위: EA (2건) │
|
||||||
|
│ ┌─────────────────────────────────────────────┐ │
|
||||||
|
│ │ 품목코드 │ 품목명 │ 규격 │ 단위 │ │
|
||||||
|
│ ├─────────────────────────────────────────────┤ │
|
||||||
|
│ │ SALE-001 │ 볼트 M8x20 │ M8x20 │ EA │ │
|
||||||
|
│ │ SALE-004 │ 스프링 와셔 │ M10 │ EA │ │
|
||||||
|
│ └─────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ▼ 통화: USD > 단위: EA (1건) │
|
||||||
|
│ ┌─────────────────────────────────────────────┐ │
|
||||||
|
│ │ 품목코드 │ 품목명 │ 규격 │ 단위 │ │
|
||||||
|
│ ├─────────────────────────────────────────────┤ │
|
||||||
|
│ │ SALE-002 │ 너트 M8 │ M8 │ EA │ │
|
||||||
|
│ └─────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 기술 구현
|
||||||
|
|
||||||
|
### 1. 상태 관리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 그룹 설정 관련 상태
|
||||||
|
const [groupByColumns, setGroupByColumns] = useState<string[]>([]); // 그룹화할 컬럼 목록
|
||||||
|
const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set()); // 접힌 그룹
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 데이터 그룹화 로직
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GroupedData {
|
||||||
|
groupKey: string; // "통화:KRW > 단위:EA"
|
||||||
|
groupValues: Record<string, any>; // { 통화: "KRW", 단위: "EA" }
|
||||||
|
items: any[]; // 그룹에 속한 데이터
|
||||||
|
count: number; // 항목 개수
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupDataByColumns = (
|
||||||
|
data: any[],
|
||||||
|
groupColumns: string[]
|
||||||
|
): GroupedData[] => {
|
||||||
|
if (groupColumns.length === 0) return [];
|
||||||
|
|
||||||
|
const grouped = new Map<string, any[]>();
|
||||||
|
|
||||||
|
data.forEach(item => {
|
||||||
|
// 그룹 키 생성: "통화:KRW > 단위:EA"
|
||||||
|
const keyParts = groupColumns.map(col => `${col}:${item[col] || '-'}`);
|
||||||
|
const groupKey = keyParts.join(' > ');
|
||||||
|
|
||||||
|
if (!grouped.has(groupKey)) {
|
||||||
|
grouped.set(groupKey, []);
|
||||||
|
}
|
||||||
|
grouped.get(groupKey)!.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(grouped.entries()).map(([groupKey, items]) => {
|
||||||
|
const groupValues: Record<string, any> = {};
|
||||||
|
groupColumns.forEach(col => {
|
||||||
|
groupValues[col] = items[0]?.[col];
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupKey,
|
||||||
|
groupValues,
|
||||||
|
items,
|
||||||
|
count: items.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. localStorage 저장/로드
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 저장 키
|
||||||
|
const groupSettingKey = useMemo(() => {
|
||||||
|
if (!tableConfig.selectedTable) return null;
|
||||||
|
return `table-list-group-${tableConfig.selectedTable}`;
|
||||||
|
}, [tableConfig.selectedTable]);
|
||||||
|
|
||||||
|
// 그룹 설정 저장
|
||||||
|
const saveGroupSettings = useCallback(() => {
|
||||||
|
if (!groupSettingKey) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
|
||||||
|
setIsGroupSettingOpen(false);
|
||||||
|
toast.success("그룹 설정이 저장되었습니다");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("그룹 설정 저장 실패:", error);
|
||||||
|
toast.error("설정 저장에 실패했습니다");
|
||||||
|
}
|
||||||
|
}, [groupSettingKey, groupByColumns]);
|
||||||
|
|
||||||
|
// 그룹 설정 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!groupSettingKey || visibleColumns.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(groupSettingKey);
|
||||||
|
if (saved) {
|
||||||
|
const savedGroups = JSON.parse(saved);
|
||||||
|
setGroupByColumns(savedGroups);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("그룹 설정 불러오기 실패:", error);
|
||||||
|
}
|
||||||
|
}, [groupSettingKey, visibleColumns]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 그룹 헤더 렌더링
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const renderGroupHeader = (group: GroupedData) => {
|
||||||
|
const isCollapsed = collapsedGroups.has(group.groupKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-muted/50 flex items-center gap-3 border-b p-3 cursor-pointer hover:bg-muted"
|
||||||
|
onClick={() => toggleGroupCollapse(group.groupKey)}
|
||||||
|
>
|
||||||
|
{/* 펼치기/접기 아이콘 */}
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 그룹 정보 */}
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{groupByColumns.map((col, idx) => (
|
||||||
|
<span key={col}>
|
||||||
|
{idx > 0 && <span className="text-muted-foreground"> > </span>}
|
||||||
|
<span className="text-muted-foreground">{columnLabels[col] || col}:</span>
|
||||||
|
{" "}
|
||||||
|
<span className="text-foreground">{group.groupValues[col]}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 항목 개수 */}
|
||||||
|
<span className="text-muted-foreground text-xs ml-auto">
|
||||||
|
({group.count}건)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 그룹 설정 다이얼로그
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Dialog open={isGroupSettingOpen} onOpenChange={setIsGroupSettingOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">그룹 설정</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
{/* 컬럼 목록 */}
|
||||||
|
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded border p-2">
|
||||||
|
{visibleColumns
|
||||||
|
.filter((col) => col.columnName !== "__checkbox__")
|
||||||
|
.map((col) => (
|
||||||
|
<div key={col.columnName} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`group-${col.columnName}`}
|
||||||
|
checked={groupByColumns.includes(col.columnName)}
|
||||||
|
onCheckedChange={() => toggleGroupColumn(col.columnName)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`group-${col.columnName}`}
|
||||||
|
className="flex-1 cursor-pointer text-xs font-normal sm:text-sm"
|
||||||
|
>
|
||||||
|
{columnLabels[col.columnName] || col.displayName || col.columnName}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선택된 그룹 안내 */}
|
||||||
|
<div className="text-muted-foreground bg-muted/30 rounded p-3 text-xs">
|
||||||
|
{groupByColumns.length === 0 ? (
|
||||||
|
<span>그룹화할 컬럼을 선택하세요</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
선택된 그룹: <span className="text-primary font-semibold">
|
||||||
|
{groupByColumns.map((col) => columnLabels[col] || col).join(" → ")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsGroupSettingOpen(false)}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveGroupSettings} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
|
적용
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 그룹 해제 버튼
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* 헤더 영역 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2>{tableLabel}</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 그룹 표시 배지 */}
|
||||||
|
{groupByColumns.length > 0 && (
|
||||||
|
<div className="bg-primary/10 text-primary flex items-center gap-2 rounded px-3 py-1 text-xs">
|
||||||
|
<span>그룹: {groupByColumns.map(col => columnLabels[col] || col).join(", ")}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setGroupByColumns([]);
|
||||||
|
localStorage.removeItem(groupSettingKey || "");
|
||||||
|
toast.success("그룹이 해제되었습니다");
|
||||||
|
}}
|
||||||
|
className="hover:bg-primary/20 rounded p-0.5"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 그룹 설정 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsGroupSettingOpen(true)}
|
||||||
|
>
|
||||||
|
<Layers className="mr-2 h-4 w-4" />
|
||||||
|
그룹 설정
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 구현 순서
|
||||||
|
|
||||||
|
### Phase 1: TableListComponent 구현
|
||||||
|
1. ✅ 상태 관리 추가 (groupByColumns, isGroupSettingOpen, collapsedGroups)
|
||||||
|
2. ✅ 그룹화 로직 구현 (groupDataByColumns 함수)
|
||||||
|
3. ✅ localStorage 저장/로드 로직
|
||||||
|
4. ✅ 그룹 설정 다이얼로그 UI
|
||||||
|
5. ✅ 그룹 헤더 렌더링
|
||||||
|
6. ✅ 그룹별 데이터 렌더링
|
||||||
|
7. ✅ 그룹 해제 기능
|
||||||
|
|
||||||
|
### Phase 2: FlowWidget 구현
|
||||||
|
1. ✅ TableListComponent와 동일한 로직 적용
|
||||||
|
2. ✅ 스텝 데이터에 그룹화 적용
|
||||||
|
3. ✅ UI 통일성 유지
|
||||||
|
|
||||||
|
### Phase 3: 테스트 및 최적화
|
||||||
|
1. ✅ 다중 그룹 계층 테스트
|
||||||
|
2. ✅ 대량 데이터 성능 테스트
|
||||||
|
3. ✅ localStorage 저장/복원 테스트
|
||||||
|
4. ✅ 그룹 펼치기/접기 테스트
|
||||||
|
|
||||||
|
## 🎯 예상 효과
|
||||||
|
|
||||||
|
### 사용자 경험 개선
|
||||||
|
- 데이터를 논리적으로 그룹화하여 가독성 향상
|
||||||
|
- 대량 데이터를 효율적으로 탐색 가능
|
||||||
|
- 사용자 정의 뷰 제공
|
||||||
|
|
||||||
|
### 데이터 분석 지원
|
||||||
|
- 카테고리별 데이터 분석 용이
|
||||||
|
- 통계 정보 제공 (그룹별 개수)
|
||||||
|
- 계층적 데이터 구조 시각화
|
||||||
|
|
||||||
|
## ⚠️ 주의사항
|
||||||
|
|
||||||
|
### 성능 고려사항
|
||||||
|
- 그룹화는 클라이언트 측에서 수행
|
||||||
|
- 대량 데이터의 경우 성능 저하 가능
|
||||||
|
- 필요시 서버 측 그룹화로 전환 검토
|
||||||
|
|
||||||
|
### 사용성
|
||||||
|
- 그룹화 해제가 쉽게 가능해야 함
|
||||||
|
- 그룹 설정이 직관적이어야 함
|
||||||
|
- 모바일에서도 사용 가능한 UI
|
||||||
|
|
||||||
|
## 📊 구현 상태
|
||||||
|
|
||||||
|
- [ ] Phase 1: TableListComponent 구현
|
||||||
|
- [ ] 상태 관리 추가
|
||||||
|
- [ ] 그룹화 로직 구현
|
||||||
|
- [ ] localStorage 연동
|
||||||
|
- [ ] UI 구현
|
||||||
|
- [ ] Phase 2: FlowWidget 구현
|
||||||
|
- [ ] Phase 3: 테스트 및 최적화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-11-03
|
||||||
|
**버전**: 1.0
|
||||||
|
**상태**: 구현 예정
|
||||||
|
|
||||||
Loading…
Reference in New Issue