ERP-node/frontend/components/screen/widgets/FlowWidget.tsx

1432 lines
57 KiB
TypeScript

"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { FlowComponent } from "@/types/screen-management";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { AlertCircle, Loader2, ChevronUp, Filter, X, Layers, ChevronDown, ChevronRight } from "lucide-react";
import {
getFlowById,
getAllStepCounts,
getStepDataList,
getFlowSteps,
getFlowConnections,
getStepColumnLabels,
} from "@/lib/api/flow";
import type { FlowDefinition, FlowStep } from "@/types/flow";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { useFlowStepStore } from "@/stores/flowStepStore";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options";
// 그룹화된 데이터 인터페이스
interface GroupedData {
groupKey: string;
groupValues: Record<string, any>;
items: any[];
count: number;
}
interface FlowWidgetProps {
component: FlowComponent;
onStepClick?: (stepId: number, stepName: string) => void;
onSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
flowRefreshKey?: number; // 새로고침 키
onFlowRefresh?: () => void; // 새로고침 완료 콜백
}
export function FlowWidget({
component,
onStepClick,
onSelectedDataChange,
flowRefreshKey,
onFlowRefresh,
}: FlowWidgetProps) {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { user } = useAuth(); // 사용자 정보 가져오기
const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
// TableOptions 상태
const [filters, setFilters] = useState<TableFilter[]>([]);
const [grouping, setGrouping] = useState<string[]>([]);
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>([]);
// 숫자 포맷팅 함수
const formatValue = (value: any): string => {
if (value === null || value === undefined || value === "") {
return "-";
}
// 숫자 타입이거나 숫자로 변환 가능한 문자열인 경우 포맷팅
if (typeof value === "number") {
return value.toLocaleString("ko-KR");
}
if (typeof value === "string") {
const numValue = parseFloat(value);
// 숫자로 변환 가능하고, 변환 후 원래 값과 같은 경우에만 포맷팅
if (!isNaN(numValue) && numValue.toString() === value.trim()) {
return numValue.toLocaleString("ko-KR");
}
}
return String(value);
};
// 🆕 전역 상태 관리
const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep);
const resetFlow = useFlowStepStore((state) => state.resetFlow);
const [flowData, setFlowData] = useState<FlowDefinition | null>(null);
const [steps, setSteps] = useState<FlowStep[]>([]);
const [stepCounts, setStepCounts] = useState<Record<number, number>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [connections, setConnections] = useState<any[]>([]); // 플로우 연결 정보
// 선택된 스텝의 데이터 리스트 상태
const [selectedStepId, setSelectedStepId] = useState<number | null>(null);
const [stepData, setStepData] = useState<any[]>([]);
const [stepDataColumns, setStepDataColumns] = useState<string[]>([]);
const [stepDataLoading, setStepDataLoading] = useState(false);
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
// 🆕 검색 필터 관련 상태
const [searchFilterColumns, setSearchFilterColumns] = useState<Set<string>>(new Set()); // 검색 필터로 사용할 컬럼
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); // 필터 설정 다이얼로그
const [searchValues, setSearchValues] = useState<Record<string, string>>({}); // 검색 값
const [allAvailableColumns, setAllAvailableColumns] = useState<string[]>([]); // 전체 컬럼 목록
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)
* 2순위: 모든 컬럼 표시
*/
const getVisibleColumns = (stepId: number, allColumns: string[], stepsArray?: FlowStep[]): string[] => {
// stepsArray가 제공되지 않으면 state의 steps 사용
const effectiveSteps = stepsArray || steps;
// 1순위: 플로우 스텝 기본 설정
const currentStep = effectiveSteps.find((s) => s.id === stepId);
if (currentStep?.displayConfig?.visibleColumns && currentStep.displayConfig.visibleColumns.length > 0) {
return currentStep.displayConfig.visibleColumns;
}
// 2순위: 모든 컬럼 표시
return allColumns;
};
// 🆕 스텝 데이터 페이지네이션 상태
const [stepDataPage, setStepDataPage] = useState(1);
const [stepDataPageSize, setStepDataPageSize] = useState(10);
// componentConfig에서 플로우 설정 추출 (DynamicComponentRenderer에서 전달됨)
const config = (component as any).componentConfig || (component as any).config || {};
const flowId = config.flowId || component.flowId;
const flowName = config.flowName || component.flowName;
const displayMode = config.displayMode || component.displayMode || "horizontal";
const showStepCount = config.showStepCount !== false && component.showStepCount !== false; // 기본값 true
const allowDataMove = config.allowDataMove || component.allowDataMove || false;
// 🆕 플로우 컴포넌트 ID (버튼이 이 플로우를 참조할 때 사용)
const flowComponentId = component.id;
// 🆕 localStorage 키 생성 (사용자별로 저장)
const filterSettingKey = useMemo(() => {
if (!flowId || selectedStepId === null || !user?.userId) return null;
return `flowWidget_searchFilters_${user.userId}_${flowId}_${selectedStepId}`;
}, [flowId, selectedStepId, user?.userId]);
// 🆕 그룹 설정 localStorage 키 생성
const groupSettingKey = useMemo(() => {
if (!selectedStepId) return null;
return `flowWidget_groupSettings_step_${selectedStepId}`;
}, [selectedStepId]);
// 🆕 저장된 필터 설정 불러오기
useEffect(() => {
if (!filterSettingKey || stepDataColumns.length === 0 || !user?.userId) return;
try {
// 현재 사용자의 필터 설정만 불러오기
const saved = localStorage.getItem(filterSettingKey);
if (saved) {
const savedFilters = JSON.parse(saved);
// 현재 단계에 표시되는 컬럼만 필터링
const validFilters = savedFilters.filter((col: string) => stepDataColumns.includes(col));
setSearchFilterColumns(new Set(validFilters));
} else {
// 초기값: 빈 필터 (사용자가 선택해야 함)
setSearchFilterColumns(new Set());
}
} catch (error) {
console.error("필터 설정 불러오기 실패:", error);
setSearchFilterColumns(new Set());
}
}, [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(() => {
if (!filterSettingKey) return;
try {
localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(searchFilterColumns)));
setIsFilterSettingOpen(false);
toast.success("검색 필터 설정이 저장되었습니다");
// 검색 값 초기화
setSearchValues({});
} catch (error) {
console.error("필터 설정 저장 실패:", error);
toast.error("설정 저장에 실패했습니다");
}
}, [filterSettingKey, searchFilterColumns]);
// 🆕 필터 컬럼 토글
const toggleFilterColumn = useCallback((columnName: string) => {
setSearchFilterColumns((prev) => {
const newSet = new Set(prev);
if (newSet.has(columnName)) {
newSet.delete(columnName);
} else {
newSet.add(columnName);
}
return newSet;
});
}, []);
// 🆕 전체 선택/해제
const toggleAllFilters = useCallback(() => {
if (searchFilterColumns.size === stepDataColumns.length) {
// 전체 해제
setSearchFilterColumns(new Set());
} else {
// 전체 선택
setSearchFilterColumns(new Set(stepDataColumns));
}
}, [searchFilterColumns, stepDataColumns]);
// 🆕 검색 초기화
const handleClearSearch = useCallback(() => {
setSearchValues({});
setFilteredData([]);
}, []);
// 🆕 그룹 설정 저장
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]);
// 테이블 등록 (선택된 스텝이 있을 때)
useEffect(() => {
if (!selectedStepId || !stepDataColumns || stepDataColumns.length === 0) {
return;
}
const tableId = `flow-widget-${component.id}-step-${selectedStepId}`;
const currentStep = steps.find((s) => s.id === selectedStepId);
registerTable({
tableId,
label: `${flowName || "플로우"} - ${currentStep?.name || "스텝"}`,
tableName: "flow_step_data",
columns: stepDataColumns.map((col) => ({
columnName: col,
columnLabel: columnLabels[col] || col,
inputType: "text",
visible: true,
width: 150,
sortable: true,
filterable: true,
})),
onFilterChange: setFilters,
onGroupChange: setGrouping,
onColumnVisibilityChange: setColumnVisibility,
});
return () => unregisterTable(tableId);
}, [selectedStepId, stepDataColumns, columnLabels, flowName, steps, component.id]);
// 🆕 데이터 그룹화
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(() => {
if (!stepData || stepData.length === 0) {
setFilteredData([]);
return;
}
// 검색 값이 하나라도 있는지 확인
const hasSearchValue = Object.values(searchValues).some((val) => val && String(val).trim() !== "");
if (!hasSearchValue) {
// 검색 값이 없으면 필터링 해제
setFilteredData([]);
return;
}
// 필터링 실행
const filtered = stepData.filter((row) => {
// 모든 검색 조건을 만족하는지 확인
return Object.entries(searchValues).every(([col, searchValue]) => {
if (!searchValue || String(searchValue).trim() === "") return true; // 빈 값은 필터링하지 않음
const cellValue = row[col];
if (cellValue === null || cellValue === undefined) return false;
// 문자열로 변환하여 대소문자 무시 검색
return String(cellValue).toLowerCase().includes(String(searchValue).toLowerCase());
});
});
setFilteredData(filtered);
console.log("🔍 검색 실행:", {
totalRows: stepData.length,
filteredRows: filtered.length,
searchValues,
hasSearchValue,
});
}, [searchValues, stepData]); // stepData와 searchValues가 변경될 때마다 실행
// 선택된 스텝의 데이터를 다시 로드하는 함수
const refreshStepData = async () => {
if (!flowId) return;
try {
// 스텝 카운트는 항상 업데이트 (선택된 스텝 유무와 관계없이)
const countsResponse = await getAllStepCounts(flowId);
if (countsResponse.success && countsResponse.data) {
// Record 형태로 변환
const countsMap: Record<number, number> = {};
if (Array.isArray(countsResponse.data)) {
countsResponse.data.forEach((item: any) => {
countsMap[item.stepId] = item.count;
});
} else if (typeof countsResponse.data === "object") {
Object.assign(countsMap, countsResponse.data);
}
setStepCounts(countsMap);
}
// 선택된 스텝이 있으면 해당 스텝의 데이터도 새로고침
if (selectedStepId) {
setStepDataLoading(true);
// 컬럼 라벨 조회
const labelsResponse = await getStepColumnLabels(flowId, selectedStepId);
if (labelsResponse.success && labelsResponse.data) {
setColumnLabels(labelsResponse.data);
}
const response = await getStepDataList(flowId, selectedStepId, 1, 100);
if (!response.success) {
throw new Error(response.message || "데이터를 불러올 수 없습니다");
}
const rows = response.data?.records || [];
setStepData(rows);
// 🆕 컬럼 추출 및 우선순위 적용
if (rows.length > 0) {
const allColumns = Object.keys(rows[0]);
setAllAvailableColumns(allColumns); // 전체 컬럼 목록 저장
const visibleColumns = getVisibleColumns(selectedStepId, allColumns);
setStepDataColumns(visibleColumns);
} else {
setAllAvailableColumns([]);
setStepDataColumns([]);
}
// 선택 초기화
setSelectedRows(new Set());
setSearchValues({}); // 검색 값도 초기화
setFilteredData([]); // 필터링된 데이터 초기화
onSelectedDataChange?.([], selectedStepId);
}
} catch (err: any) {
console.error("❌ 플로우 새로고침 실패:", err);
toast.error(err.message || "데이터를 새로고치는데 실패했습니다");
} finally {
if (selectedStepId) {
setStepDataLoading(false);
}
}
};
useEffect(() => {
if (!flowId) {
setLoading(false);
return;
}
const loadFlowData = async () => {
try {
setLoading(true);
setError(null);
// 프리뷰 모드에서는 샘플 데이터만 표시
if (isPreviewMode) {
console.log("🔒 프리뷰 모드: 플로우 데이터 로드 차단 - 샘플 데이터 표시");
setFlowData({
id: flowId || 0,
flowName: flowName || "샘플 플로우",
description: "프리뷰 모드 샘플",
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as FlowDefinition);
const sampleSteps: FlowStep[] = [
{
id: 1,
flowId: flowId || 0,
stepName: "시작 단계",
stepOrder: 1,
stepType: "start",
stepConfig: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: 2,
flowId: flowId || 0,
stepName: "진행 중",
stepOrder: 2,
stepType: "process",
stepConfig: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: 3,
flowId: flowId || 0,
stepName: "완료",
stepOrder: 3,
stepType: "end",
stepConfig: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
setSteps(sampleSteps);
setStepCounts({ 1: 5, 2: 3, 3: 2 });
setConnections([]);
setLoading(false);
return;
}
// 플로우 정보 조회
const flowResponse = await getFlowById(flowId!);
if (!flowResponse.success || !flowResponse.data) {
throw new Error("플로우를 찾을 수 없습니다");
}
setFlowData(flowResponse.data);
// 스텝 목록 조회
const stepsResponse = await getFlowSteps(flowId);
if (!stepsResponse.success) {
throw new Error("스텝 목록을 불러올 수 없습니다");
}
if (stepsResponse.data) {
const sortedSteps = stepsResponse.data.sort((a: FlowStep, b: FlowStep) => a.stepOrder - b.stepOrder);
setSteps(sortedSteps);
// 연결 정보 조회
const connectionsResponse = await getFlowConnections(flowId);
if (connectionsResponse.success && connectionsResponse.data) {
setConnections(connectionsResponse.data);
}
// 스텝별 데이터 건수 조회
if (showStepCount) {
const countsResponse = await getAllStepCounts(flowId!);
if (countsResponse.success && countsResponse.data) {
// 배열을 Record<number, number>로 변환
const countsMap: Record<number, number> = {};
countsResponse.data.forEach((item: any) => {
countsMap[item.stepId] = item.count;
});
setStepCounts(countsMap);
}
}
// 🆕 플로우 로드 후 첫 번째 스텝 자동 선택
if (sortedSteps.length > 0) {
const firstStep = sortedSteps[0];
setSelectedStepId(firstStep.id);
setSelectedStep(flowComponentId, firstStep.id);
// 첫 번째 스텝의 데이터 로드
try {
// 컬럼 라벨 조회
const labelsResponse = await getStepColumnLabels(flowId!, firstStep.id);
if (labelsResponse.success && labelsResponse.data) {
setColumnLabels(labelsResponse.data);
}
const response = await getStepDataList(flowId!, firstStep.id, 1, 100);
if (response.success) {
const rows = response.data?.records || [];
setStepData(rows);
if (rows.length > 0) {
const allColumns = Object.keys(rows[0]);
setAllAvailableColumns(allColumns); // 전체 컬럼 목록 저장
// sortedSteps를 직접 전달하여 타이밍 이슈 해결
const visibleColumns = getVisibleColumns(firstStep.id, allColumns, sortedSteps);
setStepDataColumns(visibleColumns);
}
}
} catch (err) {
console.error("첫 번째 스텝 데이터 로드 실패:", err);
}
}
}
} catch (err: any) {
console.error("Failed to load flow data:", err);
setError(err.message || "플로우 데이터를 불러오는데 실패했습니다");
} finally {
setLoading(false);
}
};
loadFlowData();
}, [flowId, showStepCount]);
// flowRefreshKey가 변경될 때마다 스텝 데이터 새로고침
useEffect(() => {
if (flowRefreshKey !== undefined && flowRefreshKey > 0 && flowId) {
refreshStepData();
}
}, [flowRefreshKey]);
// 🆕 언마운트 시 전역 상태 초기화
useEffect(() => {
return () => {
resetFlow(flowComponentId);
};
}, [flowComponentId, resetFlow]);
// 🆕 스텝 클릭 핸들러 (전역 상태 업데이트 추가)
const handleStepClick = async (stepId: number, stepName: string) => {
// 프리뷰 모드에서는 스텝 클릭 차단
if (isPreviewMode) {
return;
}
// 외부 콜백 실행
if (onStepClick) {
onStepClick(stepId, stepName);
}
// 같은 스텝을 다시 클릭하면 접기
if (selectedStepId === stepId) {
setSelectedStepId(null);
setSelectedStep(flowComponentId, null); // 🆕 전역 상태 업데이트
setStepData([]);
setStepDataColumns([]);
setSelectedRows(new Set());
setStepDataPage(1); // 🆕 페이지 리셋
onSelectedDataChange?.([], null);
return;
}
// 새로운 스텝 선택 - 데이터 로드
setSelectedStepId(stepId);
setSelectedStep(flowComponentId, stepId); // 🆕 전역 상태 업데이트
setStepDataLoading(true);
setSelectedRows(new Set());
setStepDataPage(1); // 🆕 페이지 리셋
onSelectedDataChange?.([], stepId);
try {
// 컬럼 라벨 조회
const labelsResponse = await getStepColumnLabels(flowId!, stepId);
console.log("🏷️ 컬럼 라벨 조회 결과:", {
stepId,
success: labelsResponse.success,
labelsCount: labelsResponse.data ? Object.keys(labelsResponse.data).length : 0,
labels: labelsResponse.data,
});
if (labelsResponse.success && labelsResponse.data) {
setColumnLabels(labelsResponse.data);
} else {
console.warn("⚠️ 컬럼 라벨 조회 실패 또는 데이터 없음:", labelsResponse);
setColumnLabels({});
}
// 데이터 조회
const response = await getStepDataList(flowId!, stepId, 1, 100);
if (!response.success) {
throw new Error(response.message || "데이터를 불러올 수 없습니다");
}
const rows = response.data?.records || [];
setStepData(rows);
// 🆕 컬럼 추출 및 우선순위 적용
if (rows.length > 0) {
const allColumns = Object.keys(rows[0]);
setAllAvailableColumns(allColumns); // 전체 컬럼 목록 저장
const visibleColumns = getVisibleColumns(stepId, allColumns);
setStepDataColumns(visibleColumns);
} else {
setAllAvailableColumns([]);
setStepDataColumns([]);
}
} catch (err: any) {
console.error("Failed to load step data:", err);
toast.error(err.message || "데이터를 불러오는데 실패했습니다");
} finally {
setStepDataLoading(false);
}
};
// 체크박스 토글
const toggleRowSelection = (rowIndex: number) => {
// 프리뷰 모드에서는 행 선택 차단
if (isPreviewMode) {
return;
}
const newSelected = new Set(selectedRows);
if (newSelected.has(rowIndex)) {
newSelected.delete(rowIndex);
} else {
newSelected.add(rowIndex);
}
setSelectedRows(newSelected);
// 선택된 데이터를 상위로 전달
const selectedData = Array.from(newSelected).map((index) => stepData[index]);
console.log("🌊 FlowWidget - 체크박스 토글, 상위로 전달:", {
rowIndex,
newSelectedSize: newSelected.size,
selectedData,
selectedStepId,
hasCallback: !!onSelectedDataChange,
});
onSelectedDataChange?.(selectedData, selectedStepId);
};
// 전체 선택/해제
const toggleAllRows = () => {
let newSelected: Set<number>;
if (selectedRows.size === stepData.length) {
newSelected = new Set();
} else {
newSelected = new Set(stepData.map((_, index) => index));
}
setSelectedRows(newSelected);
// 선택된 데이터를 상위로 전달
const selectedData = Array.from(newSelected).map((index) => stepData[index]);
onSelectedDataChange?.(selectedData, selectedStepId);
};
// 🆕 표시할 데이터 결정
// - 검색 값이 있으면 → filteredData 사용 (결과가 0건이어도 filteredData 사용)
// - 검색 값이 없으면 → stepData 사용 (전체 데이터)
const hasSearchValue = Object.values(searchValues).some((val) => val && String(val).trim() !== "");
const displayData = hasSearchValue ? filteredData : stepData;
// 🆕 페이지네이션된 스텝 데이터
const paginatedStepData = displayData.slice((stepDataPage - 1) * stepDataPageSize, stepDataPage * stepDataPageSize);
const totalStepDataPages = Math.ceil(displayData.length / stepDataPageSize);
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
);
}
if (error) {
return (
<div className="border-destructive/50 bg-destructive/10 flex items-center gap-2 rounded-lg border p-4">
<AlertCircle className="text-destructive h-5 w-5" />
<span className="text-destructive text-sm">{error}</span>
</div>
);
}
if (!flowId || !flowData) {
return (
<div className="border-muted-foreground/25 flex items-center justify-center rounded-lg border-2 border-dashed p-8">
<span className="text-muted-foreground text-sm"> </span>
</div>
);
}
if (steps.length === 0) {
return (
<div className="border-muted flex items-center justify-center rounded-lg border p-8">
<span className="text-muted-foreground text-sm"> </span>
</div>
);
}
// 반응형 컨테이너 클래스
const containerClass =
displayMode === "horizontal"
? "flex flex-col sm:flex-row sm:flex-wrap items-center justify-center gap-3 sm:gap-4"
: "flex flex-col items-center gap-4";
return (
<div className="@container flex w-full flex-col p-2 sm:p-4 lg:p-6">
{/* 플로우 스텝 목록 */}
<div className={`${containerClass} flex-shrink-0`}>
{steps.map((step, index) => (
<React.Fragment key={step.id}>
{/* 스텝 카드 */}
<div
className="group relative w-full cursor-pointer pb-4 transition-all duration-300 sm:w-auto sm:min-w-[200px] lg:min-w-[240px]"
onClick={() => handleStepClick(step.id, step.stepName)}
>
{/* 콘텐츠 */}
<div className="relative flex flex-col items-center justify-center gap-2 pb-5 sm:gap-2.5 sm:pb-6">
{/* 스텝 이름 */}
<h4
className={`text-base font-semibold leading-tight transition-colors duration-300 sm:text-lg lg:text-xl ${
selectedStepId === step.id ? "text-primary" : "text-foreground group-hover:text-primary/80"
}`}
>
{step.stepName}
</h4>
{/* 데이터 건수 */}
{showStepCount && (
<div
className={`flex items-center gap-1.5 transition-all duration-300 ${
selectedStepId === step.id
? "text-primary"
: "text-muted-foreground group-hover:text-primary"
}`}
>
<span className="text-sm font-medium sm:text-base">
{(stepCounts[step.id] || 0).toLocaleString("ko-KR")}
</span>
<span className="text-xs font-normal sm:text-sm"></span>
</div>
)}
</div>
{/* 하단 선 */}
<div
className={`h-0.5 transition-all duration-300 ${
selectedStepId === step.id
? "bg-primary"
: "bg-border group-hover:bg-primary/50"
}`}
/>
</div>
{/* 화살표 (마지막 스텝 제외) */}
{index < steps.length - 1 && (
<div className="flex shrink-0 items-center justify-center py-2 sm:py-0">
{displayMode === "horizontal" ? (
<div className="flex items-center gap-1">
<div className="h-0.5 w-6 bg-border sm:w-8" />
<svg
className="h-4 w-4 text-muted-foreground sm:h-5 sm:w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<div className="h-0.5 w-6 bg-border sm:w-8" />
</div>
) : (
<div className="flex flex-col items-center gap-1">
<div className="h-6 w-0.5 bg-border sm:h-8" />
<svg
className="h-4 w-4 text-muted-foreground sm:h-5 sm:w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
<div className="h-6 w-0.5 bg-border sm:h-8" />
</div>
)}
</div>
)}
</React.Fragment>
))}
</div>
{/* 선택된 스텝의 데이터 리스트 */}
{selectedStepId !== null && (
<div className="mt-4 flex w-full flex-col sm:mt-6 lg:mt-8">
{/* 필터 및 그룹 설정 */}
{stepDataColumns.length > 0 && (
<>
<div className="bg-background flex-shrink-0 border-b px-4 py-3 sm:px-6 sm:py-4">
<div className="flex items-center gap-2 flex-wrap">
{/* 검색 필터 입력 영역 */}
{searchFilterColumns.size > 0 && (
<>
{Array.from(searchFilterColumns).map((col) => (
<Input
key={col}
value={searchValues[col] || ""}
onChange={(e) =>
setSearchValues((prev) => ({
...prev,
[col]: e.target.value,
}))
}
placeholder={`${columnLabels[col] || col} 검색...`}
className="h-8 text-xs w-40"
/>
))}
{Object.keys(searchValues).length > 0 && (
<Button variant="ghost" size="sm" onClick={handleClearSearch} className="h-8 text-xs">
<X className="mr-1 h-3 w-3" />
</Button>
)}
</>
)}
{/* 필터/그룹 설정 버튼 */}
<div className="flex gap-2 ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => {
if (isPreviewMode) {
return;
}
setIsFilterSettingOpen(true);
}}
disabled={isPreviewMode}
className="h-8 shrink-0 text-xs sm:text-sm"
>
<Filter className="mr-2 h-3 w-3 sm:h-4 sm:w-4" />
{searchFilterColumns.size > 0 && (
<Badge variant="secondary" className="ml-2 h-5 px-1.5 text-[10px]">
{searchFilterColumns.size}
</Badge>
)}
</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>
)}
</>
)}
{/* 데이터 영역 - 고정 높이 + 스크롤 */}
{stepDataLoading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin sm:h-8 sm:w-8" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
) : stepData.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center">
<svg
className="text-muted-foreground/50 mb-3 h-12 w-12"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<span className="text-muted-foreground text-sm"> </span>
</div>
) : (
<>
{/* 모바일: 카드 뷰 - 고정 높이 + 스크롤 */}
<div className="overflow-y-auto @sm:hidden" style={{ height: "450px" }}>
<div className="space-y-2 p-3">
{paginatedStepData.map((row, pageIndex) => {
const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex;
return (
<div
key={actualIndex}
className={`bg-card rounded-md border p-3 transition-colors ${
selectedRows.has(actualIndex) ? "bg-primary/5 border-primary/30" : ""
}`}
>
{allowDataMove && (
<div className="mb-2 flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground text-xs font-medium"></span>
<Checkbox
checked={selectedRows.has(actualIndex)}
onCheckedChange={() => toggleRowSelection(actualIndex)}
/>
</div>
)}
<div className="space-y-1.5">
{stepDataColumns.map((col) => (
<div key={col} className="flex justify-between gap-2 text-xs">
<span className="text-muted-foreground font-medium">{columnLabels[col] || col}:</span>
<span className="text-foreground truncate">{formatValue(row[col])}</span>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
{/* 데스크톱: 테이블 뷰 - 고정 높이 + 스크롤 */}
<div className="relative hidden overflow-auto @sm:block" style={{ height: "450px" }}>
<Table noWrapper>
<TableHeader>
<TableRow className="hover:bg-muted/50">
{allowDataMove && (
<TableHead className="bg-background sticky top-0 left-0 z-20 w-12 border-b px-6 py-3 text-center shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
<Checkbox
checked={selectedRows.size === stepData.length && stepData.length > 0}
onCheckedChange={toggleAllRows}
/>
</TableHead>
)}
{stepDataColumns.map((col) => (
<TableHead
key={col}
className="bg-background sticky top-0 z-10 border-b px-6 py-3 text-sm font-semibold whitespace-nowrap shadow-[0_1px_0_0_rgb(0,0,0,0.1)]"
>
{columnLabels[col] || col}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{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;
return (
<TableRow
key={actualIndex}
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>
);
})
)}
</TableBody>
</Table>
</div>
</>
)}
{/* 페이지네이션 - 항상 하단에 고정 */}
{!stepDataLoading && stepData.length > 0 && (
<div className="bg-background flex-shrink-0 border-t px-4 py-3 sm:px-6">
<div className="flex flex-col items-center justify-between gap-3 sm:flex-row">
{/* 왼쪽: 페이지 정보 + 페이지 크기 선택 */}
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-4">
<div className="text-muted-foreground text-xs sm:text-sm">
{stepDataPage} / {totalStepDataPages} ( {stepData.length.toLocaleString("ko-KR")})
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs"> :</span>
<Select
value={stepDataPageSize.toString()}
onValueChange={(value) => {
setStepDataPageSize(Number(value));
setStepDataPage(1); // 페이지 크기 변경 시 첫 페이지로
}}
>
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 오른쪽: 페이지네이션 */}
{totalStepDataPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => {
if (isPreviewMode) {
return;
}
setStepDataPage((p) => Math.max(1, p - 1));
}}
className={
stepDataPage === 1 || isPreviewMode ? "pointer-events-none opacity-50" : "cursor-pointer"
}
/>
</PaginationItem>
{totalStepDataPages <= 7 ? (
Array.from({ length: totalStepDataPages }, (_, i) => i + 1).map((page) => (
<PaginationItem key={page}>
<PaginationLink
onClick={() => {
if (isPreviewMode) {
return;
}
setStepDataPage(page);
}}
isActive={stepDataPage === page}
className={isPreviewMode ? "pointer-events-none opacity-50" : "cursor-pointer"}
>
{page}
</PaginationLink>
</PaginationItem>
))
) : (
<>
{Array.from({ length: totalStepDataPages }, (_, i) => i + 1)
.filter((page) => {
return (
page === 1 ||
page === totalStepDataPages ||
(page >= stepDataPage - 2 && page <= stepDataPage + 2)
);
})
.map((page, idx, arr) => (
<React.Fragment key={page}>
{idx > 0 && arr[idx - 1] !== page - 1 && (
<PaginationItem>
<span className="text-muted-foreground px-2">...</span>
</PaginationItem>
)}
<PaginationItem>
<PaginationLink
onClick={() => {
if (isPreviewMode) {
return;
}
setStepDataPage(page);
}}
isActive={stepDataPage === page}
className={isPreviewMode ? "pointer-events-none opacity-50" : "cursor-pointer"}
>
{page}
</PaginationLink>
</PaginationItem>
</React.Fragment>
))}
</>
)}
<PaginationItem>
<PaginationNext
onClick={() => {
if (isPreviewMode) {
return;
}
setStepDataPage((p) => Math.min(totalStepDataPages, p + 1));
}}
className={
stepDataPage === totalStepDataPages || isPreviewMode
? "pointer-events-none opacity-50"
: "cursor-pointer"
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
</div>
)}
</div>
)}
{/* 🆕 검색 필터 설정 다이얼로그 */}
<Dialog open={isFilterSettingOpen} onOpenChange={setIsFilterSettingOpen}>
<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="bg-muted/50 flex items-center gap-3 rounded border p-3">
<Checkbox
id="select-all-filters"
checked={searchFilterColumns.size === stepDataColumns.length && stepDataColumns.length > 0}
onCheckedChange={toggleAllFilters}
/>
<Label htmlFor="select-all-filters" className="flex-1 cursor-pointer text-xs font-semibold sm:text-sm">
/
</Label>
<span className="text-muted-foreground text-xs">
{searchFilterColumns.size} / {stepDataColumns.length}
</span>
</div>
{/* 컬럼 목록 */}
<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={`filter-${col}`}
checked={searchFilterColumns.has(col)}
onCheckedChange={() => toggleFilterColumn(col)}
/>
<Label htmlFor={`filter-${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-center text-xs">
{searchFilterColumns.size === 0 ? (
<span> 1 </span>
) : (
<span>
<span className="text-primary font-semibold">{searchFilterColumns.size}</span>
</span>
)}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsFilterSettingOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button onClick={saveFilterSettings} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</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>
);
}