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

969 lines
42 KiB
TypeScript
Raw Normal View History

2025-10-20 10:55:33 +09:00
"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";
2025-10-24 14:11:12 +09:00
import { AlertCircle, Loader2, ChevronUp, History } from "lucide-react";
2025-10-24 16:39:54 +09:00
import {
getFlowById,
getAllStepCounts,
getStepDataList,
getFlowAuditLogs,
getFlowSteps,
getFlowConnections,
} from "@/lib/api/flow";
2025-10-20 15:53:00 +09:00
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";
2025-10-24 16:34:21 +09:00
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
2025-10-20 15:53:00 +09:00
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";
2025-10-20 10:55:33 +09:00
interface FlowWidgetProps {
component: FlowComponent;
onStepClick?: (stepId: number, stepName: string) => void;
onSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
2025-10-23 17:55:04 +09:00
flowRefreshKey?: number; // 새로고침 키
onFlowRefresh?: () => void; // 새로고침 완료 콜백
2025-10-20 10:55:33 +09:00
}
2025-10-24 15:40:08 +09:00
export function FlowWidget({
component,
onStepClick,
onSelectedDataChange,
flowRefreshKey,
onFlowRefresh,
}: FlowWidgetProps) {
// 🆕 전역 상태 관리
const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep);
const resetFlow = useFlowStepStore((state) => state.resetFlow);
2025-10-20 10:55:33 +09:00
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);
2025-10-20 15:53:00 +09:00
const [connections, setConnections] = useState<any[]>([]); // 플로우 연결 정보
2025-10-20 10:55:33 +09:00
2025-10-20 15:53:00 +09:00
// 선택된 스텝의 데이터 리스트 상태
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());
/**
* 🆕
* 1순위: 플로우 (displayConfig)
* 2순위: 모든
*/
const getVisibleColumns = (stepId: number, allColumns: string[], stepsArray?: FlowStep[]): string[] => {
// stepsArray가 제공되지 않으면 state의 steps 사용
const effectiveSteps = stepsArray || steps;
// 1순위: 플로우 스텝 기본 설정
console.log(`🔍 [FlowWidget] steps 배열 상태:`, {
stepsLength: effectiveSteps.length,
stepsIds: effectiveSteps.map((s) => s.id),
targetStepId: stepId,
});
const currentStep = effectiveSteps.find((s) => s.id === stepId);
console.log(`🔍 [FlowWidget] currentStep 찾기 (스텝 ${stepId}):`, {
found: !!currentStep,
hasDisplayConfig: !!currentStep?.displayConfig,
displayConfig: currentStep?.displayConfig,
displayConfigType: typeof currentStep?.displayConfig,
});
if (currentStep?.displayConfig?.visibleColumns && currentStep.displayConfig.visibleColumns.length > 0) {
console.log(`🎨 [FlowWidget] 플로우 기본 설정 적용 (스텝 ${stepId}):`, currentStep.displayConfig.visibleColumns);
return currentStep.displayConfig.visibleColumns;
}
// 2순위: 모든 컬럼 표시
console.log(`🎨 [FlowWidget] 전체 컬럼 표시 (스텝 ${stepId}):`, allColumns);
return allColumns;
};
2025-10-24 15:40:08 +09:00
// 🆕 스텝 데이터 페이지네이션 상태
const [stepDataPage, setStepDataPage] = useState(1);
2025-10-24 16:34:21 +09:00
const [stepDataPageSize, setStepDataPageSize] = useState(10);
2025-10-24 15:40:08 +09:00
2025-10-20 15:53:00 +09:00
// 오딧 로그 상태
const [auditLogs, setAuditLogs] = useState<FlowAuditLog[]>([]);
const [auditLogsLoading, setAuditLogsLoading] = useState(false);
const [showAuditLogs, setShowAuditLogs] = useState(false);
const [auditPage, setAuditPage] = useState(1);
const [auditPageSize] = useState(10);
2025-10-20 10:55:33 +09:00
// 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;
2025-10-23 17:55:04 +09:00
// 선택된 스텝의 데이터를 다시 로드하는 함수
const refreshStepData = async () => {
if (!flowId) return;
try {
// 스텝 카운트는 항상 업데이트 (선택된 스텝 유무와 관계없이)
const countsResponse = await getAllStepCounts(flowId);
console.log("📊 스텝 카운트 API 응답:", countsResponse);
2025-10-24 15:40:08 +09:00
2025-10-23 17:55:04 +09:00
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;
});
2025-10-24 15:40:08 +09:00
} else if (typeof countsResponse.data === "object") {
2025-10-23 17:55:04 +09:00
Object.assign(countsMap, countsResponse.data);
}
2025-10-24 15:40:08 +09:00
2025-10-23 17:55:04 +09:00
console.log("✅ 스텝 카운트 업데이트:", countsMap);
setStepCounts(countsMap);
}
// 선택된 스텝이 있으면 해당 스텝의 데이터도 새로고침
if (selectedStepId) {
setStepDataLoading(true);
2025-10-24 15:40:08 +09:00
2025-10-23 17:55:04 +09:00
const response = await getStepDataList(flowId, selectedStepId, 1, 100);
if (!response.success) {
throw new Error(response.message || "데이터를 불러올 수 없습니다");
}
const rows = response.data?.records || [];
setStepData(rows);
// 🆕 컬럼 추출 및 우선순위 적용
2025-10-23 17:55:04 +09:00
if (rows.length > 0) {
const allColumns = Object.keys(rows[0]);
const visibleColumns = getVisibleColumns(selectedStepId, allColumns);
setStepDataColumns(visibleColumns);
2025-10-23 17:55:04 +09:00
} else {
setStepDataColumns([]);
}
2025-10-20 10:55:33 +09:00
2025-10-23 17:55:04 +09:00
// 선택 초기화
setSelectedRows(new Set());
onSelectedDataChange?.([], selectedStepId);
}
} catch (err: any) {
console.error("❌ 플로우 새로고침 실패:", err);
toast.error(err.message || "데이터를 새로고치는데 실패했습니다");
} finally {
if (selectedStepId) {
setStepDataLoading(false);
}
}
};
useEffect(() => {
2025-10-20 10:55:33 +09:00
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);
// 스텝 목록 조회
2025-10-24 16:39:54 +09:00
const stepsResponse = await getFlowSteps(flowId);
if (!stepsResponse.success) {
2025-10-20 10:55:33 +09:00
throw new Error("스텝 목록을 불러올 수 없습니다");
}
2025-10-24 16:39:54 +09:00
if (stepsResponse.data) {
const sortedSteps = stepsResponse.data.sort((a: FlowStep, b: FlowStep) => a.stepOrder - b.stepOrder);
console.log("📋 [FlowWidget] 스텝 목록 로드:", {
stepsCount: sortedSteps.length,
steps: sortedSteps.map((s: FlowStep) => ({
id: s.id,
name: s.stepName,
hasDisplayConfig: !!s.displayConfig,
displayConfig: s.displayConfig,
})),
});
2025-10-20 10:55:33 +09:00
setSteps(sortedSteps);
2025-10-20 15:53:00 +09:00
// 연결 정보 조회
2025-10-24 16:39:54 +09:00
const connectionsResponse = await getFlowConnections(flowId);
if (connectionsResponse.success && connectionsResponse.data) {
setConnections(connectionsResponse.data);
2025-10-20 15:53:00 +09:00
}
2025-10-20 10:55:33 +09:00
// 스텝별 데이터 건수 조회
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);
}
}
2025-10-24 17:27:22 +09:00
// 🆕 플로우 로드 후 첫 번째 스텝 자동 선택
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) {
const allColumns = Object.keys(rows[0]);
// sortedSteps를 직접 전달하여 타이밍 이슈 해결
const visibleColumns = getVisibleColumns(firstStep.id, allColumns, sortedSteps);
setStepDataColumns(visibleColumns);
2025-10-24 17:27:22 +09:00
}
}
} catch (err) {
console.error("첫 번째 스텝 데이터 로드 실패:", err);
}
}
2025-10-20 10:55:33 +09:00
}
} catch (err: any) {
console.error("Failed to load flow data:", err);
setError(err.message || "플로우 데이터를 불러오는데 실패했습니다");
} finally {
setLoading(false);
}
};
loadFlowData();
}, [flowId, showStepCount]);
2025-10-23 17:55:04 +09:00
// flowRefreshKey가 변경될 때마다 스텝 데이터 새로고침
useEffect(() => {
if (flowRefreshKey !== undefined && flowRefreshKey > 0 && flowId) {
console.log("🔄 플로우 새로고침 실행, flowRefreshKey:", flowRefreshKey);
refreshStepData();
}
}, [flowRefreshKey]);
// 🆕 언마운트 시 전역 상태 초기화
useEffect(() => {
return () => {
console.log("🧹 [FlowWidget] 언마운트 - 전역 상태 초기화:", flowComponentId);
resetFlow(flowComponentId);
};
}, [flowComponentId, resetFlow]);
// 🆕 스텝 클릭 핸들러 (전역 상태 업데이트 추가)
2025-10-20 15:53:00 +09:00
const handleStepClick = async (stepId: number, stepName: string) => {
// 외부 콜백 실행
2025-10-20 10:55:33 +09:00
if (onStepClick) {
onStepClick(stepId, stepName);
2025-10-20 15:53:00 +09:00
}
// 같은 스텝을 다시 클릭하면 접기
if (selectedStepId === stepId) {
setSelectedStepId(null);
setSelectedStep(flowComponentId, null); // 🆕 전역 상태 업데이트
2025-10-20 15:53:00 +09:00
setStepData([]);
setStepDataColumns([]);
setSelectedRows(new Set());
2025-10-24 15:40:08 +09:00
setStepDataPage(1); // 🆕 페이지 리셋
onSelectedDataChange?.([], null);
console.log("🔄 [FlowWidget] 단계 선택 해제:", { flowComponentId, stepId });
2025-10-20 15:53:00 +09:00
return;
}
// 새로운 스텝 선택 - 데이터 로드
setSelectedStepId(stepId);
setSelectedStep(flowComponentId, stepId); // 🆕 전역 상태 업데이트
2025-10-20 15:53:00 +09:00
setStepDataLoading(true);
setSelectedRows(new Set());
2025-10-24 15:40:08 +09:00
setStepDataPage(1); // 🆕 페이지 리셋
onSelectedDataChange?.([], stepId);
2025-10-20 15:53:00 +09:00
console.log("✅ [FlowWidget] 단계 선택:", { flowComponentId, stepId, stepName });
2025-10-20 15:53:00 +09:00
try {
const response = await getStepDataList(flowId!, stepId, 1, 100);
if (!response.success) {
throw new Error(response.message || "데이터를 불러올 수 없습니다");
}
const rows = response.data?.records || [];
setStepData(rows);
// 🆕 컬럼 추출 및 우선순위 적용
2025-10-20 15:53:00 +09:00
if (rows.length > 0) {
const allColumns = Object.keys(rows[0]);
const visibleColumns = getVisibleColumns(stepId, allColumns);
setStepDataColumns(visibleColumns);
2025-10-20 15:53:00 +09:00
} 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);
2025-10-20 10:55:33 +09:00
} else {
2025-10-20 15:53:00 +09:00
newSelected.add(rowIndex);
2025-10-20 10:55:33 +09:00
}
2025-10-20 15:53:00 +09:00
setSelectedRows(newSelected);
2025-10-24 15:40:08 +09:00
// 선택된 데이터를 상위로 전달
const selectedData = Array.from(newSelected).map((index) => stepData[index]);
console.log("🌊 FlowWidget - 체크박스 토글, 상위로 전달:", {
rowIndex,
newSelectedSize: newSelected.size,
selectedData,
selectedStepId,
hasCallback: !!onSelectedDataChange,
});
onSelectedDataChange?.(selectedData, selectedStepId);
2025-10-20 10:55:33 +09:00
};
2025-10-20 15:53:00 +09:00
// 전체 선택/해제
const toggleAllRows = () => {
let newSelected: Set<number>;
2025-10-20 15:53:00 +09:00
if (selectedRows.size === stepData.length) {
newSelected = new Set();
2025-10-20 15:53:00 +09:00
} else {
newSelected = new Set(stepData.map((_, index) => index));
2025-10-20 15:53:00 +09:00
}
setSelectedRows(newSelected);
2025-10-24 15:40:08 +09:00
// 선택된 데이터를 상위로 전달
const selectedData = Array.from(newSelected).map((index) => stepData[index]);
onSelectedDataChange?.(selectedData, selectedStepId);
2025-10-20 15:53:00 +09:00
};
// 오딧 로그 로드
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);
2025-10-20 10:55:33 +09:00
}
};
2025-10-20 15:53:00 +09:00
// 오딧 로그 모달 열기
const handleOpenAuditLogs = () => {
setShowAuditLogs(true);
setAuditPage(1); // 페이지 초기화
loadAuditLogs();
};
// 페이지네이션된 오딧 로그
const paginatedAuditLogs = auditLogs.slice((auditPage - 1) * auditPageSize, auditPage * auditPageSize);
const totalAuditPages = Math.ceil(auditLogs.length / auditPageSize);
2025-10-24 15:40:08 +09:00
// 🆕 페이지네이션된 스텝 데이터
const paginatedStepData = stepData.slice((stepDataPage - 1) * stepDataPageSize, stepDataPage * stepDataPageSize);
const totalStepDataPages = Math.ceil(stepData.length / stepDataPageSize);
2025-10-20 10:55:33 +09:00
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>
);
}
2025-10-20 15:53:00 +09:00
// 반응형 컨테이너 클래스
2025-10-20 10:55:33 +09:00
const containerClass =
displayMode === "horizontal"
2025-10-20 15:53:00 +09:00
? "flex flex-col sm:flex-row sm:flex-wrap items-center justify-center gap-3 sm:gap-4"
2025-10-20 10:55:33 +09:00
: "flex flex-col items-center gap-4";
return (
2025-10-24 16:34:21 +09:00
<div className="@container flex w-full flex-col p-2 sm:p-4 lg:p-6">
2025-10-20 10:55:33 +09:00
{/* 플로우 제목 */}
2025-10-24 15:40:08 +09:00
<div className="mb-3 flex-shrink-0 sm:mb-4">
2025-10-20 15:53:00 +09:00
<div className="flex items-center justify-center gap-2">
<h3 className="text-foreground text-base font-semibold sm:text-lg lg:text-xl">{flowData.name}</h3>
{/* 오딧 로그 버튼 */}
<Dialog open={showAuditLogs} onOpenChange={setShowAuditLogs}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" onClick={handleOpenAuditLogs} className="gap-1.5">
<History className="h-4 w-4" />
<span className="hidden sm:inline"> </span>
</Button>
</DialogTrigger>
<DialogContent className="max-h-[85vh] max-w-[95vw] sm:max-w-[1000px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> ( {auditLogs.length})</DialogDescription>
</DialogHeader>
{auditLogsLoading ? (
<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>
) : auditLogs.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> </div>
) : (
<div className="space-y-4">
{/* 테이블 */}
<div className="bg-card overflow-hidden rounded-lg border">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[120px]"> </TableHead>
<TableHead className="w-[120px]"> </TableHead>
<TableHead className="w-[100px]"> ID</TableHead>
<TableHead className="w-[140px]"> </TableHead>
<TableHead className="w-[100px]"></TableHead>
2025-10-21 14:21:29 +09:00
<TableHead className="w-[150px]">DB </TableHead>
2025-10-20 15:53:00 +09:00
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedAuditLogs.map((log) => {
const fromStep = steps.find((s) => s.id === log.fromStepId);
const toStep = steps.find((s) => s.id === log.toStepId);
return (
<TableRow key={log.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-xs">
{new Date(log.changedAt).toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{log.moveType === "status"
? "상태"
: log.moveType === "table"
? "테이블"
: "하이브리드"}
</Badge>
</TableCell>
<TableCell className="font-medium">
{fromStep?.stepName || `Step ${log.fromStepId}`}
</TableCell>
<TableCell className="font-medium">
{toStep?.stepName || `Step ${log.toStepId}`}
</TableCell>
<TableCell className="font-mono text-xs">
{log.sourceDataId || "-"}
{log.targetDataId && log.targetDataId !== log.sourceDataId && (
<>
<br /> {log.targetDataId}
</>
)}
</TableCell>
<TableCell className="text-xs">
{log.statusFrom && log.statusTo ? (
<span className="font-mono">
{log.statusFrom}
<br /> {log.statusTo}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-xs">{log.changedBy}</TableCell>
2025-10-21 14:21:29 +09:00
<TableCell className="text-xs">
{log.dbConnectionName ? (
<span
className={
log.dbConnectionName === "내부 데이터베이스"
? "text-blue-600"
: "text-green-600"
}
>
{log.dbConnectionName}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
2025-10-20 15:53:00 +09:00
<TableCell className="text-xs">
{log.sourceTable || "-"}
{log.targetTable && log.targetTable !== log.sourceTable && (
<>
<br /> {log.targetTable}
</>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
{/* 페이지네이션 */}
{totalAuditPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
{(auditPage - 1) * auditPageSize + 1}-{Math.min(auditPage * auditPageSize, auditLogs.length)} /{" "}
{auditLogs.length}
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setAuditPage((p) => Math.max(1, p - 1))}
className={auditPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{Array.from({ length: totalAuditPages }, (_, i) => i + 1)
.filter((page) => {
// 현재 페이지 주변만 표시
return (
page === 1 ||
page === totalAuditPages ||
(page >= auditPage - 1 && page <= auditPage + 1)
);
})
.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={() => setAuditPage(page)}
isActive={auditPage === page}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
</React.Fragment>
))}
<PaginationItem>
<PaginationNext
onClick={() => setAuditPage((p) => Math.min(totalAuditPages, p + 1))}
className={
auditPage === totalAuditPages ? "pointer-events-none opacity-50" : "cursor-pointer"
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</div>
{flowData.description && (
<p className="text-muted-foreground mt-1 text-center text-xs sm:text-sm">{flowData.description}</p>
)}
2025-10-20 10:55:33 +09:00
</div>
{/* 플로우 스텝 목록 */}
2025-10-24 15:40:08 +09:00
<div className={`${containerClass} flex-shrink-0`}>
2025-10-20 10:55:33 +09:00
{steps.map((step, index) => (
<React.Fragment key={step.id}>
{/* 스텝 카드 */}
2025-10-20 15:53:00 +09:00
<div
className={`group bg-card relative w-full cursor-pointer rounded-lg border-2 p-4 shadow-sm transition-all duration-200 sm:w-auto sm:min-w-[180px] sm:rounded-xl sm:p-5 lg:min-w-[220px] lg:p-6 ${
selectedStepId === step.id
? "border-primary bg-primary/5 shadow-md"
: "border-border hover:border-primary/50 hover:shadow-md"
}`}
2025-10-20 10:55:33 +09:00
onClick={() => handleStepClick(step.id, step.stepName)}
>
2025-10-20 15:53:00 +09:00
{/* 단계 번호 배지 */}
<div className="bg-primary/10 text-primary mb-2 inline-flex items-center rounded-full px-2 py-1 text-xs font-medium sm:mb-3 sm:px-3">
Step {step.stepOrder}
2025-10-20 10:55:33 +09:00
</div>
2025-10-20 15:53:00 +09:00
{/* 스텝 이름 */}
<h4 className="text-foreground mb-2 pr-8 text-base leading-tight font-semibold sm:text-lg">
{step.stepName}
</h4>
{/* 데이터 건수 */}
{showStepCount && (
<div className="text-muted-foreground mt-2 flex items-center gap-2 text-xs sm:mt-3 sm:text-sm">
<div className="bg-muted flex h-7 items-center rounded-md px-2 sm:h-8 sm:px-3">
<span className="text-foreground text-sm font-semibold sm:text-base">
{stepCounts[step.id] || 0}
</span>
<span className="ml-1"></span>
2025-10-20 10:55:33 +09:00
</div>
2025-10-20 15:53:00 +09:00
</div>
)}
{/* 선택 인디케이터 */}
{selectedStepId === step.id && (
<div className="absolute top-3 right-3 sm:top-4 sm:right-4">
<ChevronUp className="text-primary h-4 w-4 sm:h-5 sm:w-5" />
</div>
)}
</div>
2025-10-20 10:55:33 +09:00
{/* 화살표 (마지막 스텝 제외) */}
{index < steps.length - 1 && (
2025-10-20 15:53:00 +09:00
<div className="text-muted-foreground/40 flex shrink-0 items-center justify-center py-2 sm:py-0">
{displayMode === "horizontal" ? (
<svg
className="h-5 w-5 rotate-90 sm:h-6 sm:w-6 sm:rotate-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
) : (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
2025-10-20 10:55:33 +09:00
</div>
)}
</React.Fragment>
))}
</div>
2025-10-20 15:53:00 +09:00
{/* 선택된 스텝의 데이터 리스트 */}
{selectedStepId !== null && (
2025-10-24 16:34:21 +09:00
<div className="bg-muted/30 mt-4 flex w-full flex-col rounded-lg border sm:mt-6 lg:mt-8">
{/* 헤더 - 자동 높이 */}
2025-10-24 15:40:08 +09:00
<div className="bg-background flex-shrink-0 border-b px-4 py-3 sm:px-6 sm:py-4">
<h4 className="text-foreground text-base font-semibold sm:text-lg">
{steps.find((s) => s.id === selectedStepId)?.stepName}
</h4>
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">
{stepData.length}
{selectedRows.size > 0 && (
<span className="text-primary ml-2 font-medium">({selectedRows.size} )</span>
)}
</p>
2025-10-20 15:53:00 +09:00
</div>
2025-10-24 16:34:21 +09:00
{/* 데이터 영역 - 고정 높이 + 스크롤 */}
{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">
2025-10-24 15:40:08 +09:00
{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" : ""
}`}
2025-10-20 15:53:00 +09:00
>
{allowDataMove && (
2025-10-24 15:40:08 +09:00
<div className="mb-2 flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground text-xs font-medium"></span>
2025-10-20 15:53:00 +09:00
<Checkbox
2025-10-24 15:40:08 +09:00
checked={selectedRows.has(actualIndex)}
onCheckedChange={() => toggleRowSelection(actualIndex)}
2025-10-20 15:53:00 +09:00
/>
2025-10-24 15:40:08 +09:00
</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">{col}:</span>
<span className="text-foreground truncate">
{row[col] !== null && row[col] !== undefined ? (
String(row[col])
) : (
<span className="text-muted-foreground">-</span>
)}
</span>
</div>
))}
</div>
</div>
);
})}
</div>
2025-10-24 16:34:21 +09:00
</div>
2025-10-24 15:40:08 +09:00
2025-10-24 16:34:21 +09:00
{/* 데스크톱: 테이블 뷰 - 고정 높이 + 스크롤 */}
2025-10-24 17:27:22 +09:00
<div className="relative hidden overflow-auto @sm:block" style={{ height: "450px" }}>
<Table noWrapper>
2025-10-24 16:34:21 +09:00
<TableHeader>
2025-10-24 17:27:22 +09:00
<TableRow className="hover:bg-muted/50">
2025-10-24 16:34:21 +09:00
{allowDataMove && (
2025-10-24 17:27:22 +09:00
<TableHead className="bg-background sticky top-0 left-0 z-20 w-12 border-b px-3 py-2 text-center shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
2025-10-24 16:34:21 +09:00
<Checkbox
checked={selectedRows.size === stepData.length && stepData.length > 0}
onCheckedChange={toggleAllRows}
/>
</TableHead>
)}
{stepDataColumns.map((col) => (
<TableHead
key={col}
2025-10-24 17:27:22 +09:00
className="bg-background sticky top-0 z-10 border-b px-3 py-2 text-xs font-semibold whitespace-nowrap shadow-[0_1px_0_0_rgb(0,0,0,0.1)] sm:text-sm"
2025-10-24 16:34:21 +09:00
>
{col}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{paginatedStepData.map((row, pageIndex) => {
const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex;
return (
<TableRow
key={actualIndex}
className={`hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`}
>
{allowDataMove && (
<TableCell className="bg-background sticky left-0 z-10 border-b px-3 py-2 text-center">
<Checkbox
checked={selectedRows.has(actualIndex)}
onCheckedChange={() => toggleRowSelection(actualIndex)}
/>
</TableCell>
)}
{stepDataColumns.map((col) => (
<TableCell key={col} className="border-b px-3 py-2 text-xs whitespace-nowrap sm:text-sm">
{row[col] !== null && row[col] !== undefined ? (
String(row[col])
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</>
)}
2025-10-24 15:40:08 +09:00
2025-10-24 16:34:21 +09:00
{/* 페이지네이션 - 항상 하단에 고정 */}
{!stepDataLoading && stepData.length > 0 && (
2025-10-24 15:40:08 +09:00
<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">
2025-10-24 16:34:21 +09:00
{/* 왼쪽: 페이지 정보 + 페이지 크기 선택 */}
<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})
</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-8 w-20 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>
2025-10-24 15:40:08 +09:00
</div>
2025-10-24 16:34:21 +09:00
{/* 오른쪽: 페이지네이션 */}
{totalStepDataPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setStepDataPage((p) => Math.max(1, p - 1))}
className={stepDataPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{totalStepDataPages <= 7 ? (
Array.from({ length: totalStepDataPages }, (_, i) => i + 1).map((page) => (
<PaginationItem key={page}>
<PaginationLink
onClick={() => setStepDataPage(page)}
isActive={stepDataPage === page}
className="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>
)}
2025-10-24 15:40:08 +09:00
<PaginationItem>
2025-10-24 16:34:21 +09:00
<PaginationLink
onClick={() => setStepDataPage(page)}
isActive={stepDataPage === page}
className="cursor-pointer"
>
{page}
</PaginationLink>
2025-10-24 15:40:08 +09:00
</PaginationItem>
2025-10-24 16:34:21 +09:00
</React.Fragment>
))}
</>
)}
<PaginationItem>
<PaginationNext
onClick={() => setStepDataPage((p) => Math.min(totalStepDataPages, p + 1))}
className={
stepDataPage === totalStepDataPages ? "pointer-events-none opacity-50" : "cursor-pointer"
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
2025-10-20 15:53:00 +09:00
</div>
2025-10-24 15:40:08 +09:00
</div>
2025-10-20 15:53:00 +09:00
)}
</div>
2025-10-20 10:55:33 +09:00
)}
</div>
);
}