mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
359 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|