"use client"; import React, { useEffect, useState } from "react"; import { FlowComponent } from "@/types/screen-management"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { AlertCircle, Loader2, ChevronUp, History } from "lucide-react"; import { getFlowById, getAllStepCounts, getStepDataList, getFlowAuditLogs, getFlowSteps, getFlowConnections, } from "@/lib/api/flow"; import type { FlowDefinition, FlowStep, FlowAuditLog } from "@/types/flow"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; import { useFlowStepStore } from "@/stores/flowStepStore"; 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 setSelectedStep = useFlowStepStore((state) => state.setSelectedStep); const resetFlow = useFlowStepStore((state) => state.resetFlow); const [flowData, setFlowData] = useState(null); const [steps, setSteps] = useState([]); const [stepCounts, setStepCounts] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [connections, setConnections] = useState([]); // 플로우 연결 정보 // 선택된 스텝의 데이터 리스트 상태 const [selectedStepId, setSelectedStepId] = useState(null); const [stepData, setStepData] = useState([]); const [stepDataColumns, setStepDataColumns] = useState([]); const [stepDataLoading, setStepDataLoading] = useState(false); const [selectedRows, setSelectedRows] = useState>(new Set()); // 🆕 스텝 데이터 페이지네이션 상태 const [stepDataPage, setStepDataPage] = useState(1); const [stepDataPageSize, setStepDataPageSize] = useState(10); // 오딧 로그 상태 const [auditLogs, setAuditLogs] = useState([]); const [auditLogsLoading, setAuditLogsLoading] = useState(false); const [showAuditLogs, setShowAuditLogs] = useState(false); const [auditPage, setAuditPage] = useState(1); const [auditPageSize] = useState(10); // componentConfig에서 플로우 설정 추출 (DynamicComponentRenderer에서 전달됨) const config = (component as any).componentConfig || (component as any).config || {}; const flowId = config.flowId || component.flowId; const flowName = config.flowName || component.flowName; const displayMode = config.displayMode || component.displayMode || "horizontal"; const showStepCount = config.showStepCount !== false && component.showStepCount !== false; // 기본값 true const allowDataMove = config.allowDataMove || component.allowDataMove || false; // 🆕 플로우 컴포넌트 ID (버튼이 이 플로우를 참조할 때 사용) const flowComponentId = component.id; // 선택된 스텝의 데이터를 다시 로드하는 함수 const refreshStepData = async () => { if (!flowId) return; try { // 스텝 카운트는 항상 업데이트 (선택된 스텝 유무와 관계없이) const countsResponse = await getAllStepCounts(flowId); console.log("📊 스텝 카운트 API 응답:", countsResponse); if (countsResponse.success && countsResponse.data) { // Record 형태로 변환 const countsMap: Record = {}; if (Array.isArray(countsResponse.data)) { countsResponse.data.forEach((item: any) => { countsMap[item.stepId] = item.count; }); } else if (typeof countsResponse.data === "object") { Object.assign(countsMap, countsResponse.data); } console.log("✅ 스텝 카운트 업데이트:", countsMap); setStepCounts(countsMap); } // 선택된 스텝이 있으면 해당 스텝의 데이터도 새로고침 if (selectedStepId) { setStepDataLoading(true); 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) { setStepDataColumns(Object.keys(rows[0])); } else { setStepDataColumns([]); } // 선택 초기화 setSelectedRows(new Set()); 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); // 플로우 정보 조회 const flowResponse = await getFlowById(flowId!); if (!flowResponse.success || !flowResponse.data) { throw new Error("플로우를 찾을 수 없습니다"); } setFlowData(flowResponse.data); // 스텝 목록 조회 const stepsResponse = await getFlowSteps(flowId); if (!stepsResponse.success) { throw new Error("스텝 목록을 불러올 수 없습니다"); } if (stepsResponse.data) { const sortedSteps = stepsResponse.data.sort((a: FlowStep, b: FlowStep) => a.stepOrder - b.stepOrder); setSteps(sortedSteps); // 연결 정보 조회 const connectionsResponse = await getFlowConnections(flowId); if (connectionsResponse.success && connectionsResponse.data) { setConnections(connectionsResponse.data); } // 스텝별 데이터 건수 조회 if (showStepCount) { const countsResponse = await getAllStepCounts(flowId!); if (countsResponse.success && countsResponse.data) { // 배열을 Record로 변환 const countsMap: Record = {}; countsResponse.data.forEach((item: any) => { countsMap[item.stepId] = item.count; }); setStepCounts(countsMap); } } // 🆕 플로우 로드 후 첫 번째 스텝 자동 선택 if (sortedSteps.length > 0) { const firstStep = sortedSteps[0]; setSelectedStepId(firstStep.id); setSelectedStep(flowComponentId, firstStep.id); console.log("✅ [FlowWidget] 첫 번째 단계 자동 선택:", { flowComponentId, stepId: firstStep.id, stepName: firstStep.stepName, }); // 첫 번째 스텝의 데이터 로드 try { const response = await getStepDataList(flowId!, firstStep.id, 1, 100); if (response.success) { const rows = response.data?.records || []; setStepData(rows); if (rows.length > 0) { setStepDataColumns(Object.keys(rows[0])); } } } 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) { console.log("🔄 플로우 새로고침 실행, flowRefreshKey:", flowRefreshKey); refreshStepData(); } }, [flowRefreshKey]); // 🆕 언마운트 시 전역 상태 초기화 useEffect(() => { return () => { console.log("🧹 [FlowWidget] 언마운트 - 전역 상태 초기화:", flowComponentId); resetFlow(flowComponentId); }; }, [flowComponentId, resetFlow]); // 🆕 스텝 클릭 핸들러 (전역 상태 업데이트 추가) const handleStepClick = async (stepId: number, stepName: string) => { // 외부 콜백 실행 if (onStepClick) { onStepClick(stepId, stepName); } // 같은 스텝을 다시 클릭하면 접기 if (selectedStepId === stepId) { setSelectedStepId(null); setSelectedStep(flowComponentId, null); // 🆕 전역 상태 업데이트 setStepData([]); setStepDataColumns([]); setSelectedRows(new Set()); setStepDataPage(1); // 🆕 페이지 리셋 onSelectedDataChange?.([], null); console.log("🔄 [FlowWidget] 단계 선택 해제:", { flowComponentId, stepId }); return; } // 새로운 스텝 선택 - 데이터 로드 setSelectedStepId(stepId); setSelectedStep(flowComponentId, stepId); // 🆕 전역 상태 업데이트 setStepDataLoading(true); setSelectedRows(new Set()); setStepDataPage(1); // 🆕 페이지 리셋 onSelectedDataChange?.([], stepId); console.log("✅ [FlowWidget] 단계 선택:", { flowComponentId, stepId, stepName }); try { 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) { setStepDataColumns(Object.keys(rows[0])); } else { setStepDataColumns([]); } } catch (err: any) { console.error("Failed to load step data:", err); toast.error(err.message || "데이터를 불러오는데 실패했습니다"); } finally { setStepDataLoading(false); } }; // 체크박스 토글 const toggleRowSelection = (rowIndex: number) => { 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; 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); }; // 오딧 로그 로드 const loadAuditLogs = async () => { if (!flowId) return; try { setAuditLogsLoading(true); const response = await getFlowAuditLogs(flowId, 100); // 최근 100개 if (response.success && response.data) { setAuditLogs(response.data); } } catch (err: any) { console.error("Failed to load audit logs:", err); toast.error("이력 조회 중 오류가 발생했습니다"); } finally { setAuditLogsLoading(false); } }; // 오딧 로그 모달 열기 const handleOpenAuditLogs = () => { setShowAuditLogs(true); setAuditPage(1); // 페이지 초기화 loadAuditLogs(); }; // 페이지네이션된 오딧 로그 const paginatedAuditLogs = auditLogs.slice((auditPage - 1) * auditPageSize, auditPage * auditPageSize); const totalAuditPages = Math.ceil(auditLogs.length / auditPageSize); // 🆕 페이지네이션된 스텝 데이터 const paginatedStepData = stepData.slice((stepDataPage - 1) * stepDataPageSize, stepDataPage * stepDataPageSize); const totalStepDataPages = Math.ceil(stepData.length / stepDataPageSize); if (loading) { return (
플로우 로딩 중...
); } if (error) { return (
{error}
); } if (!flowId || !flowData) { return (
플로우를 선택해주세요
); } if (steps.length === 0) { return (
플로우에 스텝이 없습니다
); } // 반응형 컨테이너 클래스 const containerClass = displayMode === "horizontal" ? "flex flex-col sm:flex-row sm:flex-wrap items-center justify-center gap-3 sm:gap-4" : "flex flex-col items-center gap-4"; return (
{/* 플로우 제목 */}

