wacrm/src/app/(dashboard)/automations/page.tsx
Arnas Donauskas b7b362ae42
feat(automations): visual flow builder, engine, and logs viewer
Replaces the /automations placeholder with a full feature:
- Migration 006 adds automations, automation_steps, automation_logs,
  and automation_pending_executions with RLS matching existing tables.
- List page with active/inactive switch, quick-start templates,
  duplicate, delete, and View Logs actions.
- Full-screen vertical flow builder with Trigger + Action + Condition
  (Yes/No branches) + Wait cards. Inline edit forms, up/down reorder,
  dotted-grid canvas.
- Execution engine (src/lib/automations/engine.ts) dispatched from the
  WhatsApp webhook for new_message_received, new_contact_created, and
  keyword_match. Wait steps enqueue to automation_pending_executions;
  /api/automations/cron drains them behind AUTOMATION_CRON_SECRET.
- Logs viewer with per-step checkmarks and expandable detail.

Existing files touched only in webhook/route.ts (fire-and-forget
trigger dispatch) and types/index.ts (appended automation types).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 19:39:02 +03:00

359 lines
12 KiB
TypeScript

"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import {
Zap,
Plus,
MoreVertical,
Copy,
Pencil,
Trash2,
FileText,
MessageCircle,
Clock,
Users,
PhoneCall,
Loader2,
} from "lucide-react"
import { createClient } from "@/lib/supabase/client"
import type { Automation } from "@/types"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { AUTOMATION_TEMPLATES, type TemplateSlug } from "@/lib/automations/templates"
import { triggerMeta, formatRelative } from "@/lib/automations/trigger-meta"
import { cn } from "@/lib/utils"
const TEMPLATE_ORDER: TemplateSlug[] = [
"welcome_message",
"out_of_office",
"lead_qualifier",
"follow_up_reminder",
]
const TEMPLATE_ICON: Record<TemplateSlug, typeof Zap> = {
welcome_message: MessageCircle,
out_of_office: Clock,
lead_qualifier: Users,
follow_up_reminder: PhoneCall,
}
export default function AutomationsPage() {
const router = useRouter()
const [automations, setAutomations] = useState<Automation[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [pendingDelete, setPendingDelete] = useState<Automation | null>(null)
const [deleting, setDeleting] = useState(false)
async function load() {
try {
const supabase = createClient()
const { data, error: fetchErr } = await supabase
.from("automations")
.select("*")
.order("created_at", { ascending: false })
if (fetchErr) throw fetchErr
setAutomations((data ?? []) as Automation[])
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load automations")
}
}
useEffect(() => {
load()
}, [])
async function toggleActive(a: Automation, next: boolean) {
// Optimistic flip so the switch feels instant.
setAutomations((prev) =>
prev?.map((x) => (x.id === a.id ? { ...x, is_active: next } : x)) ?? prev,
)
const res = await fetch(`/api/automations/${a.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ is_active: next }),
})
if (!res.ok) {
// Roll back on error.
setAutomations((prev) =>
prev?.map((x) => (x.id === a.id ? { ...x, is_active: !next } : x)) ?? prev,
)
const body = await res.json().catch(() => ({}))
toast.error(body?.error ?? "Failed to update")
return
}
toast.success(next ? "Automation activated" : "Automation paused")
}
async function duplicate(a: Automation) {
const res = await fetch(`/api/automations/${a.id}/duplicate`, { method: "POST" })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
toast.error(body?.error ?? "Failed to duplicate")
return
}
toast.success("Automation duplicated")
load()
}
async function confirmDelete() {
if (!pendingDelete) return
setDeleting(true)
const res = await fetch(`/api/automations/${pendingDelete.id}`, { method: "DELETE" })
setDeleting(false)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
toast.error(body?.error ?? "Failed to delete")
return
}
toast.success("Automation deleted")
setPendingDelete(null)
load()
}
async function startFromTemplate(slug: TemplateSlug) {
router.push(`/automations/new?template=${slug}`)
}
if (error) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">{error}</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
)
}
if (automations === null) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-emerald-500" />
</div>
)
}
const showTemplates = automations.length < 3
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Automations</h1>
<p className="mt-1 text-sm text-slate-400">
Build workflows that react to WhatsApp events automatically.
</p>
</div>
<Button
onClick={() => router.push("/automations/new")}
className="bg-emerald-600 text-white hover:bg-emerald-700"
>
<Plus className="h-4 w-4" />
Create Automation
</Button>
</div>
{showTemplates && (
<section>
<h2 className="mb-3 text-sm font-semibold text-slate-300">Quick-start templates</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
{TEMPLATE_ORDER.map((slug) => {
const t = AUTOMATION_TEMPLATES[slug]
const Icon = TEMPLATE_ICON[slug]
return (
<button
key={slug}
onClick={() => startFromTemplate(slug)}
className="group flex flex-col items-start rounded-xl border border-slate-800 bg-slate-900 p-4 text-left transition-colors hover:border-emerald-500/50 hover:bg-slate-900/80"
>
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-400 group-hover:bg-emerald-500/15">
<Icon className="h-5 w-5" />
</div>
<div className="text-sm font-semibold text-white">{t.name}</div>
<p className="mt-1 text-xs text-slate-400">{t.description}</p>
</button>
)
})}
</div>
</section>
)}
{automations.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-slate-800 bg-slate-900/40">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-500/10">
<Zap className="h-6 w-6 text-emerald-500" />
</div>
<p className="mt-3 text-sm font-medium text-white">No automations yet</p>
<p className="mt-1 text-xs text-slate-400">
Pick a template above or create one from scratch.
</p>
</div>
) : (
<ul className="space-y-3">
{automations.map((a) => (
<AutomationCard
key={a.id}
automation={a}
onToggle={(next) => toggleActive(a, next)}
onEdit={() => router.push(`/automations/${a.id}/edit`)}
onDuplicate={() => duplicate(a)}
onLogs={() => router.push(`/automations/${a.id}/logs`)}
onDelete={() => setPendingDelete(a)}
/>
))}
</ul>
)}
<Dialog open={!!pendingDelete} onOpenChange={(v) => !v && setPendingDelete(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete automation</DialogTitle>
<DialogDescription>
This permanently removes{" "}
<span className="text-white">{pendingDelete?.name}</span> and its execution
history. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setPendingDelete(null)}
disabled={deleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
disabled={deleting}
>
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function AutomationCard({
automation,
onToggle,
onEdit,
onDuplicate,
onLogs,
onDelete,
}: {
automation: Automation
onToggle: (next: boolean) => void
onEdit: () => void
onDuplicate: () => void
onLogs: () => void
onDelete: () => void
}) {
const meta = triggerMeta(automation.trigger_type)
return (
<li className="rounded-xl border border-slate-800 bg-slate-900 transition-colors hover:border-slate-700">
<div className="flex items-center gap-4 p-4">
<div
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-500/10"
aria-hidden
>
<Zap className="h-5 w-5 text-emerald-400" />
</div>
<button
type="button"
onClick={onEdit}
className="min-w-0 flex-1 text-left"
>
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-white">
{automation.name}
</span>
{automation.is_active && (
<span className="relative flex h-2 w-2" aria-label="active">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
</span>
)}
</div>
{automation.description && (
<p className="mt-0.5 truncate text-xs text-slate-400">{automation.description}</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium",
meta.pillClass,
)}
>
{meta.label}
</span>
<span className="tabular-nums">
{automation.execution_count} run{automation.execution_count === 1 ? "" : "s"}
</span>
<span aria-hidden>·</span>
<span>last {formatRelative(automation.last_executed_at)}</span>
</div>
</button>
<div className="flex items-center gap-3">
<Switch
checked={automation.is_active}
onCheckedChange={(v) => onToggle(!!v)}
aria-label={automation.is_active ? "Deactivate" : "Activate"}
/>
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open menu"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-800 hover:text-white data-[popup-open]:bg-slate-800"
>
<MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<Pencil className="h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={onDuplicate}>
<Copy className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={onLogs}>
<FileText className="h-4 w-4" />
View Logs
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</li>
)
}