플로우 분기처리 구현
This commit is contained in:
@@ -4,10 +4,29 @@ 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 } from "lucide-react";
|
||||
import { getFlowById, getAllStepCounts } from "@/lib/api/flow";
|
||||
import type { FlowDefinition, FlowStep } from "@/types/flow";
|
||||
import { FlowDataListModal } from "@/components/flow/FlowDataListModal";
|
||||
import { AlertCircle, Loader2, ChevronDown, ChevronUp, History } from "lucide-react";
|
||||
import { getFlowById, getAllStepCounts, getStepDataList, moveBatchData, getFlowAuditLogs } 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 { toast } from "sonner";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
|
||||
interface FlowWidgetProps {
|
||||
component: FlowComponent;
|
||||
@@ -20,10 +39,23 @@ export function FlowWidget({ component, onStepClick }: FlowWidgetProps) {
|
||||
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 [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedStep, setSelectedStep] = useState<{ id: number; name: string } | null>(null);
|
||||
// 선택된 스텝의 데이터 리스트 상태
|
||||
const [selectedStepId, setSelectedStepId] = useState<number | null>(null);
|
||||
const [stepData, setStepData] = useState<any[]>([]);
|
||||
const [stepDataColumns, setStepDataColumns] = useState<string[]>([]);
|
||||
const [stepDataLoading, setStepDataLoading] = useState(false);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
const [movingData, setMovingData] = useState(false);
|
||||
const [selectedNextStepId, setSelectedNextStepId] = useState<number | null>(null); // 선택된 다음 단계
|
||||
|
||||
// 오딧 로그 상태
|
||||
const [auditLogs, setAuditLogs] = useState<FlowAuditLog[]>([]);
|
||||
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 || {};
|
||||
@@ -78,6 +110,15 @@ export function FlowWidget({ component, onStepClick }: FlowWidgetProps) {
|
||||
const sortedSteps = stepsData.data.sort((a: FlowStep, b: FlowStep) => a.stepOrder - b.stepOrder);
|
||||
setSteps(sortedSteps);
|
||||
|
||||
// 연결 정보 조회
|
||||
const connectionsResponse = await fetch(`/api/flow/connections/${flowId}`);
|
||||
if (connectionsResponse.ok) {
|
||||
const connectionsData = await connectionsResponse.json();
|
||||
if (connectionsData.success && connectionsData.data) {
|
||||
setConnections(connectionsData.data);
|
||||
}
|
||||
}
|
||||
|
||||
// 스텝별 데이터 건수 조회
|
||||
if (showStepCount) {
|
||||
const countsResponse = await getAllStepCounts(flowId!);
|
||||
@@ -103,36 +144,176 @@ export function FlowWidget({ component, onStepClick }: FlowWidgetProps) {
|
||||
}, [flowId, showStepCount]);
|
||||
|
||||
// 스텝 클릭 핸들러
|
||||
const handleStepClick = (stepId: number, stepName: string) => {
|
||||
const handleStepClick = async (stepId: number, stepName: string) => {
|
||||
if (onStepClick) {
|
||||
onStepClick(stepId, stepName);
|
||||
} else {
|
||||
// 기본 동작: 모달 열기
|
||||
setSelectedStep({ id: stepId, name: stepName });
|
||||
setModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 스텝을 다시 클릭하면 접기
|
||||
if (selectedStepId === stepId) {
|
||||
setSelectedStepId(null);
|
||||
setStepData([]);
|
||||
setStepDataColumns([]);
|
||||
setSelectedRows(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
// 새로운 스텝 선택 - 데이터 로드
|
||||
setSelectedStepId(stepId);
|
||||
setStepDataLoading(true);
|
||||
setSelectedRows(new Set());
|
||||
|
||||
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 handleDataMoved = async () => {
|
||||
if (!flowId) return;
|
||||
// 체크박스 토글
|
||||
const toggleRowSelection = (rowIndex: number) => {
|
||||
const newSelected = new Set(selectedRows);
|
||||
if (newSelected.has(rowIndex)) {
|
||||
newSelected.delete(rowIndex);
|
||||
} else {
|
||||
newSelected.add(rowIndex);
|
||||
}
|
||||
setSelectedRows(newSelected);
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
const toggleAllRows = () => {
|
||||
if (selectedRows.size === stepData.length) {
|
||||
setSelectedRows(new Set());
|
||||
} else {
|
||||
setSelectedRows(new Set(stepData.map((_, index) => index)));
|
||||
}
|
||||
};
|
||||
|
||||
// 현재 단계에서 가능한 다음 단계들 찾기
|
||||
const getNextSteps = (currentStepId: number) => {
|
||||
return connections
|
||||
.filter((conn) => conn.fromStepId === currentStepId)
|
||||
.map((conn) => steps.find((s) => s.id === conn.toStepId))
|
||||
.filter((step) => step !== undefined);
|
||||
};
|
||||
|
||||
// 다음 단계로 이동
|
||||
const handleMoveToNext = async (targetStepId?: number) => {
|
||||
if (!flowId || !selectedStepId || selectedRows.size === 0) return;
|
||||
|
||||
// 다음 단계 결정
|
||||
let nextStepId = targetStepId || selectedNextStepId;
|
||||
|
||||
if (!nextStepId) {
|
||||
const nextSteps = getNextSteps(selectedStepId);
|
||||
if (nextSteps.length === 0) {
|
||||
toast.error("다음 단계가 없습니다");
|
||||
return;
|
||||
}
|
||||
if (nextSteps.length === 1) {
|
||||
nextStepId = nextSteps[0].id;
|
||||
} else {
|
||||
toast.error("다음 단계를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedData = Array.from(selectedRows).map((index) => stepData[index]);
|
||||
|
||||
try {
|
||||
// 스텝별 데이터 건수 다시 조회
|
||||
setMovingData(true);
|
||||
|
||||
// Primary Key 컬럼 추출 (첫 번째 컬럼 가정)
|
||||
const primaryKeyColumn = stepDataColumns[0];
|
||||
const dataIds = selectedData.map((data) => String(data[primaryKeyColumn]));
|
||||
|
||||
// 배치 이동 API 호출
|
||||
const response = await moveBatchData({
|
||||
flowId,
|
||||
fromStepId: selectedStepId,
|
||||
toStepId: nextStepId,
|
||||
dataIds,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || "데이터 이동에 실패했습니다");
|
||||
}
|
||||
|
||||
const nextStepName = steps.find((s) => s.id === nextStepId)?.stepName;
|
||||
toast.success(`${selectedRows.size}건의 데이터를 "${nextStepName}"(으)로 이동했습니다`);
|
||||
|
||||
// 선택 초기화
|
||||
setSelectedNextStepId(null);
|
||||
setSelectedRows(new Set());
|
||||
|
||||
// 데이터 새로고침
|
||||
await handleStepClick(selectedStepId, steps.find((s) => s.id === selectedStepId)?.stepName || "");
|
||||
|
||||
// 건수 새로고침
|
||||
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);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh step counts:", err);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to move data:", err);
|
||||
toast.error(err.message || "데이터 이동 중 오류가 발생했습니다");
|
||||
} finally {
|
||||
setMovingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 오딧 로그 로드
|
||||
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);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
@@ -167,17 +348,189 @@ export function FlowWidget({ component, onStepClick }: FlowWidgetProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// 반응형 컨테이너 클래스
|
||||
const containerClass =
|
||||
displayMode === "horizontal"
|
||||
? "flex flex-wrap items-center justify-center gap-3"
|
||||
? "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="min-h-full w-full p-4">
|
||||
<div className="@container min-h-full w-full p-2 sm:p-4 lg:p-6">
|
||||
{/* 플로우 제목 */}
|
||||
<div className="mb-4 text-center">
|
||||
<h3 className="text-foreground text-lg font-semibold">{flowData.name}</h3>
|
||||
{flowData.description && <p className="text-muted-foreground mt-1 text-sm">{flowData.description}</p>}
|
||||
<div className="mb-3 sm:mb-4">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 플로우 스텝 목록 */}
|
||||
@@ -185,53 +538,272 @@ export function FlowWidget({ component, onStepClick }: FlowWidgetProps) {
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.id}>
|
||||
{/* 스텝 카드 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:border-primary hover:bg-accent flex shrink-0 flex-col items-start gap-3 p-5"
|
||||
<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"
|
||||
}`}
|
||||
onClick={() => handleStepClick(step.id, step.stepName)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Badge variant="outline" className="text-sm">
|
||||
단계 {step.stepOrder}
|
||||
</Badge>
|
||||
{showStepCount && (
|
||||
<Badge variant="secondary" className="text-sm font-semibold">
|
||||
{stepCounts[step.id] || 0}건
|
||||
</Badge>
|
||||
)}
|
||||
{/* 단계 번호 배지 */}
|
||||
<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}
|
||||
</div>
|
||||
<div className="w-full text-left">
|
||||
<div className="text-foreground text-base font-semibold">{step.stepName}</div>
|
||||
{step.tableName && (
|
||||
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-sm">
|
||||
<span>📊</span>
|
||||
<span>{step.tableName}</span>
|
||||
|
||||
{/* 스텝 이름 */}
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
{/* 화살표 (마지막 스텝 제외) */}
|
||||
{index < steps.length - 1 && (
|
||||
<div className="text-muted-foreground flex shrink-0 items-center justify-center text-2xl font-bold">
|
||||
{displayMode === "horizontal" ? "→" : "↓"}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 데이터 목록 모달 */}
|
||||
{selectedStep && flowId && (
|
||||
<FlowDataListModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
flowId={flowId}
|
||||
stepId={selectedStep.id}
|
||||
stepName={selectedStep.name}
|
||||
allowDataMove={allowDataMove}
|
||||
onDataMoved={handleDataMoved}
|
||||
/>
|
||||
{/* 선택된 스텝의 데이터 리스트 */}
|
||||
{selectedStepId !== null && (
|
||||
<div className="bg-muted/30 mt-4 w-full rounded-lg p-4 sm:mt-6 sm:rounded-xl sm:p-5 lg:mt-8 lg:p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex flex-col items-start justify-between gap-3 sm:mb-6 sm:flex-row sm:items-center">
|
||||
<div className="flex-1">
|
||||
<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}건의 데이터</p>
|
||||
</div>
|
||||
{allowDataMove &&
|
||||
selectedRows.size > 0 &&
|
||||
(() => {
|
||||
const nextSteps = getNextSteps(selectedStepId);
|
||||
return nextSteps.length > 1 ? (
|
||||
// 다음 단계가 여러 개인 경우: 선택 UI 표시
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<Select
|
||||
value={selectedNextStepId?.toString() || ""}
|
||||
onValueChange={(value) => setSelectedNextStepId(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:w-[180px] sm:text-sm">
|
||||
<SelectValue placeholder="이동할 단계 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{nextSteps.map((step) => (
|
||||
<SelectItem key={step.id} value={step.id.toString()}>
|
||||
{step.stepName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
onClick={() => handleMoveToNext()}
|
||||
disabled={movingData || !selectedNextStepId}
|
||||
className="h-8 gap-1 px-3 text-xs sm:h-10 sm:gap-2 sm:px-4 sm:text-sm"
|
||||
>
|
||||
{movingData ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
|
||||
<span>이동 중...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-3 w-3 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
/>
|
||||
</svg>
|
||||
<span>이동 ({selectedRows.size})</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// 다음 단계가 하나인 경우: 바로 이동 버튼만 표시
|
||||
<Button
|
||||
onClick={() => handleMoveToNext()}
|
||||
disabled={movingData}
|
||||
className="h-8 w-full gap-1 px-3 text-xs sm:h-10 sm:w-auto sm:gap-2 sm:px-4 sm:text-sm"
|
||||
>
|
||||
{movingData ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
|
||||
<span className="hidden sm:inline">이동 중...</span>
|
||||
<span className="sm:hidden">이동중</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-3 w-3 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
/>
|
||||
</svg>
|
||||
<span className="hidden sm:inline">
|
||||
{nextSteps.length > 0 ? `${nextSteps[0].stepName}(으)로 이동` : "다음 단계로 이동"} (
|
||||
{selectedRows.size})
|
||||
</span>
|
||||
<span className="sm:hidden">다음 ({selectedRows.size})</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
{stepDataLoading ? (
|
||||
<div className="flex items-center justify-center py-8 sm:py-12">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin sm:h-8 sm:w-8" />
|
||||
<span className="text-muted-foreground ml-2 text-xs sm:ml-3 sm:text-sm">데이터 로딩 중...</span>
|
||||
</div>
|
||||
) : stepData.length === 0 ? (
|
||||
<div className="bg-card flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-8 sm:py-12">
|
||||
<svg
|
||||
className="text-muted-foreground/50 mb-2 h-10 w-10 sm:mb-3 sm:h-12 sm: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-xs sm:text-sm">데이터가 없습니다</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 모바일: 카드 뷰 (컨테이너 640px 미만) */}
|
||||
<div className="space-y-3 @sm:hidden">
|
||||
{stepData.map((row, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`bg-card rounded-lg border p-3 transition-colors ${
|
||||
selectedRows.has(index) ? "border-primary bg-primary/5" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{/* 체크박스 헤더 */}
|
||||
{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(index)} onCheckedChange={() => toggleRowSelection(index)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 필드들 */}
|
||||
<div className="space-y-2">
|
||||
{stepDataColumns.map((col) => (
|
||||
<div key={col} className="flex justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs font-medium">{col}:</span>
|
||||
<span className="text-foreground truncate text-xs">
|
||||
{row[col] !== null && row[col] !== undefined ? (
|
||||
String(row[col])
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 데스크톱: 테이블 뷰 (컨테이너 640px 이상) */}
|
||||
<div className="bg-card hidden overflow-x-auto rounded-lg border shadow-sm @sm:block">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
{allowDataMove && (
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedRows.size === stepData.length && stepData.length > 0}
|
||||
onCheckedChange={toggleAllRows}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{stepDataColumns.map((col) => (
|
||||
<TableHead key={col} className="text-xs font-semibold whitespace-nowrap sm:text-sm">
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stepData.map((row, index) => (
|
||||
<TableRow
|
||||
key={index}
|
||||
className={`transition-colors ${selectedRows.has(index) ? "bg-primary/5" : "hover:bg-muted/50"}`}
|
||||
>
|
||||
{allowDataMove && (
|
||||
<TableCell className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(index)}
|
||||
onCheckedChange={() => toggleRowSelection(index)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{stepDataColumns.map((col) => (
|
||||
<TableCell key={col} className="font-mono 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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user