2025-10-24 15:40:08 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 플로우 검증 결과 알림 (우측 상단 플로팅)
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { memo, useState } from "react";
|
|
|
|
|
|
import { AlertTriangle, AlertCircle, Info, X, ChevronDown, ChevronUp } from "lucide-react";
|
|
|
|
|
|
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
|
|
|
|
|
import { summarizeValidations } from "@/lib/utils/flowValidation";
|
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
|
|
|
|
|
|
interface ValidationNotificationProps {
|
|
|
|
|
|
validations: FlowValidation[];
|
|
|
|
|
|
onNodeClick?: (nodeId: string) => void;
|
|
|
|
|
|
onClose?: () => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-29 11:26:00 +09:00
|
|
|
|
export const ValidationNotification = memo(({ validations, onNodeClick, onClose }: ValidationNotificationProps) => {
|
|
|
|
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
|
|
const summary = summarizeValidations(validations);
|
2025-10-24 15:40:08 +09:00
|
|
|
|
|
2025-10-29 11:26:00 +09:00
|
|
|
|
if (validations.length === 0) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2025-10-24 15:40:08 +09:00
|
|
|
|
|
2025-10-29 11:26:00 +09:00
|
|
|
|
const getTypeLabel = (type: string): string => {
|
|
|
|
|
|
const labels: Record<string, string> = {
|
|
|
|
|
|
"disconnected-node": "연결되지 않은 노드",
|
|
|
|
|
|
"parallel-conflict": "병렬 실행 충돌",
|
|
|
|
|
|
"missing-where": "WHERE 조건 누락",
|
|
|
|
|
|
"circular-reference": "순환 참조",
|
|
|
|
|
|
"data-source-mismatch": "데이터 소스 불일치",
|
|
|
|
|
|
"parallel-table-access": "병렬 테이블 접근",
|
2025-10-24 15:40:08 +09:00
|
|
|
|
};
|
2025-10-29 11:26:00 +09:00
|
|
|
|
return labels[type] || type;
|
|
|
|
|
|
};
|
2025-10-24 15:40:08 +09:00
|
|
|
|
|
2025-10-29 11:26:00 +09:00
|
|
|
|
// 타입별로 그룹화
|
|
|
|
|
|
const groupedValidations = validations.reduce(
|
|
|
|
|
|
(acc, validation) => {
|
2025-10-24 15:40:08 +09:00
|
|
|
|
if (!acc[validation.type]) {
|
|
|
|
|
|
acc[validation.type] = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
acc[validation.type].push(validation);
|
|
|
|
|
|
return acc;
|
2025-10-29 11:26:00 +09:00
|
|
|
|
},
|
|
|
|
|
|
{} as Record<string, FlowValidation[]>,
|
|
|
|
|
|
);
|
2025-10-24 15:40:08 +09:00
|
|
|
|
|
2025-10-29 11:26:00 +09:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="animate-in slide-in-from-right-5 fixed top-4 right-4 z-50 w-80 duration-300">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
2026-03-10 18:30:18 +09:00
|
|
|
|
"bg-background rounded-lg border-2 shadow-2xl",
|
2025-10-29 11:26:00 +09:00
|
|
|
|
summary.hasBlockingIssues
|
2025-10-30 15:39:39 +09:00
|
|
|
|
? "border-destructive"
|
2025-10-29 11:26:00 +09:00
|
|
|
|
: summary.warningCount > 0
|
2025-10-30 15:39:39 +09:00
|
|
|
|
? "border-warning"
|
|
|
|
|
|
: "border-primary",
|
2025-10-29 11:26:00 +09:00
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* 헤더 */}
|
2025-10-24 15:40:08 +09:00
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
2025-10-29 11:26:00 +09:00
|
|
|
|
"flex cursor-pointer items-center justify-between p-3",
|
2026-03-10 18:30:18 +09:00
|
|
|
|
summary.hasBlockingIssues
|
|
|
|
|
|
? "bg-destructive/10"
|
|
|
|
|
|
: summary.warningCount > 0
|
|
|
|
|
|
? "bg-warning/10"
|
|
|
|
|
|
: "bg-primary/10",
|
2025-10-24 15:40:08 +09:00
|
|
|
|
)}
|
2025-10-29 11:26:00 +09:00
|
|
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
2025-10-24 15:40:08 +09:00
|
|
|
|
>
|
2025-10-29 11:26:00 +09:00
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
{summary.hasBlockingIssues ? (
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<AlertCircle className="text-destructive h-5 w-5" />
|
2025-10-29 11:26:00 +09:00
|
|
|
|
) : summary.warningCount > 0 ? (
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<AlertTriangle className="text-warning h-5 w-5" />
|
2025-10-29 11:26:00 +09:00
|
|
|
|
) : (
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<Info className="text-primary h-5 w-5" />
|
2025-10-24 15:40:08 +09:00
|
|
|
|
)}
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<span className="text-foreground text-sm font-semibold">플로우 검증</span>
|
2025-10-24 15:40:08 +09:00
|
|
|
|
<div className="flex items-center gap-1">
|
2025-10-29 11:26:00 +09:00
|
|
|
|
{summary.errorCount > 0 && (
|
|
|
|
|
|
<Badge variant="destructive" className="h-5 text-[10px]">
|
|
|
|
|
|
{summary.errorCount}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{summary.warningCount > 0 && (
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<Badge className="bg-warning hover:bg-warning/90 h-5 text-[10px]">{summary.warningCount}</Badge>
|
2025-10-24 15:40:08 +09:00
|
|
|
|
)}
|
2025-10-29 11:26:00 +09:00
|
|
|
|
{summary.infoCount > 0 && (
|
|
|
|
|
|
<Badge variant="secondary" className="h-5 text-[10px]">
|
|
|
|
|
|
{summary.infoCount}
|
|
|
|
|
|
</Badge>
|
2025-10-24 15:40:08 +09:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-29 11:26:00 +09:00
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
|
{isExpanded ? (
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<ChevronUp className="text-muted-foreground h-4 w-4" />
|
2025-10-29 11:26:00 +09:00
|
|
|
|
) : (
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<ChevronDown className="text-muted-foreground h-4 w-4" />
|
2025-10-29 11:26:00 +09:00
|
|
|
|
)}
|
|
|
|
|
|
{onClose && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onClose();
|
|
|
|
|
|
}}
|
2026-03-10 18:30:18 +09:00
|
|
|
|
className="hover:bg-muted h-6 w-6 p-0"
|
2025-10-29 11:26:00 +09:00
|
|
|
|
>
|
|
|
|
|
|
<X className="h-3.5 w-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-24 15:40:08 +09:00
|
|
|
|
|
2025-10-29 11:26:00 +09:00
|
|
|
|
{/* 확장된 내용 */}
|
|
|
|
|
|
{isExpanded && (
|
|
|
|
|
|
<div className="max-h-[60vh] overflow-y-auto border-t">
|
|
|
|
|
|
<div className="space-y-2 p-2">
|
|
|
|
|
|
{Object.entries(groupedValidations).map(([type, typeValidations]) => {
|
|
|
|
|
|
const firstValidation = typeValidations[0];
|
|
|
|
|
|
const Icon =
|
|
|
|
|
|
firstValidation.severity === "error"
|
|
|
|
|
|
? AlertCircle
|
|
|
|
|
|
: firstValidation.severity === "warning"
|
|
|
|
|
|
? AlertTriangle
|
|
|
|
|
|
: Info;
|
2025-10-24 15:40:08 +09:00
|
|
|
|
|
2025-10-29 11:26:00 +09:00
|
|
|
|
return (
|
|
|
|
|
|
<div key={type}>
|
|
|
|
|
|
{/* 타입 헤더 */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"mb-1 flex items-center gap-2 rounded-md px-2 py-1 text-xs font-medium",
|
|
|
|
|
|
firstValidation.severity === "error"
|
2025-10-30 15:39:39 +09:00
|
|
|
|
? "bg-destructive/10 text-destructive"
|
2025-10-29 11:26:00 +09:00
|
|
|
|
: firstValidation.severity === "warning"
|
2025-10-30 15:39:39 +09:00
|
|
|
|
? "bg-warning/10 text-warning"
|
|
|
|
|
|
: "bg-primary/10 text-primary",
|
2025-10-29 11:26:00 +09:00
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon className="h-3 w-3" />
|
|
|
|
|
|
{getTypeLabel(type)}
|
|
|
|
|
|
<span className="ml-auto">{typeValidations.length}개</span>
|
|
|
|
|
|
</div>
|
2025-10-24 15:40:08 +09:00
|
|
|
|
|
2025-10-29 11:26:00 +09:00
|
|
|
|
{/* 검증 항목들 */}
|
|
|
|
|
|
<div className="space-y-1 pl-5">
|
|
|
|
|
|
{typeValidations.map((validation, index) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={index}
|
2026-03-10 18:30:18 +09:00
|
|
|
|
className="group border-border bg-muted hover:border-primary/50 hover:bg-background cursor-pointer rounded-md border p-2 text-xs transition-all hover:shadow-sm"
|
2025-10-29 11:26:00 +09:00
|
|
|
|
onClick={() => onNodeClick?.(validation.nodeId)}
|
|
|
|
|
|
>
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<p className="text-foreground leading-relaxed">{validation.message}</p>
|
2025-10-29 11:26:00 +09:00
|
|
|
|
{validation.affectedNodes && validation.affectedNodes.length > 1 && (
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<div className="text-muted-foreground mt-1 text-[10px]">
|
2025-10-29 11:26:00 +09:00
|
|
|
|
영향받는 노드: {validation.affectedNodes.length}개
|
2025-10-24 15:40:08 +09:00
|
|
|
|
</div>
|
2025-10-29 11:26:00 +09:00
|
|
|
|
)}
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<div className="text-primary mt-1 text-[10px] opacity-0 transition-opacity group-hover:opacity-100">
|
2025-10-29 11:26:00 +09:00
|
|
|
|
클릭하여 노드 보기 →
|
2025-10-24 15:40:08 +09:00
|
|
|
|
</div>
|
2025-10-29 11:26:00 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
2025-10-24 15:40:08 +09:00
|
|
|
|
</div>
|
2025-10-29 11:26:00 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2025-10-24 15:40:08 +09:00
|
|
|
|
</div>
|
2025-10-29 11:26:00 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-10-24 15:40:08 +09:00
|
|
|
|
|
2025-10-29 11:26:00 +09:00
|
|
|
|
{/* 요약 메시지 (닫혀있을 때) */}
|
|
|
|
|
|
{!isExpanded && (
|
|
|
|
|
|
<div className="border-t px-3 py-2">
|
2026-03-10 18:30:18 +09:00
|
|
|
|
<p className="text-muted-foreground text-xs">
|
2025-10-29 11:26:00 +09:00
|
|
|
|
{summary.hasBlockingIssues
|
|
|
|
|
|
? "⛔ 오류를 해결해야 저장할 수 있습니다"
|
|
|
|
|
|
: summary.warningCount > 0
|
|
|
|
|
|
? "⚠️ 경고 사항을 확인하세요"
|
|
|
|
|
|
: "ℹ️ 정보를 확인하세요"}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-10-24 15:40:08 +09:00
|
|
|
|
</div>
|
2025-10-29 11:26:00 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
2025-10-24 15:40:08 +09:00
|
|
|
|
|
|
|
|
|
|
ValidationNotification.displayName = "ValidationNotification";
|