560 lines
22 KiB
TypeScript
560 lines
22 KiB
TypeScript
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
|
import { useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Plus,
|
|
Play,
|
|
Pause,
|
|
Trash2,
|
|
Settings,
|
|
Clock,
|
|
Zap,
|
|
Webhook,
|
|
Mail,
|
|
MessageSquare,
|
|
Bot,
|
|
Database,
|
|
FileText,
|
|
Loader2,
|
|
CheckCircle,
|
|
XCircle,
|
|
AlertCircle,
|
|
ChevronRight,
|
|
Calendar
|
|
} from "lucide-react";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
|
|
interface Automation {
|
|
id: number;
|
|
name: string;
|
|
description: string | null;
|
|
triggerType: string;
|
|
triggerConfig: string | null;
|
|
isActive: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
interface AutomationLog {
|
|
id: number;
|
|
status: string;
|
|
result: string | null;
|
|
error: string | null;
|
|
startedAt: string;
|
|
completedAt: string | null;
|
|
}
|
|
|
|
const TRIGGER_TYPES = [
|
|
{ value: "schedule", label: "Agendamento", icon: Clock, description: "Executa em horários programados" },
|
|
{ value: "webhook", label: "Webhook", icon: Webhook, description: "Executa quando recebe uma requisição" },
|
|
{ value: "manual", label: "Manual", icon: Play, description: "Executa apenas quando você clicar" },
|
|
{ value: "event", label: "Evento", icon: Zap, description: "Executa quando algo acontece no sistema" },
|
|
];
|
|
|
|
const ACTION_TYPES = [
|
|
{ value: "agent_task", label: "Tarefa do Agente IA", icon: Bot, description: "Executa uma tarefa autônoma com o Agente" },
|
|
{ value: "send_notification", label: "Enviar Notificação", icon: AlertCircle, description: "Envia uma notificação interna" },
|
|
{ value: "send_email", label: "Enviar Email", icon: Mail, description: "Envia um email" },
|
|
{ value: "send_whatsapp", label: "Enviar WhatsApp", icon: MessageSquare, description: "Envia mensagem no WhatsApp" },
|
|
{ value: "erp_sync", label: "Sincronizar ERP", icon: Database, description: "Sincroniza dados com o ERP" },
|
|
{ value: "generate_report", label: "Gerar Relatório", icon: FileText, description: "Gera um relatório automático" },
|
|
{ value: "webhook", label: "Chamar Webhook", icon: Webhook, description: "Faz uma requisição HTTP" },
|
|
{ value: "database_backup", label: "Backup de Banco de Dados", icon: Database, description: "Executa backup de uma fonte de dados" },
|
|
];
|
|
|
|
async function fetchDataSources(): Promise<any[]> {
|
|
const response = await fetch("/api/bi/data-sources", { credentials: "include" });
|
|
if (!response.ok) return [];
|
|
return response.json();
|
|
}
|
|
|
|
async function fetchAutomations(): Promise<Automation[]> {
|
|
const response = await fetch("/api/automations", { credentials: "include" });
|
|
if (!response.ok) throw new Error("Failed to fetch automations");
|
|
return response.json();
|
|
}
|
|
|
|
async function createAutomation(data: any): Promise<Automation> {
|
|
const response = await fetch("/api/automations", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data),
|
|
credentials: "include",
|
|
});
|
|
if (!response.ok) throw new Error("Failed to create automation");
|
|
return response.json();
|
|
}
|
|
|
|
async function deleteAutomation(id: number): Promise<void> {
|
|
const response = await fetch(`/api/automations/${id}`, {
|
|
method: "DELETE",
|
|
credentials: "include",
|
|
});
|
|
if (!response.ok) throw new Error("Failed to delete automation");
|
|
}
|
|
|
|
async function toggleAutomation(id: number, isActive: boolean): Promise<void> {
|
|
const response = await fetch(`/api/automations/${id}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ isActive: isActive ? "true" : "false" }),
|
|
credentials: "include",
|
|
});
|
|
if (!response.ok) throw new Error("Failed to toggle automation");
|
|
}
|
|
|
|
async function runAutomation(id: number): Promise<any> {
|
|
const response = await fetch(`/api/automations/${id}/run`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
});
|
|
if (!response.ok) throw new Error("Failed to run automation");
|
|
return response.json();
|
|
}
|
|
|
|
async function fetchAutomationLogs(id: number): Promise<AutomationLog[]> {
|
|
const response = await fetch(`/api/automations/${id}/logs`, { credentials: "include" });
|
|
if (!response.ok) throw new Error("Failed to fetch logs");
|
|
return response.json();
|
|
}
|
|
|
|
function CreateAutomationDialog({ onCreated }: { onCreated: () => void }) {
|
|
const [open, setOpen] = useState(false);
|
|
const [name, setName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [triggerType, setTriggerType] = useState("manual");
|
|
const [actionType, setActionType] = useState("agent_task");
|
|
const [actionPrompt, setActionPrompt] = useState("");
|
|
const [intervalMinutes, setIntervalMinutes] = useState("60");
|
|
const [selectedDataSource, setSelectedDataSource] = useState("");
|
|
|
|
const { data: dataSources = [] } = useQuery({
|
|
queryKey: ["data-sources"],
|
|
queryFn: fetchDataSources,
|
|
});
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: createAutomation,
|
|
onSuccess: () => {
|
|
setOpen(false);
|
|
setName("");
|
|
setDescription("");
|
|
setTriggerType("manual");
|
|
setActionType("agent_task");
|
|
setActionPrompt("");
|
|
setSelectedDataSource("");
|
|
onCreated();
|
|
},
|
|
});
|
|
|
|
const handleCreate = () => {
|
|
if (!name.trim()) return;
|
|
|
|
let actionConfig: any = {};
|
|
if (actionType === "agent_task") {
|
|
actionConfig = { prompt: actionPrompt };
|
|
} else if (actionType === "database_backup" && selectedDataSource) {
|
|
actionConfig = { dataSourceId: parseInt(selectedDataSource) };
|
|
}
|
|
|
|
createMutation.mutate({
|
|
name,
|
|
description,
|
|
triggerType,
|
|
actions: [
|
|
{
|
|
actionType,
|
|
actionConfig,
|
|
},
|
|
],
|
|
schedule: triggerType === "schedule" ? { intervalMinutes: parseInt(intervalMinutes) } : undefined,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button className="bg-[#c89b3c] hover:bg-[#d4a94a] text-[#1f334d]" data-testid="button-new-automation">
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Nova Automação
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-w-lg bg-[#1f334d] border-[#c89b3c]/30 text-white">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-[#c89b3c]">Criar Nova Automação</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 mt-4">
|
|
<div>
|
|
<label className="text-sm text-white/70 mb-1 block">Nome</label>
|
|
<Input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="Ex: Relatório diário de vendas"
|
|
className="bg-[#162638] border-white/20 text-white"
|
|
data-testid="input-automation-name"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm text-white/70 mb-1 block">Descrição (opcional)</label>
|
|
<Input
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="O que esta automação faz..."
|
|
className="bg-[#162638] border-white/20 text-white"
|
|
data-testid="input-automation-description"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm text-white/70 mb-2 block">Quando executar?</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{TRIGGER_TYPES.map((trigger) => (
|
|
<button
|
|
key={trigger.value}
|
|
onClick={() => setTriggerType(trigger.value)}
|
|
className={`p-3 rounded-lg border text-left transition-all ${
|
|
triggerType === trigger.value
|
|
? "border-[#c89b3c] bg-[#c89b3c]/20"
|
|
: "border-white/10 hover:border-white/30"
|
|
}`}
|
|
data-testid={`trigger-${trigger.value}`}
|
|
>
|
|
<trigger.icon className={`w-5 h-5 mb-1 ${triggerType === trigger.value ? "text-[#c89b3c]" : "text-white/50"}`} />
|
|
<p className="text-sm font-medium">{trigger.label}</p>
|
|
<p className="text-xs text-white/50">{trigger.description}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{triggerType === "schedule" && (
|
|
<div>
|
|
<label className="text-sm text-white/70 mb-1 block">Intervalo (minutos)</label>
|
|
<Input
|
|
type="number"
|
|
value={intervalMinutes}
|
|
onChange={(e) => setIntervalMinutes(e.target.value)}
|
|
placeholder="60"
|
|
className="bg-[#162638] border-white/20 text-white"
|
|
data-testid="input-interval"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label className="text-sm text-white/70 mb-2 block">O que fazer?</label>
|
|
<Select value={actionType} onValueChange={setActionType}>
|
|
<SelectTrigger className="bg-[#162638] border-white/20 text-white" data-testid="select-action-type">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="bg-[#162638] border-white/20">
|
|
{ACTION_TYPES.map((action) => (
|
|
<SelectItem key={action.value} value={action.value} className="text-white">
|
|
<div className="flex items-center gap-2">
|
|
<action.icon className="w-4 h-4" />
|
|
{action.label}
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{actionType === "agent_task" && (
|
|
<div>
|
|
<label className="text-sm text-white/70 mb-1 block">Instrução para o Agente</label>
|
|
<textarea
|
|
value={actionPrompt}
|
|
onChange={(e) => setActionPrompt(e.target.value)}
|
|
placeholder="Ex: Gere um relatório de vendas do dia anterior..."
|
|
className="w-full h-24 bg-[#162638] border border-white/20 rounded-lg p-2.5 text-white text-sm placeholder:text-white/40 focus:outline-none focus:border-[#c89b3c] resize-none"
|
|
data-testid="input-agent-prompt"
|
|
/>
|
|
</div>
|
|
)}
|
|
{actionType === "database_backup" && (
|
|
<div>
|
|
<label className="text-sm text-white/70 mb-1 block">Fonte de Dados</label>
|
|
<Select value={selectedDataSource} onValueChange={setSelectedDataSource}>
|
|
<SelectTrigger className="bg-[#162638] border-white/20 text-white" data-testid="select-datasource">
|
|
<SelectValue placeholder="Selecione uma fonte de dados" />
|
|
</SelectTrigger>
|
|
<SelectContent className="bg-[#162638] border-white/20">
|
|
{dataSources.map((ds: any) => (
|
|
<SelectItem key={ds.id} value={ds.id.toString()} className="text-white">
|
|
<div className="flex items-center gap-2">
|
|
<Database className="w-4 h-4" />
|
|
{ds.name} ({ds.type})
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{dataSources.length === 0 && (
|
|
<p className="text-xs text-yellow-400 mt-2">
|
|
Nenhuma fonte de dados configurada. Acesse Insights para adicionar.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
<Button
|
|
onClick={handleCreate}
|
|
disabled={!name.trim() || createMutation.isPending}
|
|
className="w-full bg-[#c89b3c] hover:bg-[#d4a94a] text-[#1f334d]"
|
|
data-testid="button-create-automation"
|
|
>
|
|
{createMutation.isPending ? (
|
|
<><Loader2 className="w-4 h-4 mr-2 animate-spin" />Criando...</>
|
|
) : (
|
|
<><Zap className="w-4 h-4 mr-2" />Criar Automação</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function AutomationCard({ automation, onRefresh }: { automation: Automation; onRefresh: () => void }) {
|
|
const [showLogs, setShowLogs] = useState(false);
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data: logs = [] } = useQuery({
|
|
queryKey: ["automation-logs", automation.id],
|
|
queryFn: () => fetchAutomationLogs(automation.id),
|
|
enabled: showLogs,
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: () => deleteAutomation(automation.id),
|
|
onSuccess: onRefresh,
|
|
});
|
|
|
|
const toggleMutation = useMutation({
|
|
mutationFn: () => toggleAutomation(automation.id, automation.isActive !== "true"),
|
|
onSuccess: onRefresh,
|
|
});
|
|
|
|
const runMutation = useMutation({
|
|
mutationFn: () => runAutomation(automation.id),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["automation-logs", automation.id] });
|
|
},
|
|
});
|
|
|
|
const trigger = TRIGGER_TYPES.find((t) => t.value === automation.triggerType);
|
|
const TriggerIcon = trigger?.icon || Zap;
|
|
const isActive = automation.isActive === "true";
|
|
|
|
return (
|
|
<Card className="bg-[#1f334d] border-[#c89b3c]/20 hover:border-[#c89b3c]/40 transition-all" data-testid={`automation-card-${automation.id}`}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-start gap-3">
|
|
<div className={`p-2 rounded-lg ${isActive ? "bg-[#c89b3c]/20" : "bg-white/5"}`}>
|
|
<TriggerIcon className={`w-5 h-5 ${isActive ? "text-[#c89b3c]" : "text-white/30"}`} />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-medium text-white">{automation.name}</h3>
|
|
{automation.description && (
|
|
<p className="text-sm text-white/50 mt-0.5">{automation.description}</p>
|
|
)}
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<Badge variant={isActive ? "default" : "secondary"} className={isActive ? "bg-green-500/20 text-green-400" : ""}>
|
|
{isActive ? "Ativa" : "Inativa"}
|
|
</Badge>
|
|
<span className="text-xs text-white/40">{trigger?.label}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => runMutation.mutate()}
|
|
disabled={runMutation.isPending}
|
|
className="h-8 w-8 text-white/50 hover:text-[#c89b3c] hover:bg-[#c89b3c]/10"
|
|
title="Executar agora"
|
|
data-testid={`run-automation-${automation.id}`}
|
|
>
|
|
{runMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => toggleMutation.mutate()}
|
|
className="h-8 w-8 text-white/50 hover:text-yellow-400 hover:bg-yellow-400/10"
|
|
title={isActive ? "Pausar" : "Ativar"}
|
|
data-testid={`toggle-automation-${automation.id}`}
|
|
>
|
|
{isActive ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setShowLogs(!showLogs)}
|
|
className="h-8 w-8 text-white/50 hover:text-white hover:bg-white/10"
|
|
title="Ver histórico"
|
|
data-testid={`logs-automation-${automation.id}`}
|
|
>
|
|
<ChevronRight className={`w-4 h-4 transition-transform ${showLogs ? "rotate-90" : ""}`} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => deleteMutation.mutate()}
|
|
className="h-8 w-8 text-white/50 hover:text-red-400 hover:bg-red-400/10"
|
|
title="Excluir"
|
|
data-testid={`delete-automation-${automation.id}`}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{showLogs && (
|
|
<div className="mt-4 pt-4 border-t border-white/10">
|
|
<p className="text-xs text-white/40 mb-2 uppercase tracking-wider">Histórico de Execuções</p>
|
|
{logs.length === 0 ? (
|
|
<p className="text-sm text-white/30">Nenhuma execução ainda</p>
|
|
) : (
|
|
<div className="space-y-2 max-h-40 overflow-y-auto">
|
|
{logs.slice(0, 5).map((log) => (
|
|
<div key={log.id} className="flex items-center gap-2 text-sm">
|
|
{log.status === "completed" ? (
|
|
<CheckCircle className="w-4 h-4 text-green-400 shrink-0" />
|
|
) : log.status === "error" ? (
|
|
<XCircle className="w-4 h-4 text-red-400 shrink-0" />
|
|
) : (
|
|
<Loader2 className="w-4 h-4 text-yellow-400 animate-spin shrink-0" />
|
|
)}
|
|
<span className="text-white/70 truncate flex-1">
|
|
{log.result || log.error || "Em execução..."}
|
|
</span>
|
|
<span className="text-xs text-white/30">
|
|
{new Date(log.startedAt).toLocaleString("pt-BR", {
|
|
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit"
|
|
})}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export default function Automations() {
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data: automations = [], isLoading } = useQuery({
|
|
queryKey: ["automations"],
|
|
queryFn: fetchAutomations,
|
|
});
|
|
|
|
const handleRefresh = () => {
|
|
queryClient.invalidateQueries({ queryKey: ["automations"] });
|
|
};
|
|
|
|
return (
|
|
<BrowserFrame>
|
|
<div className="min-h-full bg-gradient-to-br from-[#f5f7fa] to-[#e8ecf1] p-6">
|
|
<div className="max-w-5xl mx-auto">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-light text-[#1f334d]" style={{ fontFamily: 'Georgia, serif' }}>
|
|
<span className="italic text-[#c89b3c]">Automações</span> Arcádia
|
|
</h1>
|
|
<p className="text-[#5a6c7d] text-sm mt-1">
|
|
Configure tarefas automáticas com o poder do Agente IA
|
|
</p>
|
|
</div>
|
|
<CreateAutomationDialog onCreated={handleRefresh} />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
|
<Card className="bg-[#1f334d] border-none">
|
|
<CardContent className="p-4 flex items-center gap-3">
|
|
<div className="p-3 rounded-full bg-[#c89b3c]/20">
|
|
<Zap className="w-5 h-5 text-[#c89b3c]" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold text-white">{automations.length}</p>
|
|
<p className="text-xs text-white/50">Total de Automações</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="bg-[#1f334d] border-none">
|
|
<CardContent className="p-4 flex items-center gap-3">
|
|
<div className="p-3 rounded-full bg-green-500/20">
|
|
<CheckCircle className="w-5 h-5 text-green-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold text-white">
|
|
{automations.filter((a) => a.isActive === "true").length}
|
|
</p>
|
|
<p className="text-xs text-white/50">Ativas</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="bg-[#1f334d] border-none">
|
|
<CardContent className="p-4 flex items-center gap-3">
|
|
<div className="p-3 rounded-full bg-blue-500/20">
|
|
<Clock className="w-5 h-5 text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold text-white">
|
|
{automations.filter((a) => a.triggerType === "schedule").length}
|
|
</p>
|
|
<p className="text-xs text-white/50">Agendadas</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-8 h-8 animate-spin text-[#c89b3c]" />
|
|
</div>
|
|
) : automations.length === 0 ? (
|
|
<Card className="bg-[#1f334d] border-[#c89b3c]/20">
|
|
<CardContent className="p-12 text-center">
|
|
<div className="w-16 h-16 rounded-full bg-[#c89b3c]/20 flex items-center justify-center mx-auto mb-4">
|
|
<Zap className="w-8 h-8 text-[#c89b3c]" />
|
|
</div>
|
|
<h3 className="text-lg font-medium text-white mb-2">Nenhuma automação criada</h3>
|
|
<p className="text-white/50 text-sm mb-4">
|
|
Crie sua primeira automação para executar tarefas automaticamente
|
|
</p>
|
|
<CreateAutomationDialog onCreated={handleRefresh} />
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{automations.map((automation) => (
|
|
<AutomationCard key={automation.id} automation={automation} onRefresh={handleRefresh} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</BrowserFrame>
|
|
);
|
|
}
|