- Replaced existing toast error messages with the new `showErrorToast` utility across multiple components, improving consistency in error reporting. - Updated error messages to provide more specific guidance for users, enhancing the overall user experience during error scenarios. - Ensured that all relevant error handling in batch management, external call configurations, cascading management, and screen management components now utilizes the new utility for better maintainability.
2253 lines
87 KiB
TypeScript
2253 lines
87 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 { showErrorToast } from "@/lib/utils/toastUtils";
|
|
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<string>>(new Set()); // Primary Key 값으로 선택 관리
|
|
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);
|
|
showErrorToast("필터 설정 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
}
|
|
}, [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);
|
|
showErrorToast("그룹 설정 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
}
|
|
}, [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);
|
|
showErrorToast("데이터 새로고침에 실패했습니다", err, { guidance: "네트워크 연결을 확인하고 다시 시도해 주세요." });
|
|
} 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);
|
|
showErrorToast("스텝 데이터를 불러오는 데 실패했습니다", err, { guidance: "네트워크 연결을 확인해 주세요." });
|
|
} 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<string>;
|
|
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<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);
|
|
}, []);
|
|
|
|
// 🆕 전체 선택 핸들러 (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 (
|
|
<Checkbox
|
|
checked={selectedRows.has(rowKey)}
|
|
onCheckedChange={() => 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<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);
|
|
showErrorToast("Excel 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." });
|
|
}
|
|
}, [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);
|
|
showErrorToast("PDF 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." });
|
|
}
|
|
}, [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);
|
|
showErrorToast("클립보드 복사에 실패했습니다", error, { guidance: "브라우저 권한을 확인해 주세요." });
|
|
}
|
|
}, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, getRowKey]);
|
|
|
|
// 🆕 통합 검색 실행
|
|
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);
|
|
showErrorToast("데이터 새로고침에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
} 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);
|
|
showErrorToast("데이터 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
|
}
|
|
|
|
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(getRowKey(row)) ? "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(getRowKey(row))}
|
|
onCheckedChange={() => toggleRowSelection(row)}
|
|
/>
|
|
</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(getRowKey(row)) ? "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(getRowKey(row))}
|
|
onCheckedChange={() => toggleRowSelection(row)}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|