"use client"; import React, { useCallback, useEffect, useMemo, useState, useRef } 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, ChevronLeft, Edit, FileSpreadsheet, FileText, Copy, RefreshCw, } from "lucide-react"; import * as XLSX from "xlsx"; 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 { SingleTableWithSticky } from "@/lib/registry/components/table-list/SingleTableWithSticky"; import type { ColumnConfig } from "@/lib/registry/components/table-list/types"; 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; 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([]); const [grouping, setGrouping] = useState([]); const [columnVisibility, setColumnVisibility] = useState([]); // 숫자 포맷팅 함수 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(null); const [steps, setSteps] = useState([]); const [stepCounts, setStepCounts] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [connections, setConnections] = useState([]); // 플로우 연결 정보 // 선택된 스텝의 데이터 리스트 상태 const [selectedStepId, setSelectedStepId] = useState(null); const [stepData, setStepData] = useState([]); const [stepDataColumns, setStepDataColumns] = useState([]); const [stepDataLoading, setStepDataLoading] = useState(false); const [selectedRows, setSelectedRows] = useState>(new Set()); // Primary Key 값으로 선택 관리 const [columnLabels, setColumnLabels] = useState>({}); // 컬럼명 -> 라벨 매핑 // 🆕 검색 필터 관련 상태 const [searchFilterColumns, setSearchFilterColumns] = useState>(new Set()); // 검색 필터로 사용할 컬럼 const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); // 필터 설정 다이얼로그 const [searchValues, setSearchValues] = useState>({}); // 검색 값 const [allAvailableColumns, setAllAvailableColumns] = useState([]); // 전체 컬럼 목록 const [filteredData, setFilteredData] = useState([]); // 필터링된 데이터 // 🆕 그룹 설정 관련 상태 const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그 const [groupByColumns, setGroupByColumns] = useState([]); // 그룹화할 컬럼 목록 const [collapsedGroups, setCollapsedGroups] = useState>(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); // 🆕 정렬 상태 (SingleTableWithSticky용) const [sortColumn, setSortColumn] = useState(null); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); // 🆕 툴바 관련 상태 const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false); const [globalSearchTerm, setGlobalSearchTerm] = useState(""); const [searchHighlights, setSearchHighlights] = useState>(new Set()); const [currentSearchIndex, setCurrentSearchIndex] = useState(0); // 🆕 인라인 편집 관련 상태 const [editingCell, setEditingCell] = useState<{ rowIndex: number; colIndex: number; columnName: string; originalValue: any; } | null>(null); const [editingValue, setEditingValue] = useState(""); const editInputRef = useRef(null); // 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(); 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 = {}; 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 = {}; 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로 변환 const countsMap: Record = {}; 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); } }; // Primary Key 컬럼명 (플로우 정의에서 가져오거나 기본값 id) const primaryKeyColumn = flowData?.primaryKey || "id"; // 행의 Primary Key 값 가져오기 const getRowKey = useCallback((row: any): string => { const keyValue = row[primaryKeyColumn] || row.id; return String(keyValue); }, [primaryKeyColumn]); // 체크박스 토글 (Primary Key 기반) const toggleRowSelection = (row: any) => { // 프리뷰 모드에서는 행 선택 차단 if (isPreviewMode) { return; } const rowKey = getRowKey(row); const newSelected = new Set(selectedRows); if (newSelected.has(rowKey)) { newSelected.delete(rowKey); } else { newSelected.add(rowKey); } setSelectedRows(newSelected); // 선택된 데이터를 상위로 전달 (stepData에서 선택된 행들 찾기) const selectedData = stepData.filter((r) => newSelected.has(getRowKey(r))); console.log("🌊 FlowWidget - 체크박스 토글, 상위로 전달:", { rowKey, newSelectedSize: newSelected.size, selectedData, selectedStepId, hasCallback: !!onSelectedDataChange, }); onSelectedDataChange?.(selectedData, selectedStepId); }; // 전체 선택/해제 (Primary Key 기반) const toggleAllRows = () => { let newSelected: Set; if (selectedRows.size === stepData.length) { newSelected = new Set(); } else { newSelected = new Set(stepData.map((row) => getRowKey(row))); } setSelectedRows(newSelected); // 선택된 데이터를 상위로 전달 const selectedData = stepData.filter((row) => newSelected.has(getRowKey(row))); onSelectedDataChange?.(selectedData, selectedStepId); }; // 🆕 표시할 데이터 결정 // - 검색 값이 있으면 → filteredData 사용 (결과가 0건이어도 filteredData 사용) // - 검색 값이 없으면 → stepData 사용 (전체 데이터) const hasSearchValue = Object.values(searchValues).some((val) => val && String(val).trim() !== ""); const displayData = hasSearchValue ? filteredData : stepData; // 🆕 정렬된 데이터 const sortedDisplayData = useMemo(() => { if (!sortColumn) return displayData; return [...displayData].sort((a, b) => { const aVal = a[sortColumn]; const bVal = b[sortColumn]; // null/undefined 처리 if (aVal == null && bVal == null) return 0; if (aVal == null) return sortDirection === "asc" ? 1 : -1; if (bVal == null) return sortDirection === "asc" ? -1 : 1; // 숫자 비교 if (typeof aVal === "number" && typeof bVal === "number") { return sortDirection === "asc" ? aVal - bVal : bVal - aVal; } // 문자열 비교 const aStr = String(aVal).toLowerCase(); const bStr = String(bVal).toLowerCase(); if (sortDirection === "asc") { return aStr.localeCompare(bStr, "ko"); } return bStr.localeCompare(aStr, "ko"); }); }, [displayData, sortColumn, sortDirection]); // 🆕 페이지네이션된 스텝 데이터 (정렬된 데이터 기반) const paginatedStepData = sortedDisplayData.slice((stepDataPage - 1) * stepDataPageSize, stepDataPage * stepDataPageSize); const totalStepDataPages = Math.ceil(sortedDisplayData.length / stepDataPageSize); // 🆕 정렬 핸들러 const handleSort = useCallback((columnName: string) => { if (sortColumn === columnName) { // 같은 컬럼 클릭 시 방향 토글 setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")); } else { // 다른 컬럼 클릭 시 해당 컬럼으로 오름차순 정렬 setSortColumn(columnName); setSortDirection("asc"); } }, [sortColumn]); // 🆕 SingleTableWithSticky용 컬럼 설정 생성 const tableColumns: ColumnConfig[] = useMemo(() => { const cols: ColumnConfig[] = []; // 체크박스 컬럼 추가 (allowDataMove가 true일 때) if (allowDataMove) { cols.push({ columnName: "__checkbox__", displayName: "", visible: true, sortable: false, searchable: false, width: 50, align: "center", order: 0, }); } // 데이터 컬럼들 추가 stepDataColumns.forEach((col, index) => { cols.push({ columnName: col, displayName: columnLabels[col] || col, visible: true, sortable: true, searchable: true, width: 150, align: "left", order: index + 1, }); }); return cols; }, [stepDataColumns, columnLabels, allowDataMove]); // 🆕 SingleTableWithSticky용 테이블 설정 const tableConfig = useMemo(() => ({ stickyHeader: true, checkbox: { enabled: allowDataMove, selectAll: allowDataMove, multiple: true, position: "left" as const, }, tableStyle: { hoverEffect: true, alternateRows: false, }, }), [allowDataMove]); // 🆕 현재 페이지 기준으로 변환된 검색 하이라이트 const pageSearchHighlights = useMemo(() => { if (searchHighlights.size === 0) return new Set(); const pageStartIndex = (stepDataPage - 1) * stepDataPageSize; const pageEndIndex = pageStartIndex + stepDataPageSize; const pageHighlights = new Set(); searchHighlights.forEach((key) => { const [rowIndexStr, colIndexStr] = key.split("-"); const rowIndex = parseInt(rowIndexStr); // 현재 페이지에 해당하는 항목만 포함 if (rowIndex >= pageStartIndex && rowIndex < pageEndIndex) { // 페이지 내 상대 인덱스로 변환 const pageRowIndex = rowIndex - pageStartIndex; pageHighlights.add(`${pageRowIndex}-${colIndexStr}`); } }); return pageHighlights; }, [searchHighlights, stepDataPage, stepDataPageSize]); // 🆕 현재 페이지 기준 검색 인덱스 const pageCurrentSearchIndex = useMemo(() => { if (searchHighlights.size === 0) return 0; const highlightArray = Array.from(searchHighlights); const currentKey = highlightArray[currentSearchIndex]; if (!currentKey) return -1; const [rowIndexStr, colIndexStr] = currentKey.split("-"); const rowIndex = parseInt(rowIndexStr); const pageStartIndex = (stepDataPage - 1) * stepDataPageSize; const pageRowIndex = rowIndex - pageStartIndex; // 현재 페이지에 있는지 확인 if (pageRowIndex < 0 || pageRowIndex >= stepDataPageSize) return -1; // pageSearchHighlights에서의 인덱스 찾기 const pageKey = `${pageRowIndex}-${colIndexStr}`; const pageHighlightArray = Array.from(pageSearchHighlights); return pageHighlightArray.indexOf(pageKey); }, [searchHighlights, currentSearchIndex, stepDataPage, stepDataPageSize, pageSearchHighlights]); // 🆕 컬럼 너비 계산 함수 const getColumnWidth = useCallback((column: ColumnConfig) => { if (column.columnName === "__checkbox__") return 50; return column.width || 150; }, []); // 🆕 셀 값 포맷팅 함수 const formatCellValue = useCallback((value: any, format?: string, columnName?: string) => { return formatValue(value); }, []); // 🆕 전체 선택 핸들러 (Primary Key 기반) const handleSelectAll = useCallback((checked: boolean) => { if (checked) { const allKeys = new Set(sortedDisplayData.map((row) => getRowKey(row))); setSelectedRows(allKeys); // 선택된 데이터를 상위로 전달 onSelectedDataChange?.(sortedDisplayData, selectedStepId); } else { setSelectedRows(new Set()); onSelectedDataChange?.([], selectedStepId); } }, [sortedDisplayData, getRowKey, onSelectedDataChange, selectedStepId]); // 🆕 행 클릭 핸들러 const handleRowClick = useCallback((row: any) => { // 필요 시 행 클릭 로직 추가 }, []); // 🆕 체크박스 셀 렌더링 (Primary Key 기반) // index 파라미터는 SingleTableWithSticky 인터페이스 호환을 위해 유지하지만 사용하지 않음 const renderCheckboxCell = useCallback((row: any, _index: number) => { const rowKey = getRowKey(row); return ( toggleRowSelection(row)} /> ); }, [selectedRows, toggleRowSelection, getRowKey]); // 🆕 Excel 내보내기 (Primary Key 기반) const exportToExcel = useCallback(() => { try { const exportData = selectedRows.size > 0 ? sortedDisplayData.filter((row) => selectedRows.has(getRowKey(row))) : sortedDisplayData; if (exportData.length === 0) { toast.warning("내보낼 데이터가 없습니다."); return; } // 컬럼 라벨 적용 const formattedData = exportData.map((row) => { const newRow: Record = {}; stepDataColumns.forEach((col) => { const label = columnLabels[col] || col; newRow[label] = row[col]; }); return newRow; }); const ws = XLSX.utils.json_to_sheet(formattedData); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, "Data"); const fileName = `${flowName || "flow"}_data_${new Date().toISOString().split("T")[0]}.xlsx`; XLSX.writeFile(wb, fileName); toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`); } catch (error) { console.error("Excel 내보내기 오류:", error); toast.error("Excel 내보내기에 실패했습니다."); } }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, flowName, getRowKey]); // 🆕 PDF 내보내기 (html2canvas 사용으로 한글 지원) const exportToPdf = useCallback(async () => { try { const exportData = selectedRows.size > 0 ? sortedDisplayData.filter((row) => selectedRows.has(getRowKey(row))) : sortedDisplayData; if (exportData.length === 0) { toast.warning("내보낼 데이터가 없습니다."); return; } toast.loading("PDF 생성 중...", { id: "pdf-export" }); // html2canvas와 jspdf 동적 로드 const [{ default: html2canvas }, { default: jsPDF }] = await Promise.all([ import("html2canvas"), import("jspdf"), ]); // 임시 테이블 HTML 생성 const tempContainer = document.createElement("div"); tempContainer.style.cssText = ` position: absolute; left: -9999px; top: 0; background: white; padding: 20px; font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif; `; // 제목 const title = document.createElement("h2"); title.textContent = flowName || "Flow Data"; title.style.cssText = "margin-bottom: 10px; font-size: 18px; color: #333;"; tempContainer.appendChild(title); // 날짜 const dateInfo = document.createElement("p"); dateInfo.textContent = `내보내기 일시: ${new Date().toLocaleString("ko-KR")}`; dateInfo.style.cssText = "margin-bottom: 15px; font-size: 12px; color: #666;"; tempContainer.appendChild(dateInfo); // 테이블 생성 const table = document.createElement("table"); table.style.cssText = ` border-collapse: collapse; width: 100%; font-size: 11px; `; // 헤더 const thead = document.createElement("thead"); const headerRow = document.createElement("tr"); stepDataColumns.forEach((col) => { const th = document.createElement("th"); th.textContent = columnLabels[col] || col; th.style.cssText = ` background: #4a90d9; color: white; padding: 8px 12px; text-align: left; border: 1px solid #3a7bc8; white-space: nowrap; `; headerRow.appendChild(th); }); thead.appendChild(headerRow); table.appendChild(thead); // 바디 const tbody = document.createElement("tbody"); exportData.forEach((row, idx) => { const tr = document.createElement("tr"); tr.style.cssText = idx % 2 === 0 ? "background: #fff;" : "background: #f9f9f9;"; stepDataColumns.forEach((col) => { const td = document.createElement("td"); td.textContent = String(row[col] ?? ""); td.style.cssText = ` padding: 6px 12px; border: 1px solid #ddd; white-space: nowrap; `; tr.appendChild(td); }); tbody.appendChild(tr); }); table.appendChild(tbody); tempContainer.appendChild(table); document.body.appendChild(tempContainer); // HTML을 캔버스로 변환 const canvas = await html2canvas(tempContainer, { scale: 2, useCORS: true, logging: false, backgroundColor: "#ffffff", }); document.body.removeChild(tempContainer); // 캔버스를 PDF로 변환 const imgData = canvas.toDataURL("image/png"); const imgWidth = canvas.width; const imgHeight = canvas.height; // A4 가로 방향 (297mm x 210mm) const pdfWidth = 297; const pdfHeight = 210; const ratio = Math.min(pdfWidth / (imgWidth / 3.78), pdfHeight / (imgHeight / 3.78)); const doc = new jsPDF({ orientation: imgWidth > imgHeight ? "landscape" : "portrait", unit: "mm", format: "a4", }); const scaledWidth = (imgWidth / 3.78) * ratio * 0.9; const scaledHeight = (imgHeight / 3.78) * ratio * 0.9; // 이미지가 페이지보다 크면 여러 페이지로 분할 const pageWidth = doc.internal.pageSize.getWidth(); const pageHeight = doc.internal.pageSize.getHeight(); if (scaledHeight <= pageHeight - 20) { // 한 페이지에 들어가는 경우 doc.addImage(imgData, "PNG", 10, 10, scaledWidth, scaledHeight); } else { // 여러 페이지로 분할 let remainingHeight = scaledHeight; let yOffset = 0; let pageNum = 0; while (remainingHeight > 0) { if (pageNum > 0) { doc.addPage(); } const drawHeight = Math.min(pageHeight - 20, remainingHeight); doc.addImage( imgData, "PNG", 10, 10 - yOffset, scaledWidth, scaledHeight ); remainingHeight -= (pageHeight - 20); yOffset += (pageHeight - 20); pageNum++; } } const fileName = `${flowName || "flow"}_data_${new Date().toISOString().split("T")[0]}.pdf`; doc.save(fileName); toast.success(`${exportData.length}개 행이 PDF로 내보내기 되었습니다.`, { id: "pdf-export" }); } catch (error) { console.error("PDF 내보내기 오류:", error); toast.error("PDF 내보내기에 실패했습니다.", { id: "pdf-export" }); } }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, flowName, getRowKey]); // 🆕 복사 기능 (Primary Key 기반) const handleCopy = useCallback(() => { try { const copyData = selectedRows.size > 0 ? sortedDisplayData.filter((row) => selectedRows.has(getRowKey(row))) : []; if (copyData.length === 0) { toast.warning("복사할 데이터를 선택해주세요."); return; } // 헤더 + 데이터를 탭 구분 텍스트로 변환 const headers = stepDataColumns.map((col) => columnLabels[col] || col).join("\t"); const rows = copyData.map((row) => stepDataColumns.map((col) => String(row[col] ?? "")).join("\t") ).join("\n"); const text = `${headers}\n${rows}`; navigator.clipboard.writeText(text); toast.success(`${copyData.length}개 행이 클립보드에 복사되었습니다.`); } catch (error) { console.error("복사 오류:", error); toast.error("복사에 실패했습니다."); } }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, getRowKey]); // 🆕 통합 검색 실행 const executeGlobalSearch = useCallback((term: string) => { if (!term.trim()) { setSearchHighlights(new Set()); return; } const highlights = new Set(); const lowerTerm = term.toLowerCase(); // 전체 데이터에서 검색하여 페이지 이동 및 하이라이트 정보 저장 sortedDisplayData.forEach((row, rowIndex) => { stepDataColumns.forEach((col, colIndex) => { const value = String(row[col] ?? "").toLowerCase(); if (value.includes(lowerTerm)) { // 체크박스 컬럼 offset 고려 (allowDataMove가 true면 +1) const adjustedColIndex = allowDataMove ? colIndex + 1 : colIndex; highlights.add(`${rowIndex}-${adjustedColIndex}`); } }); }); setSearchHighlights(highlights); setCurrentSearchIndex(0); if (highlights.size === 0) { toast.info("검색 결과가 없습니다."); } else { // 첫 번째 검색 결과가 있는 페이지로 이동 const firstHighlight = Array.from(highlights)[0]; const [rowIndexStr] = firstHighlight.split("-"); const rowIndex = parseInt(rowIndexStr); const targetPage = Math.floor(rowIndex / stepDataPageSize) + 1; setStepDataPage(targetPage); toast.success(`${highlights.size}개 결과를 찾았습니다.`); } }, [sortedDisplayData, stepDataColumns, allowDataMove, stepDataPageSize]); // 🆕 검색 결과 이동 const goToNextSearchResult = useCallback(() => { if (searchHighlights.size === 0) return; const newIndex = (currentSearchIndex + 1) % searchHighlights.size; setCurrentSearchIndex(newIndex); // 해당 검색 결과가 있는 페이지로 이동 const highlightArray = Array.from(searchHighlights); const [rowIndexStr] = highlightArray[newIndex].split("-"); const rowIndex = parseInt(rowIndexStr); const targetPage = Math.floor(rowIndex / stepDataPageSize) + 1; if (targetPage !== stepDataPage) { setStepDataPage(targetPage); } }, [searchHighlights, currentSearchIndex, stepDataPageSize, stepDataPage]); const goToPrevSearchResult = useCallback(() => { if (searchHighlights.size === 0) return; const newIndex = (currentSearchIndex - 1 + searchHighlights.size) % searchHighlights.size; setCurrentSearchIndex(newIndex); // 해당 검색 결과가 있는 페이지로 이동 const highlightArray = Array.from(searchHighlights); const [rowIndexStr] = highlightArray[newIndex].split("-"); const rowIndex = parseInt(rowIndexStr); const targetPage = Math.floor(rowIndex / stepDataPageSize) + 1; if (targetPage !== stepDataPage) { setStepDataPage(targetPage); } }, [searchHighlights, currentSearchIndex, stepDataPageSize, stepDataPage]); // 🆕 검색 초기화 const clearGlobalSearch = useCallback(() => { setGlobalSearchTerm(""); setSearchHighlights(new Set()); setIsSearchPanelOpen(false); setCurrentSearchIndex(0); }, []); // 🆕 새로고침 const handleRefresh = useCallback(async () => { if (!selectedStepId) return; setStepDataLoading(true); try { const response = await getStepDataList(selectedStepId); if (response.success && response.data) { setStepData(response.data.data || []); if (response.data.columns) { const currentStep = steps.find((s) => s.id === selectedStepId); const visibleCols = getVisibleColumns(selectedStepId, response.data.columns, steps); setStepDataColumns(visibleCols); setAllAvailableColumns(response.data.columns); } } toast.success("데이터를 새로고침했습니다."); } catch (error) { console.error("새로고침 오류:", error); toast.error("새로고침에 실패했습니다."); } finally { setStepDataLoading(false); } }, [selectedStepId, steps, getVisibleColumns]); // 🆕 셀 더블클릭 시 편집 모드 진입 const handleCellDoubleClick = useCallback((rowIndex: number, colIndex: number, columnName: string, value: any) => { // 체크박스 컬럼은 편집 불가 if (columnName === "__checkbox__") return; setEditingCell({ rowIndex, colIndex, columnName, originalValue: value }); setEditingValue(value !== null && value !== undefined ? String(value) : ""); }, []); // 🆕 편집 취소 const cancelEditing = useCallback(() => { setEditingCell(null); setEditingValue(""); }, []); // 🆕 편집 저장 (플로우 스텝 데이터 업데이트) const saveEditing = useCallback(async () => { if (!editingCell || !selectedStepId || !flowId) return; const { rowIndex, columnName, originalValue } = editingCell; const newValue = editingValue; // 값이 변경되지 않았으면 그냥 닫기 if (String(originalValue ?? "") === newValue) { cancelEditing(); return; } try { // 페이지네이션을 고려한 실제 인덱스 계산 const actualIndex = (stepDataPage - 1) * stepDataPageSize + rowIndex; // 현재 행의 데이터 가져오기 (정렬된 전체 데이터에서) const currentRow = paginatedStepData[rowIndex]; if (!currentRow) { toast.error("데이터를 찾을 수 없습니다."); cancelEditing(); return; } // Primary Key 값 찾기 (일반적으로 id 또는 첫 번째 컬럼) // 플로우 정의에서 primaryKey를 가져오거나, 기본값으로 id 사용 const primaryKeyColumn = flowData?.primaryKey || "id"; const recordId = currentRow[primaryKeyColumn] || currentRow.id; if (!recordId) { toast.error("레코드 ID를 찾을 수 없습니다. Primary Key 설정을 확인해주세요."); cancelEditing(); return; } // API 호출하여 데이터 업데이트 const { updateFlowStepData } = await import("@/lib/api/flow"); const response = await updateFlowStepData(flowId, selectedStepId, recordId, { [columnName]: newValue }); if (response.success) { // 로컬 상태 업데이트 setStepData((prev) => { const newData = [...prev]; // 원본 데이터에서 해당 레코드 찾기 const targetIndex = newData.findIndex((row) => { const rowRecordId = row[primaryKeyColumn] || row.id; return rowRecordId === recordId; }); if (targetIndex !== -1) { newData[targetIndex] = { ...newData[targetIndex], [columnName]: newValue }; } return newData; }); toast.success("데이터가 저장되었습니다."); } else { toast.error(response.error || "저장에 실패했습니다."); } } catch (error) { console.error("편집 저장 오류:", error); toast.error("저장 중 오류가 발생했습니다."); } cancelEditing(); }, [editingCell, editingValue, selectedStepId, flowId, flowData, paginatedStepData, stepDataPage, stepDataPageSize, cancelEditing]); // 🆕 편집 키보드 핸들러 const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => { switch (e.key) { case "Enter": e.preventDefault(); saveEditing(); break; case "Escape": e.preventDefault(); cancelEditing(); break; case "Tab": e.preventDefault(); saveEditing(); break; } }, [saveEditing, cancelEditing]); // 🆕 편집 입력 필드 자동 포커스 useEffect(() => { if (editingCell && editInputRef.current) { editInputRef.current.focus(); editInputRef.current.select(); } }, [editingCell]); if (loading) { return (
플로우 로딩 중...
); } if (error) { return (
{error}
); } if (!flowId || !flowData) { return (
플로우를 선택해주세요
); } if (steps.length === 0) { return (
플로우에 스텝이 없습니다
); } // 반응형 컨테이너 클래스 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 (
{/* 플로우 스텝 목록 */}
{steps.map((step, index) => ( {/* 스텝 카드 */}
handleStepClick(step.id, step.stepName)} > {/* 콘텐츠 */}
{/* 스텝 이름 */}

{step.stepName}

{/* 데이터 건수 */} {showStepCount && (
{(stepCounts[step.id] || 0).toLocaleString("ko-KR")}
)}
{/* 하단 선 */}
{/* 화살표 (마지막 스텝 제외) */} {index < steps.length - 1 && (
{displayMode === "horizontal" ? (
) : (
)}
)} ))}
{/* 선택된 스텝의 데이터 리스트 */} {selectedStepId !== null && (
{/* 🆕 DevExpress 스타일 기능 툴바 */} {stepDataColumns.length > 0 && ( <>
{/* 내보내기 버튼들 */}
{/* 복사 버튼 */}
{/* 선택 정보 */} {selectedRows.size > 0 && (
{selectedRows.size}개 선택됨
)} {/* 🆕 통합 검색 패널 */}
{isSearchPanelOpen ? (
setGlobalSearchTerm(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { executeGlobalSearch(globalSearchTerm); } else if (e.key === "Escape") { clearGlobalSearch(); } }} placeholder="검색어 입력... (Enter)" className="border-input bg-background h-7 w-32 rounded border px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary sm:w-48" autoFocus /> {searchHighlights.size > 0 && ( {currentSearchIndex + 1}/{searchHighlights.size} )}
) : ( )}
{/* 필터/그룹 설정 버튼 */}
{/* 새로고침 */}
{/* 검색 필터 입력 영역 */} {searchFilterColumns.size > 0 && (
{Array.from(searchFilterColumns).map((col) => ( setSearchValues((prev) => ({ ...prev, [col]: e.target.value, })) } placeholder={`${columnLabels[col] || col} 검색...`} className="h-8 text-xs w-40" /> ))} {Object.keys(searchValues).length > 0 && ( )}
)} {/* 🆕 그룹 표시 배지 */} {groupByColumns.length > 0 && (
그룹:
{groupByColumns.map((col, idx) => ( {idx > 0 && } {columnLabels[col] || col} ))}
)} )} {/* 데이터 영역 - 고정 높이 + 스크롤 */} {stepDataLoading ? (
데이터 로딩 중...
) : stepData.length === 0 ? (
데이터가 없습니다
) : ( <> {/* 모바일: 카드 뷰 - 고정 높이 + 스크롤 */}
{paginatedStepData.map((row, pageIndex) => { const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex; return (
{allowDataMove && (
선택 toggleRowSelection(row)} />
)}
{stepDataColumns.map((col) => (
{columnLabels[col] || col}: {formatValue(row[col])}
))}
); })}
{/* 데스크톱: 테이블 뷰 - SingleTableWithSticky 사용 */}
{groupByColumns.length > 0 && groupedData.length > 0 ? ( // 그룹화된 렌더링 (기존 방식 유지)
{allowDataMove && ( 0} onCheckedChange={toggleAllRows} /> )} {stepDataColumns.map((col) => ( handleSort(col)} >
{columnLabels[col] || col} {sortColumn === col && ( {sortDirection === "asc" ? "↑" : "↓"} )}
))}
{groupedData.flatMap((group) => { const isCollapsed = collapsedGroups.has(group.groupKey); const groupRows = [
toggleGroupCollapse(group.groupKey)} > {isCollapsed ? ( ) : ( )} {group.groupKey} ({group.count}건)
, ]; if (!isCollapsed) { const dataRows = group.items.map((row, itemIndex) => { const actualIndex = sortedDisplayData.indexOf(row); return ( {allowDataMove && ( toggleRowSelection(row)} /> )} {stepDataColumns.map((col) => ( {formatValue(row[col])} ))} ); }); groupRows.push(...dataRows); } return groupRows; })}
) : ( // 일반 렌더링 - SingleTableWithSticky 사용 0} onSort={handleSort} handleSelectAll={handleSelectAll} handleRowClick={handleRowClick} renderCheckboxCell={renderCheckboxCell} formatCellValue={formatCellValue} getColumnWidth={getColumnWidth} loading={stepDataLoading} // 인라인 편집 props onCellDoubleClick={handleCellDoubleClick} editingCell={editingCell} editingValue={editingValue} onEditingValueChange={setEditingValue} onEditKeyDown={handleEditKeyDown} editInputRef={editInputRef} // 검색 하이라이트 props (현재 페이지 기준으로 변환된 값) searchHighlights={pageSearchHighlights} currentSearchIndex={pageCurrentSearchIndex} searchTerm={globalSearchTerm} /> )}
)} {/* 페이지네이션 - 항상 하단에 고정 */} {!stepDataLoading && stepData.length > 0 && (
{/* 왼쪽: 페이지 정보 + 페이지 크기 선택 */}
페이지 {stepDataPage} / {totalStepDataPages} (총 {stepData.length.toLocaleString("ko-KR")}건)
표시 개수:
{/* 오른쪽: 페이지네이션 */} {totalStepDataPages > 1 && ( { if (isPreviewMode) { return; } setStepDataPage((p) => Math.max(1, p - 1)); }} className={ stepDataPage === 1 || isPreviewMode ? "pointer-events-none opacity-50" : "cursor-pointer" } /> {totalStepDataPages <= 7 ? ( Array.from({ length: totalStepDataPages }, (_, i) => i + 1).map((page) => ( { if (isPreviewMode) { return; } setStepDataPage(page); }} isActive={stepDataPage === page} className={isPreviewMode ? "pointer-events-none opacity-50" : "cursor-pointer"} > {page} )) ) : ( <> {Array.from({ length: totalStepDataPages }, (_, i) => i + 1) .filter((page) => { return ( page === 1 || page === totalStepDataPages || (page >= stepDataPage - 2 && page <= stepDataPage + 2) ); }) .map((page, idx, arr) => ( {idx > 0 && arr[idx - 1] !== page - 1 && ( ... )} { if (isPreviewMode) { return; } setStepDataPage(page); }} isActive={stepDataPage === page} className={isPreviewMode ? "pointer-events-none opacity-50" : "cursor-pointer"} > {page} ))} )} { if (isPreviewMode) { return; } setStepDataPage((p) => Math.min(totalStepDataPages, p + 1)); }} className={ stepDataPage === totalStepDataPages || isPreviewMode ? "pointer-events-none opacity-50" : "cursor-pointer" } /> )}
)}
)} {/* 🆕 검색 필터 설정 다이얼로그 */} 검색 필터 설정 검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다.
{/* 전체 선택/해제 */}
0} onCheckedChange={toggleAllFilters} /> {searchFilterColumns.size} / {stepDataColumns.length}개
{/* 컬럼 목록 */}
{stepDataColumns.map((col) => (
toggleFilterColumn(col)} />
))}
{/* 선택된 컬럼 개수 안내 */}
{searchFilterColumns.size === 0 ? ( 검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요 ) : ( {searchFilterColumns.size}개의 검색 필터가 표시됩니다 )}
{/* 🆕 그룹 설정 다이얼로그 */} 그룹 설정 데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다.
{/* 컬럼 목록 */}
{stepDataColumns.map((col) => (
toggleGroupColumn(col)} />
))}
{/* 선택된 그룹 안내 */}
{groupByColumns.length === 0 ? ( 그룹화할 컬럼을 선택하세요 ) : ( 선택된 그룹:{" "} {groupByColumns.map((col) => columnLabels[col] || col).join(" → ")} )}
); }