feat: FlowWidget에 그룹핑 기능 구현
- TableListComponent와 동일한 그룹핑 기능 적용 - 다중 컬럼 선택으로 계층적 그룹화 지원 - 그룹 설정 다이얼로그 추가 - 그룹별 데이터 펼치기/접기 기능 - 그룹 헤더에 항목 개수 표시 - localStorage에 그룹 설정 저장/복원 - 그룹 해제 버튼 추가 - 그룹 표시 배지 UI 주요 기능: - 플로우 스텝 데이터에 그룹화 적용 - filteredData와 stepData 모두 지원 - 그룹 없을 때는 기존 페이지네이션 유지 - 그룹 있을 때는 모든 그룹 데이터 표시
This commit is contained in:
parent
b607ef0aa0
commit
eb9c85f786
|
|
@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { FlowComponent } from "@/types/screen-management";
|
import { FlowComponent } from "@/types/screen-management";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AlertCircle, Loader2, ChevronUp, Filter, X } from "lucide-react";
|
import { AlertCircle, Loader2, ChevronUp, Filter, X, Layers, ChevronDown, ChevronRight } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getFlowById,
|
getFlowById,
|
||||||
getAllStepCounts,
|
getAllStepCounts,
|
||||||
|
|
@ -40,6 +40,14 @@ import { Input } from "@/components/ui/input";
|
||||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
|
||||||
|
// 그룹화된 데이터 인터페이스
|
||||||
|
interface GroupedData {
|
||||||
|
groupKey: string;
|
||||||
|
groupValues: Record<string, any>;
|
||||||
|
items: any[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface FlowWidgetProps {
|
interface FlowWidgetProps {
|
||||||
component: FlowComponent;
|
component: FlowComponent;
|
||||||
onStepClick?: (stepId: number, stepName: string) => void;
|
onStepClick?: (stepId: number, stepName: string) => void;
|
||||||
|
|
@ -106,6 +114,11 @@ export function FlowWidget({
|
||||||
const [allAvailableColumns, setAllAvailableColumns] = useState<string[]>([]); // 전체 컬럼 목록
|
const [allAvailableColumns, setAllAvailableColumns] = useState<string[]>([]); // 전체 컬럼 목록
|
||||||
const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터
|
const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터
|
||||||
|
|
||||||
|
// 🆕 그룹 설정 관련 상태
|
||||||
|
const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그
|
||||||
|
const [groupByColumns, setGroupByColumns] = useState<string[]>([]); // 그룹화할 컬럼 목록
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set()); // 접힌 그룹
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🆕 컬럼 표시 결정 함수
|
* 🆕 컬럼 표시 결정 함수
|
||||||
* 1순위: 플로우 스텝 기본 설정 (displayConfig)
|
* 1순위: 플로우 스텝 기본 설정 (displayConfig)
|
||||||
|
|
@ -163,43 +176,30 @@ export function FlowWidget({
|
||||||
// 초기값: 빈 필터 (사용자가 선택해야 함)
|
// 초기값: 빈 필터 (사용자가 선택해야 함)
|
||||||
setSearchFilterColumns(new Set());
|
setSearchFilterColumns(new Set());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이전 사용자의 필터 설정 정리 (사용자 ID가 다른 키들 제거)
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const currentUserId = user.userId;
|
|
||||||
const keysToRemove: string[] = [];
|
|
||||||
|
|
||||||
// localStorage의 모든 키를 확인
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
|
||||||
if (key && key.startsWith("flowWidget_searchFilters_")) {
|
|
||||||
// 키 형식: flowWidget_searchFilters_${userId}_${flowId}_${stepId}
|
|
||||||
// split("_")를 하면 ["flowWidget", "searchFilters", "사용자ID", "플로우ID", "스텝ID"]
|
|
||||||
// 따라서 userId는 parts[2]입니다
|
|
||||||
const parts = key.split("_");
|
|
||||||
if (parts.length >= 3) {
|
|
||||||
const userIdFromKey = parts[2]; // flowWidget_searchFilters_ 다음이 userId
|
|
||||||
// 현재 사용자 ID와 다른 사용자의 설정은 제거
|
|
||||||
if (userIdFromKey !== currentUserId) {
|
|
||||||
keysToRemove.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이전 사용자의 설정 제거
|
|
||||||
if (keysToRemove.length > 0) {
|
|
||||||
keysToRemove.forEach(key => {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("필터 설정 불러오기 실패:", error);
|
console.error("필터 설정 불러오기 실패:", error);
|
||||||
setSearchFilterColumns(new Set());
|
setSearchFilterColumns(new Set());
|
||||||
}
|
}
|
||||||
}, [filterSettingKey, stepDataColumns, user?.userId]);
|
}, [filterSettingKey, stepDataColumns, user?.userId]);
|
||||||
|
|
||||||
|
// 🆕 저장된 그룹 설정 불러오기
|
||||||
|
useEffect(() => {
|
||||||
|
if (!groupSettingKey || stepDataColumns.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(groupSettingKey);
|
||||||
|
if (saved) {
|
||||||
|
const savedGroups = JSON.parse(saved);
|
||||||
|
// 현재 단계에 표시되는 컬럼만 필터링
|
||||||
|
const validGroups = savedGroups.filter((col: string) => stepDataColumns.includes(col));
|
||||||
|
setGroupByColumns(validGroups);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("그룹 설정 불러오기 실패:", error);
|
||||||
|
setGroupByColumns([]);
|
||||||
|
}
|
||||||
|
}, [groupSettingKey, stepDataColumns]);
|
||||||
|
|
||||||
// 🆕 필터 설정 저장
|
// 🆕 필터 설정 저장
|
||||||
const saveFilterSettings = useCallback(() => {
|
const saveFilterSettings = useCallback(() => {
|
||||||
if (!filterSettingKey) return;
|
if (!filterSettingKey) return;
|
||||||
|
|
@ -247,6 +247,98 @@ export function FlowWidget({
|
||||||
setFilteredData([]);
|
setFilteredData([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 🆕 그룹 설정 localStorage 키 생성
|
||||||
|
const groupSettingKey = useMemo(() => {
|
||||||
|
if (!selectedStep) return null;
|
||||||
|
return `flowWidget_groupSettings_step_${selectedStep}`;
|
||||||
|
}, [selectedStep]);
|
||||||
|
|
||||||
|
// 🆕 그룹 설정 저장
|
||||||
|
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[] => {
|
||||||
|
const dataToGroup = filteredData.length > 0 ? filteredData : stepData;
|
||||||
|
|
||||||
|
if (groupByColumns.length === 0 || dataToGroup.length === 0) return [];
|
||||||
|
|
||||||
|
const grouped = new Map<string, any[]>();
|
||||||
|
|
||||||
|
dataToGroup.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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [filteredData, stepData, groupByColumns, columnLabels]);
|
||||||
|
|
||||||
// 🆕 검색 값이 변경될 때마다 자동 검색 (useEffect로 직접 처리)
|
// 🆕 검색 값이 변경될 때마다 자동 검색 (useEffect로 직접 처리)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!stepData || stepData.length === 0) {
|
if (!stepData || stepData.length === 0) {
|
||||||
|
|
@ -796,6 +888,7 @@ export function FlowWidget({
|
||||||
|
|
||||||
{/* 🆕 필터 설정 버튼 */}
|
{/* 🆕 필터 설정 버튼 */}
|
||||||
{stepDataColumns.length > 0 && (
|
{stepDataColumns.length > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -816,9 +909,61 @@ export function FlowWidget({
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (isPreviewMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsGroupSettingOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={isPreviewMode}
|
||||||
|
className="h-8 shrink-0 text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Layers className="mr-2 h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
|
그룹 설정
|
||||||
|
{groupByColumns.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-2 h-5 px-1.5 text-[10px]">
|
||||||
|
{groupByColumns.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 🆕 그룹 표시 배지 */}
|
||||||
|
{groupByColumns.length > 0 && (
|
||||||
|
<div className="border-b border-border bg-muted/30 px-4 py-2">
|
||||||
|
<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={() => {
|
||||||
|
if (!isPreviewMode) {
|
||||||
|
clearGrouping();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isPreviewMode}
|
||||||
|
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
|
||||||
|
title="그룹 해제"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 🆕 검색 필터 입력 영역 */}
|
{/* 🆕 검색 필터 입력 영역 */}
|
||||||
{searchFilterColumns.size > 0 && (
|
{searchFilterColumns.size > 0 && (
|
||||||
<div className="mt-2 space-y-3 p-4">
|
<div className="mt-2 space-y-3 p-4">
|
||||||
|
|
@ -940,7 +1085,64 @@ export function FlowWidget({
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{paginatedStepData.map((row, pageIndex) => {
|
{groupByColumns.length > 0 && groupedData.length > 0 ? (
|
||||||
|
// 그룹화된 렌더링
|
||||||
|
groupedData.flatMap((group) => {
|
||||||
|
const isCollapsed = collapsedGroups.has(group.groupKey);
|
||||||
|
const groupRows = [
|
||||||
|
<TableRow key={`group-${group.groupKey}`}>
|
||||||
|
<TableCell
|
||||||
|
colSpan={stepDataColumns.length + (allowDataMove ? 1 : 0)}
|
||||||
|
className="bg-muted/50 border-b"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 p-2 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>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!isCollapsed) {
|
||||||
|
const dataRows = group.items.map((row, itemIndex) => {
|
||||||
|
const actualIndex = displayData.indexOf(row);
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={`${group.groupKey}-${itemIndex}`}
|
||||||
|
className={`h-16 transition-colors hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`}
|
||||||
|
>
|
||||||
|
{allowDataMove && (
|
||||||
|
<TableCell className="bg-background sticky left-0 z-10 border-b px-6 py-3 text-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRows.has(actualIndex)}
|
||||||
|
onCheckedChange={() => toggleRowSelection(actualIndex)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{stepDataColumns.map((col) => (
|
||||||
|
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
|
||||||
|
{formatValue(row[col])}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
groupRows.push(...dataRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupRows;
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
// 일반 렌더링 (그룹 없음)
|
||||||
|
paginatedStepData.map((row, pageIndex) => {
|
||||||
const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex;
|
const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex;
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
|
|
@ -962,7 +1164,8 @@ export function FlowWidget({
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1162,6 +1365,63 @@ export function FlowWidget({
|
||||||
</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">
|
||||||
|
{stepDataColumns.map((col) => (
|
||||||
|
<div key={col} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`group-${col}`}
|
||||||
|
checked={groupByColumns.includes(col)}
|
||||||
|
onCheckedChange={() => toggleGroupColumn(col)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`group-${col}`} className="flex-1 cursor-pointer text-xs font-normal sm:text-sm">
|
||||||
|
{columnLabels[col] || col}
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue