246 lines
9.0 KiB
TypeScript
246 lines
9.0 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 플로우 검증 결과 패널
|
|
*
|
|
* 모든 검증 결과를 사이드바에 표시
|
|
*/
|
|
|
|
import { memo, useMemo } from "react";
|
|
import { AlertTriangle, AlertCircle, Info, ChevronDown, ChevronUp, X } from "lucide-react";
|
|
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
|
import { summarizeValidations } from "@/lib/utils/flowValidation";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { cn } from "@/lib/utils";
|
|
import { useState } from "react";
|
|
|
|
interface ValidationPanelProps {
|
|
validations: FlowValidation[];
|
|
onNodeClick?: (nodeId: string) => void;
|
|
onClose?: () => void;
|
|
}
|
|
|
|
export const ValidationPanel = memo(
|
|
({ validations, onNodeClick, onClose }: ValidationPanelProps) => {
|
|
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(new Set());
|
|
|
|
const summary = useMemo(
|
|
() => summarizeValidations(validations),
|
|
[validations]
|
|
);
|
|
|
|
// 타입별로 그룹화
|
|
const groupedValidations = useMemo(() => {
|
|
const groups = new Map<string, FlowValidation[]>();
|
|
for (const validation of validations) {
|
|
if (!groups.has(validation.type)) {
|
|
groups.set(validation.type, []);
|
|
}
|
|
groups.get(validation.type)!.push(validation);
|
|
}
|
|
return Array.from(groups.entries()).sort((a, b) => {
|
|
// 심각도 순으로 정렬
|
|
const severityOrder = { error: 0, warning: 1, info: 2 };
|
|
const aSeverity = Math.min(
|
|
...a[1].map((v) => severityOrder[v.severity])
|
|
);
|
|
const bSeverity = Math.min(
|
|
...b[1].map((v) => severityOrder[v.severity])
|
|
);
|
|
return aSeverity - bSeverity;
|
|
});
|
|
}, [validations]);
|
|
|
|
const toggleExpanded = (type: string) => {
|
|
setExpandedTypes((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(type)) {
|
|
next.delete(type);
|
|
} else {
|
|
next.add(type);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const getTypeLabel = (type: string): string => {
|
|
const labels: Record<string, string> = {
|
|
"parallel-conflict": "병렬 실행 충돌",
|
|
"missing-where": "WHERE 조건 누락",
|
|
"circular-reference": "순환 참조",
|
|
"data-source-mismatch": "데이터 소스 불일치",
|
|
"parallel-table-access": "병렬 테이블 접근",
|
|
};
|
|
return labels[type] || type;
|
|
};
|
|
|
|
if (validations.length === 0) {
|
|
return (
|
|
<div className="flex h-full flex-col border-l border-gray-200 bg-white">
|
|
<div className="flex items-center justify-between border-b border-gray-200 p-4">
|
|
<h3 className="text-sm font-semibold text-gray-900">검증 결과</h3>
|
|
{onClose && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={onClose}
|
|
className="h-6 w-6 p-0"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-1 items-center justify-center p-8 text-center">
|
|
<div>
|
|
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
|
<Info className="h-6 w-6 text-green-600" />
|
|
</div>
|
|
<p className="text-sm font-medium text-gray-900">문제 없음</p>
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
플로우에 문제가 발견되지 않았습니다
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col border-l border-gray-200 bg-white">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between border-b border-gray-200 p-4">
|
|
<h3 className="text-sm font-semibold text-gray-900">검증 결과</h3>
|
|
{onClose && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={onClose}
|
|
className="h-6 w-6 p-0"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 요약 */}
|
|
<div className="border-b border-gray-200 bg-gray-50 p-4">
|
|
<div className="flex items-center gap-3">
|
|
{summary.errorCount > 0 && (
|
|
<Badge variant="destructive" className="gap-1">
|
|
<AlertCircle className="h-3 w-3" />
|
|
오류 {summary.errorCount}
|
|
</Badge>
|
|
)}
|
|
{summary.warningCount > 0 && (
|
|
<Badge className="gap-1 bg-yellow-500 hover:bg-yellow-600">
|
|
<AlertTriangle className="h-3 w-3" />
|
|
경고 {summary.warningCount}
|
|
</Badge>
|
|
)}
|
|
{summary.infoCount > 0 && (
|
|
<Badge variant="secondary" className="gap-1">
|
|
<Info className="h-3 w-3" />
|
|
정보 {summary.infoCount}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{summary.hasBlockingIssues && (
|
|
<p className="mt-2 text-xs text-red-600">
|
|
⛔ 오류를 해결해야 저장할 수 있습니다
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* 검증 결과 목록 */}
|
|
<ScrollArea className="flex-1">
|
|
<div className="p-2">
|
|
{groupedValidations.map(([type, typeValidations]) => {
|
|
const isExpanded = expandedTypes.has(type);
|
|
const firstValidation = typeValidations[0];
|
|
const Icon =
|
|
firstValidation.severity === "error"
|
|
? AlertCircle
|
|
: firstValidation.severity === "warning"
|
|
? AlertTriangle
|
|
: Info;
|
|
|
|
return (
|
|
<div key={type} className="mb-2">
|
|
{/* 그룹 헤더 */}
|
|
<button
|
|
onClick={() => toggleExpanded(type)}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 rounded-lg p-3 text-left transition-colors",
|
|
firstValidation.severity === "error"
|
|
? "bg-red-50 hover:bg-red-100"
|
|
: firstValidation.severity === "warning"
|
|
? "bg-yellow-50 hover:bg-yellow-100"
|
|
: "bg-blue-50 hover:bg-blue-100"
|
|
)}
|
|
>
|
|
<Icon
|
|
className={cn(
|
|
"h-4 w-4 shrink-0",
|
|
firstValidation.severity === "error"
|
|
? "text-red-600"
|
|
: firstValidation.severity === "warning"
|
|
? "text-yellow-600"
|
|
: "text-blue-600"
|
|
)}
|
|
/>
|
|
<div className="flex-1">
|
|
<div className="text-sm font-medium text-gray-900">
|
|
{getTypeLabel(type)}
|
|
</div>
|
|
<div className="text-xs text-gray-500">
|
|
{typeValidations.length}개 발견
|
|
</div>
|
|
</div>
|
|
{isExpanded ? (
|
|
<ChevronUp className="h-4 w-4 text-gray-400" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-gray-400" />
|
|
)}
|
|
</button>
|
|
|
|
{/* 상세 내용 */}
|
|
{isExpanded && (
|
|
<div className="mt-1 space-y-1 pl-6 pr-2">
|
|
{typeValidations.map((validation, index) => (
|
|
<div
|
|
key={index}
|
|
className="group cursor-pointer rounded-lg border border-gray-200 bg-white p-3 transition-all hover:border-gray-300 hover:shadow-sm"
|
|
onClick={() => onNodeClick?.(validation.nodeId)}
|
|
>
|
|
<div className="text-xs text-gray-700">
|
|
{validation.message}
|
|
</div>
|
|
{validation.affectedNodes && validation.affectedNodes.length > 1 && (
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<Badge variant="outline" className="text-[10px]">
|
|
영향받는 노드: {validation.affectedNodes.length}개
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
<div className="mt-2 text-[10px] text-gray-400 opacity-0 transition-opacity group-hover:opacity-100">
|
|
클릭하여 노드 보기
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
ValidationPanel.displayName = "ValidationPanel";
|
|
|