2237 lines
85 KiB
TypeScript
2237 lines
85 KiB
TypeScript
"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<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);
|
|
|
|
// 🆕 정렬 상태 (SingleTableWithSticky용)
|
|
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
|
|
|
// 🆕 툴바 관련 상태
|
|
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
|
|
const [globalSearchTerm, setGlobalSearchTerm] = useState("");
|
|
const [searchHighlights, setSearchHighlights] = useState<Set<string>>(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<string>("");
|
|
const editInputRef = useRef<HTMLInputElement>(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<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 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<string>();
|
|
|
|
const pageStartIndex = (stepDataPage - 1) * stepDataPageSize;
|
|
const pageEndIndex = pageStartIndex + stepDataPageSize;
|
|
const pageHighlights = new Set<string>();
|
|
|
|
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);
|
|
}, []);
|
|
|
|
// 🆕 전체 선택 핸들러
|
|
const handleSelectAll = useCallback((checked: boolean) => {
|
|
if (checked) {
|
|
const allIndices = new Set(sortedDisplayData.map((_, idx) => idx));
|
|
setSelectedRows(allIndices);
|
|
} else {
|
|
setSelectedRows(new Set());
|
|
}
|
|
}, [sortedDisplayData]);
|
|
|
|
// 🆕 행 클릭 핸들러
|
|
const handleRowClick = useCallback((row: any) => {
|
|
// 필요 시 행 클릭 로직 추가
|
|
}, []);
|
|
|
|
// 🆕 체크박스 셀 렌더링
|
|
const renderCheckboxCell = useCallback((row: any, index: number) => {
|
|
return (
|
|
<Checkbox
|
|
checked={selectedRows.has(index)}
|
|
onCheckedChange={() => toggleRowSelection(index)}
|
|
/>
|
|
);
|
|
}, [selectedRows, toggleRowSelection]);
|
|
|
|
// 🆕 Excel 내보내기
|
|
const exportToExcel = useCallback(() => {
|
|
try {
|
|
const exportData = selectedRows.size > 0
|
|
? sortedDisplayData.filter((_, idx) => selectedRows.has(idx))
|
|
: sortedDisplayData;
|
|
|
|
if (exportData.length === 0) {
|
|
toast.warning("내보낼 데이터가 없습니다.");
|
|
return;
|
|
}
|
|
|
|
// 컬럼 라벨 적용
|
|
const formattedData = exportData.map((row) => {
|
|
const newRow: Record<string, any> = {};
|
|
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]);
|
|
|
|
// 🆕 PDF 내보내기 (html2canvas 사용으로 한글 지원)
|
|
const exportToPdf = useCallback(async () => {
|
|
try {
|
|
const exportData = selectedRows.size > 0
|
|
? sortedDisplayData.filter((_, idx) => selectedRows.has(idx))
|
|
: 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]);
|
|
|
|
// 🆕 복사 기능
|
|
const handleCopy = useCallback(() => {
|
|
try {
|
|
const copyData = selectedRows.size > 0
|
|
? sortedDisplayData.filter((_, idx) => selectedRows.has(idx))
|
|
: [];
|
|
|
|
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]);
|
|
|
|
// 🆕 통합 검색 실행
|
|
const executeGlobalSearch = useCallback((term: string) => {
|
|
if (!term.trim()) {
|
|
setSearchHighlights(new Set());
|
|
return;
|
|
}
|
|
|
|
const highlights = new Set<string>();
|
|
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<HTMLInputElement>) => {
|
|
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 (
|
|
<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 border rounded-lg overflow-hidden">
|
|
{/* 🆕 DevExpress 스타일 기능 툴바 */}
|
|
{stepDataColumns.length > 0 && (
|
|
<>
|
|
<div className="border-border bg-muted/20 flex flex-wrap items-center gap-1 border-b px-2 py-1.5 sm:gap-2 sm:px-4 sm:py-2">
|
|
{/* 내보내기 버튼들 */}
|
|
<div className="flex items-center gap-1 border-r border-border pr-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={exportToExcel}
|
|
className="h-7 text-xs"
|
|
title="Excel 내보내기"
|
|
>
|
|
<FileSpreadsheet className="mr-1 h-3 w-3 text-green-600" />
|
|
Excel
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={exportToPdf}
|
|
className="h-7 text-xs"
|
|
title="PDF 내보내기"
|
|
>
|
|
<FileText className="mr-1 h-3 w-3 text-red-600" />
|
|
PDF
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 복사 버튼 */}
|
|
<div className="flex items-center gap-1 border-r border-border pr-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleCopy}
|
|
disabled={selectedRows.size === 0}
|
|
className="h-7 text-xs"
|
|
title="복사 (Ctrl+C)"
|
|
>
|
|
<Copy className="mr-1 h-3 w-3" />
|
|
복사
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 선택 정보 */}
|
|
{selectedRows.size > 0 && (
|
|
<div className="flex items-center gap-1 border-r border-border pr-2">
|
|
<span className="bg-primary/10 text-primary rounded px-2 py-0.5 text-xs">
|
|
{selectedRows.size}개 선택됨
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setSelectedRows(new Set())}
|
|
className="h-6 w-6 p-0"
|
|
title="선택 해제"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 🆕 통합 검색 패널 */}
|
|
<div className="flex items-center gap-1 border-r border-border pr-2">
|
|
{isSearchPanelOpen ? (
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
type="text"
|
|
value={globalSearchTerm}
|
|
onChange={(e) => 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 && (
|
|
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
|
{currentSearchIndex + 1}/{searchHighlights.size}
|
|
</span>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={goToPrevSearchResult}
|
|
disabled={searchHighlights.size === 0}
|
|
className="h-6 w-6 p-0"
|
|
title="이전"
|
|
>
|
|
<ChevronLeft className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={goToNextSearchResult}
|
|
disabled={searchHighlights.size === 0}
|
|
className="h-6 w-6 p-0"
|
|
title="다음"
|
|
>
|
|
<ChevronRight className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={clearGlobalSearch}
|
|
className="h-6 w-6 p-0"
|
|
title="닫기 (Esc)"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setIsSearchPanelOpen(true)}
|
|
className="h-7 text-xs"
|
|
title="통합 검색 (Ctrl+F)"
|
|
>
|
|
<Filter className="mr-1 h-3 w-3" />
|
|
검색
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 필터/그룹 설정 버튼 */}
|
|
<div className="flex items-center gap-1 border-r border-border pr-2">
|
|
<Button
|
|
variant={searchFilterColumns.size > 0 ? "default" : "ghost"}
|
|
size="sm"
|
|
onClick={() => {
|
|
if (!isPreviewMode) {
|
|
setIsFilterSettingOpen(true);
|
|
}
|
|
}}
|
|
disabled={isPreviewMode}
|
|
className="h-7 text-xs"
|
|
title="검색 필터 설정"
|
|
>
|
|
<Filter className="mr-1 h-3 w-3" />
|
|
필터
|
|
{searchFilterColumns.size > 0 && (
|
|
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
|
|
{searchFilterColumns.size}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant={groupByColumns.length > 0 ? "default" : "ghost"}
|
|
size="sm"
|
|
onClick={() => {
|
|
if (!isPreviewMode) {
|
|
setIsGroupSettingOpen(true);
|
|
}
|
|
}}
|
|
disabled={isPreviewMode}
|
|
className="h-7 text-xs"
|
|
title="그룹 설정"
|
|
>
|
|
<Layers className="mr-1 h-3 w-3" />
|
|
그룹
|
|
{groupByColumns.length > 0 && (
|
|
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
|
|
{groupByColumns.length}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 새로고침 */}
|
|
<div className="ml-auto flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleRefresh}
|
|
disabled={stepDataLoading}
|
|
className="h-7 text-xs"
|
|
title="새로고침"
|
|
>
|
|
<RefreshCw className={`mr-1 h-3 w-3 ${stepDataLoading ? "animate-spin" : ""}`} />
|
|
새로고침
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 검색 필터 입력 영역 */}
|
|
{searchFilterColumns.size > 0 && (
|
|
<div className="bg-background flex-shrink-0 border-b px-4 py-2">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{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>
|
|
</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>
|
|
|
|
{/* 데스크톱: 테이블 뷰 - SingleTableWithSticky 사용 */}
|
|
<div className="relative hidden @sm:block">
|
|
{groupByColumns.length > 0 && groupedData.length > 0 ? (
|
|
// 그룹화된 렌더링 (기존 방식 유지)
|
|
<div className="overflow-x-auto">
|
|
<Table noWrapper>
|
|
<TableHeader className="sticky top-0 z-30 bg-background shadow-sm">
|
|
<TableRow className="hover:bg-muted/50">
|
|
{allowDataMove && (
|
|
<TableHead className="bg-background sticky left-0 z-40 w-12 border-b px-6 py-3 text-center">
|
|
<Checkbox
|
|
checked={selectedRows.size === stepData.length && stepData.length > 0}
|
|
onCheckedChange={toggleAllRows}
|
|
/>
|
|
</TableHead>
|
|
)}
|
|
{stepDataColumns.map((col) => (
|
|
<TableHead
|
|
key={col}
|
|
className="bg-background border-b px-6 py-3 text-sm font-semibold whitespace-nowrap cursor-pointer hover:bg-muted/50"
|
|
onClick={() => handleSort(col)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span>{columnLabels[col] || col}</span>
|
|
{sortColumn === col && (
|
|
<span className="text-primary">
|
|
{sortDirection === "asc" ? "↑" : "↓"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{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 = sortedDisplayData.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;
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
) : (
|
|
// 일반 렌더링 - SingleTableWithSticky 사용
|
|
<SingleTableWithSticky
|
|
visibleColumns={tableColumns}
|
|
columns={tableColumns}
|
|
data={paginatedStepData}
|
|
columnLabels={columnLabels}
|
|
sortColumn={sortColumn}
|
|
sortDirection={sortDirection}
|
|
tableConfig={tableConfig}
|
|
isDesignMode={false}
|
|
isAllSelected={selectedRows.size === sortedDisplayData.length && sortedDisplayData.length > 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}
|
|
/>
|
|
)}
|
|
</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">
|
|
<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>
|
|
);
|
|
}
|