{flowData.name}

{/* 오딧 로그 버튼 */} 플로우 변경 이력 데이터 이동 및 상태 변경 기록 (총 {auditLogs.length}건) {auditLogsLoading ? (
이력 로딩 중...
) : auditLogs.length === 0 ? (
변경 이력이 없습니다
) : (
{/* 테이블 */}
변경일시 타입 출발 단계 도착 단계 데이터 ID 상태 변경 변경자 DB 연결 테이블 {paginatedAuditLogs.map((log) => { const fromStep = steps.find((s) => s.id === log.fromStepId); const toStep = steps.find((s) => s.id === log.toStepId); return ( {new Date(log.changedAt).toLocaleString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", })} {log.moveType === "status" ? "상태" : log.moveType === "table" ? "테이블" : "하이브리드"} {fromStep?.stepName || `Step ${log.fromStepId}`} {toStep?.stepName || `Step ${log.toStepId}`} {log.sourceDataId || "-"} {log.targetDataId && log.targetDataId !== log.sourceDataId && ( <>
→ {log.targetDataId} )}
{log.statusFrom && log.statusTo ? ( {log.statusFrom}
→ {log.statusTo}
) : ( - )}
{log.changedBy} {log.dbConnectionName ? ( {log.dbConnectionName} ) : ( - )} {log.sourceTable || "-"} {log.targetTable && log.targetTable !== log.sourceTable && ( <>
→ {log.targetTable} )}
); })}
{/* 페이지네이션 */} {totalAuditPages > 1 && (
{(auditPage - 1) * auditPageSize + 1}-{Math.min(auditPage * auditPageSize, auditLogs.length)} /{" "} {auditLogs.length}건
setAuditPage((p) => Math.max(1, p - 1))} className={auditPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} /> {Array.from({ length: totalAuditPages }, (_, i) => i + 1) .filter((page) => { // 현재 페이지 주변만 표시 return ( page === 1 || page === totalAuditPages || (page >= auditPage - 1 && page <= auditPage + 1) ); }) .map((page, idx, arr) => ( {idx > 0 && arr[idx - 1] !== page - 1 && ( ... )} setAuditPage(page)} isActive={auditPage === page} className="cursor-pointer" > {page} ))} setAuditPage((p) => Math.min(totalAuditPages, p + 1))} className={ auditPage === totalAuditPages ? "pointer-events-none opacity-50" : "cursor-pointer" } />
)}
)}
{flowData.description && (

{flowData.description}

)}
{/* 플로우 스텝 목록 */}
{steps.map((step, index) => ( {/* 스텝 카드 */}
handleStepClick(step.id, step.stepName)} > {/* 단계 번호 배지 */}
Step {step.stepOrder}
{/* 스텝 이름 */}

{step.stepName}

{/* 데이터 건수 */} {showStepCount && (
{stepCounts[step.id] || 0}
)} {/* 선택 인디케이터 */} {selectedStepId === step.id && (
)}
{/* 화살표 (마지막 스텝 제외) */} {index < steps.length - 1 && (
{displayMode === "horizontal" ? ( ) : ( )}
)}
))}
{/* 선택된 스텝의 데이터 리스트 */} {selectedStepId !== null && (
{/* 헤더 - 자동 높이 */}

{steps.find((s) => s.id === selectedStepId)?.stepName}

총 {stepData.length}건의 데이터 {selectedRows.size > 0 && ( ({selectedRows.size}건 선택됨) )}

{/* 데이터 영역 - 고정 높이 + 스크롤 */} {stepDataLoading ? (
데이터 로딩 중...
) : stepData.length === 0 ? (
데이터가 없습니다
) : ( <> {/* 모바일: 카드 뷰 - 고정 높이 + 스크롤 */}
{paginatedStepData.map((row, pageIndex) => { const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex; return (
{allowDataMove && (
선택 toggleRowSelection(actualIndex)} />
)}
{stepDataColumns.map((col) => (
{col}: {row[col] !== null && row[col] !== undefined ? ( String(row[col]) ) : ( - )}
))}
); })}
{/* 데스크톱: 테이블 뷰 - 고정 높이 + 스크롤 */}
{allowDataMove && ( 0} onCheckedChange={toggleAllRows} /> )} {stepDataColumns.map((col) => ( {col} ))} {paginatedStepData.map((row, pageIndex) => { const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex; return ( {allowDataMove && ( toggleRowSelection(actualIndex)} /> )} {stepDataColumns.map((col) => ( {row[col] !== null && row[col] !== undefined ? ( String(row[col]) ) : ( - )} ))} ); })}
)} {/* 페이지네이션 - 항상 하단에 고정 */} {!stepDataLoading && stepData.length > 0 && (
{/* 왼쪽: 페이지 정보 + 페이지 크기 선택 */}
페이지 {stepDataPage} / {totalStepDataPages} (총 {stepData.length}건)
표시 개수:
{/* 오른쪽: 페이지네이션 */} {totalStepDataPages > 1 && ( setStepDataPage((p) => Math.max(1, p - 1))} className={stepDataPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} /> {totalStepDataPages <= 7 ? ( Array.from({ length: totalStepDataPages }, (_, i) => i + 1).map((page) => ( setStepDataPage(page)} isActive={stepDataPage === page} className="cursor-pointer" > {page} )) ) : ( <> {Array.from({ length: totalStepDataPages }, (_, i) => i + 1) .filter((page) => { return ( page === 1 || page === totalStepDataPages || (page >= stepDataPage - 2 && page <= stepDataPage + 2) ); }) .map((page, idx, arr) => ( {idx > 0 && arr[idx - 1] !== page - 1 && ( ... )} setStepDataPage(page)} isActive={stepDataPage === page} className="cursor-pointer" > {page} ))} )} setStepDataPage((p) => Math.min(totalStepDataPages, p + 1))} className={ stepDataPage === totalStepDataPages ? "pointer-events-none opacity-50" : "cursor-pointer" } /> )}
)}
)}
); }