feat: automações de tickets e testes de regressão

This commit is contained in:
esdrasrenan 2025-12-13 10:30:29 -03:00
parent 9f1a6a7401
commit 8ab510bfe9
18 changed files with 2221 additions and 20 deletions

View file

@ -0,0 +1,85 @@
import { describe, expect, it, beforeEach, vi } from "vitest"
import { api } from "@/convex/_generated/api"
const mutationMock = vi.fn()
vi.mock("@/server/convex-client", () => ({
createConvexClient: () => ({
mutation: mutationMock,
query: vi.fn(),
}),
ConvexConfigurationError: class extends Error {},
}))
describe("POST /api/machines/chat/messages", () => {
beforeEach(() => {
mutationMock.mockReset()
})
it("aceita mensagem somente com anexo (body vazio) e encaminha ao Convex", async () => {
mutationMock.mockResolvedValue({ ok: true })
const payload = {
action: "send",
machineToken: "token-attach-only",
ticketId: "ticket_1",
attachments: [{ storageId: "storage_1", name: "arquivo.pdf", size: 123, type: "application/pdf" }],
}
const { POST } = await import("./route")
const response = await POST(
new Request("http://localhost/api/machines/chat/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ ok: true })
expect(mutationMock).toHaveBeenCalledWith(api.liveChat.postMachineMessage, {
machineToken: payload.machineToken,
ticketId: payload.ticketId,
body: "",
attachments: payload.attachments,
})
})
it("rejeita mensagem vazia (sem body e sem anexos)", async () => {
const payload = { action: "send", machineToken: "token-empty", ticketId: "ticket_1" }
const { POST } = await import("./route")
const response = await POST(
new Request("http://localhost/api/machines/chat/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
)
expect(response.status).toBe(400)
const body = await response.json()
expect(body).toHaveProperty("error", "Payload invalido")
expect(mutationMock).not.toHaveBeenCalled()
})
it("rejeita body somente com espaços quando não há anexos", async () => {
const payload = { action: "send", machineToken: "token-spaces", ticketId: "ticket_1", body: " " }
const { POST } = await import("./route")
const response = await POST(
new Request("http://localhost/api/machines/chat/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
)
expect(response.status).toBe(400)
const body = await response.json()
expect(body).toHaveProperty("error", "Payload invalido")
expect(mutationMock).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,26 @@
import { ReactNode } from "react"
import { redirect } from "next/navigation"
import { requireAuthenticatedSession } from "@/lib/auth-server"
import { isAgentOrAdmin, isPortalUser, isStaff } from "@/lib/authz"
export const dynamic = "force-dynamic"
export const runtime = "nodejs"
export default async function AutomationsLayout({ children }: { children: ReactNode }) {
const session = await requireAuthenticatedSession()
const role = session.user.role ?? "agent"
if (!isAgentOrAdmin(role)) {
if (isPortalUser(role)) {
redirect("/portal")
}
if (isStaff(role)) {
redirect("/dashboard")
}
redirect("/login")
}
return <>{children}</>
}

View file

@ -0,0 +1,21 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { AutomationsManager } from "@/components/automations/automations-manager"
export default function AutomationsPage() {
return (
<AppShell
header={
<SiteHeader
title="Automações"
lead="Defina regras para executar ações automaticamente nos tickets."
/>
}
>
<div className="mx-auto w-full max-w-6xl space-y-6 px-4 pb-12 lg:px-6">
<AutomationsManager />
</div>
</AppShell>
)
}

View file

@ -52,7 +52,7 @@ import { cn } from "@/lib/utils"
import type { LucideIcon } from "lucide-react"
type NavRoleRequirement = "staff" | "admin"
type NavRoleRequirement = "staff" | "admin" | "agent"
type NavigationItem = {
title: string
@ -85,6 +85,7 @@ const navigation: NavigationGroup[] = [
{ title: "Resolvidos", url: "/tickets/resolved", icon: ShieldCheck, requiredRole: "staff" },
],
},
{ title: "Automações", url: "/automations", icon: Waypoints, requiredRole: "agent" },
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
{ title: "Agenda", url: "/agenda", icon: CalendarDays, requiredRole: "staff" },
{ title: "Dispositivos", url: "/admin/devices", icon: MonitorCog, requiredRole: "admin" },
@ -150,16 +151,17 @@ const navigation: NavigationGroup[] = [
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname()
const { session, isLoading, isAdmin, isStaff } = useAuth()
const { session, isLoading, isAdmin, isStaff, role } = useAuth()
const [isHydrated, setIsHydrated] = React.useState(false)
const canAccess = React.useCallback(
(requiredRole?: NavRoleRequirement) => {
if (!requiredRole) return true
if (requiredRole === "admin") return isAdmin
if (requiredRole === "agent") return isAdmin || role === "agent"
if (requiredRole === "staff") return isStaff
return false
},
[isAdmin, isStaff]
[isAdmin, isStaff, role]
)
const initialExpanded = React.useMemo(() => {
const open = new Set<string>()
@ -377,4 +379,3 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</Sidebar>
)
}

View file

@ -0,0 +1,847 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { DialogClose, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
type AutomationRow = {
id: Id<"ticketAutomations">
name: string
enabled: boolean
trigger: string
timing: string
delayMs: number | null
conditions: unknown | null
actions: unknown[]
}
type ConditionField =
| "companyId"
| "queueId"
| "categoryId"
| "subcategoryId"
| "priority"
| "status"
| "channel"
| "formTemplate"
| "chatEnabled"
type ConditionOp = "eq" | "neq" | "is_true" | "is_false"
type ConditionDraft = {
id: string
field: ConditionField
op: ConditionOp
value: string
}
type ActionType =
| "SET_PRIORITY"
| "MOVE_QUEUE"
| "ASSIGN_TO"
| "SET_FORM_TEMPLATE"
| "SET_CHAT_ENABLED"
| "ADD_INTERNAL_COMMENT"
type ActionDraft =
| { id: string; type: "SET_PRIORITY"; priority: string }
| { id: string; type: "MOVE_QUEUE"; queueId: string }
| { id: string; type: "ASSIGN_TO"; assigneeId: string }
| { id: string; type: "SET_FORM_TEMPLATE"; formTemplate: string | null }
| { id: string; type: "SET_CHAT_ENABLED"; enabled: boolean }
| { id: string; type: "ADD_INTERNAL_COMMENT"; body: string }
const PRIORITIES = [
{ value: "LOW", label: "Baixa" },
{ value: "MEDIUM", label: "Média" },
{ value: "HIGH", label: "Alta" },
{ value: "URGENT", label: "Urgente" },
]
const STATUSES = [
{ value: "PENDING", label: "Pendente" },
{ value: "AWAITING_ATTENDANCE", label: "Em andamento" },
{ value: "PAUSED", label: "Pausado" },
{ value: "RESOLVED", label: "Resolvido" },
]
const CHANNELS = [
{ value: "HELPDESK", label: "Helpdesk" },
{ value: "EMAIL", label: "E-mail" },
{ value: "PHONE", label: "Telefone" },
{ value: "WHATSAPP", label: "WhatsApp" },
]
const TRIGGERS = [
{ value: "TICKET_CREATED", label: "Abertura" },
{ value: "STATUS_CHANGED", label: "Alteração de status" },
{ value: "COMMENT_ADDED", label: "Inclusão de comentário" },
{ value: "TICKET_RESOLVED", label: "Finalização" },
]
function msToMinutes(ms: number | null) {
if (!ms || ms <= 0) return 0
return Math.max(1, Math.round(ms / 60000))
}
function minutesToMs(minutes: number) {
return Math.max(0, Math.round(minutes) * 60000)
}
function safeString(value: unknown) {
return typeof value === "string" ? value : ""
}
function toDraftConditions(raw: unknown | null): ConditionDraft[] {
const group = raw as { conditions?: unknown } | null
const list = Array.isArray(group?.conditions) ? group?.conditions : []
return list.map((c) => {
const condition = c as { field?: unknown; op?: unknown; value?: unknown }
return {
id: crypto.randomUUID(),
field: (safeString(condition.field) as ConditionField) || "companyId",
op: (safeString(condition.op) as ConditionOp) || "eq",
value: safeString(condition.value),
}
})
}
function toDraftActions(raw: unknown[]): ActionDraft[] {
return raw.map((a) => {
const base = a as Record<string, unknown>
const type = safeString(base.type) as ActionType
const id = crypto.randomUUID()
if (type === "MOVE_QUEUE") return { id, type, queueId: safeString(base.queueId) }
if (type === "ASSIGN_TO") return { id, type, assigneeId: safeString(base.assigneeId) }
if (type === "SET_FORM_TEMPLATE") return { id, type, formTemplate: safeString(base.formTemplate) || null }
if (type === "SET_CHAT_ENABLED") return { id, type, enabled: Boolean(base.enabled) }
if (type === "ADD_INTERNAL_COMMENT") return { id, type, body: safeString(base.body) }
return { id, type: "SET_PRIORITY", priority: safeString(base.priority) || "MEDIUM" }
})
}
export function AutomationEditorDialog({
automation,
onClose,
}: {
automation: AutomationRow | null
onClose: () => void
}) {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const createAutomation = useMutation(api.automations.create)
const updateAutomation = useMutation(api.automations.update)
const companies = useQuery(
api.companies.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: Id<"companies">; name: string }> | undefined
const queues = useQuery(
api.queues.listForStaff,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: Id<"queues">; name: string; slug: string }> | undefined
const categories = useQuery(
api.categories.list,
tenantId ? { tenantId } : "skip"
) as
| Array<{
id: string
name: string
secondary: Array<{ id: string; name: string; categoryId: string }>
}>
| undefined
const agents = useQuery(
api.users.listAgents,
tenantId ? { tenantId } : "skip"
) as Array<{ _id: Id<"users">; name: string; email: string }> | undefined
const templates = useQuery(
api.ticketFormTemplates.listActive,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: string; key: string; label: string }> | undefined
const initialState = useMemo(() => {
const rawOp = (automation?.conditions as { op?: unknown } | null)?.op
const conditionsOp = rawOp === "OR" ? ("OR" as const) : ("AND" as const)
return {
name: automation?.name ?? "",
enabled: automation?.enabled ?? true,
trigger: automation?.trigger ?? "TICKET_CREATED",
timing: automation?.timing ?? "IMMEDIATE",
delayMinutes: msToMinutes(automation?.delayMs ?? null),
conditionsOp,
conditions: automation ? toDraftConditions(automation.conditions) : ([] as ConditionDraft[]),
actions: automation
? toDraftActions(automation.actions)
: ([{ id: crypto.randomUUID(), type: "SET_PRIORITY", priority: "MEDIUM" }] as ActionDraft[]),
}
}, [automation])
const [name, setName] = useState(initialState.name)
const [enabled, setEnabled] = useState(initialState.enabled)
const [trigger, setTrigger] = useState(initialState.trigger)
const [timing, setTiming] = useState(initialState.timing)
const [delayMinutes, setDelayMinutes] = useState(initialState.delayMinutes)
const [conditionsOp, setConditionsOp] = useState<"AND" | "OR">(initialState.conditionsOp)
const [conditions, setConditions] = useState<ConditionDraft[]>(initialState.conditions)
const [actions, setActions] = useState<ActionDraft[]>(initialState.actions)
const [saving, setSaving] = useState(false)
useEffect(() => {
setName(initialState.name)
setEnabled(initialState.enabled)
setTrigger(initialState.trigger)
setTiming(initialState.timing)
setDelayMinutes(initialState.delayMinutes)
setConditionsOp(initialState.conditionsOp)
setConditions(initialState.conditions)
setActions(initialState.actions)
setSaving(false)
}, [initialState])
const subcategoryOptions = useMemo(() => {
const list =
categories?.flatMap((cat) =>
cat.secondary.map((sub) => ({ id: sub.id, name: sub.name, categoryId: sub.categoryId }))
) ?? []
return list.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
}, [categories])
const handleAddCondition = () => {
setConditions((prev) => [
...prev,
{ id: crypto.randomUUID(), field: "companyId", op: "eq", value: "" },
])
}
const handleRemoveCondition = (id: string) => {
setConditions((prev) => prev.filter((c) => c.id !== id))
}
const handleAddAction = () => {
setActions((prev) => [
...prev,
{ id: crypto.randomUUID(), type: "SET_PRIORITY", priority: "MEDIUM" },
])
}
const handleRemoveAction = (id: string) => {
setActions((prev) => prev.filter((a) => a.id !== id))
}
const buildPayload = () => {
const trimmedName = name.trim()
if (!trimmedName) throw new Error("Informe um nome para a automação.")
if (actions.length === 0) throw new Error("Adicione pelo menos uma ação.")
const conditionsPayload =
conditions.length > 0
? {
op: conditionsOp,
conditions: conditions.map((c) => ({
field: c.field,
op: c.op,
value: c.op === "is_true" || c.op === "is_false" ? undefined : c.value,
})),
}
: undefined
const actionsPayload = actions.map((a) => {
if (a.type === "SET_PRIORITY") return { type: a.type, priority: a.priority }
if (a.type === "MOVE_QUEUE") return { type: a.type, queueId: a.queueId }
if (a.type === "ASSIGN_TO") return { type: a.type, assigneeId: a.assigneeId }
if (a.type === "SET_FORM_TEMPLATE") return { type: a.type, formTemplate: a.formTemplate }
if (a.type === "SET_CHAT_ENABLED") return { type: a.type, enabled: a.enabled }
return { type: a.type, body: a.body }
})
const delayMs = timing === "DELAYED" ? minutesToMs(delayMinutes) : undefined
return {
name: trimmedName,
enabled: Boolean(enabled),
trigger,
timing,
delayMs,
conditions: conditionsPayload,
actions: actionsPayload,
}
}
const handleSave = async () => {
if (!convexUserId) return
setSaving(true)
try {
const payload = buildPayload()
if (automation) {
await updateAutomation({
tenantId,
viewerId: convexUserId as Id<"users">,
automationId: automation.id,
...payload,
})
toast.success("Automação atualizada")
} else {
await createAutomation({
tenantId,
viewerId: convexUserId as Id<"users">,
...payload,
})
toast.success("Automação criada")
}
onClose()
} catch (error) {
toast.error(error instanceof Error ? error.message : "Falha ao salvar automação")
} finally {
setSaving(false)
}
}
const canSave = Boolean(convexUserId) && name.trim().length > 0 && actions.length > 0 && !saving
return (
<DialogContent className="max-w-4xl">
<DialogHeader className="gap-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<DialogTitle>{automation ? "Editar automação" : "Nova automação"}</DialogTitle>
<div className="flex items-center gap-3">
<span className="text-sm text-neutral-600">Ativa</span>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="rounded-full">
{TRIGGERS.find((t) => t.value === trigger)?.label ?? "Quando"}
</Badge>
<Badge variant="outline" className="rounded-full">
{timing === "DELAYED" ? "Agendada" : "Imediata"}
</Badge>
</div>
</DialogHeader>
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Nome da automação</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ex.: Definir fila e responsável ao abrir ticket"
/>
</div>
<div className="space-y-2">
<Label>Quando</Label>
<Select value={trigger} onValueChange={setTrigger}>
<SelectTrigger>
<SelectValue placeholder="Selecione o gatilho" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{TRIGGERS.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label>Execução</Label>
<Select value={timing} onValueChange={setTiming}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="IMMEDIATE">Imediato</SelectItem>
<SelectItem value="DELAYED">Agendado (delay)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Delay (minutos)</Label>
<Input
type="number"
min={0}
step={1}
value={delayMinutes}
onChange={(e) => setDelayMinutes(Number(e.target.value))}
disabled={timing !== "DELAYED"}
/>
<p className="text-xs text-muted-foreground">
Quando agendada, a automação executa após o tempo informado.
</p>
</div>
</div>
<Separator />
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="space-y-0.5">
<p className="text-sm font-semibold text-neutral-900">Condições</p>
<p className="text-xs text-muted-foreground">Defina filtros (opcional) para decidir quando executar.</p>
</div>
<div className="flex items-center gap-2">
<Select value={conditionsOp} onValueChange={(v) => setConditionsOp(v as "AND" | "OR")}>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="AND">E</SelectItem>
<SelectItem value="OR">OU</SelectItem>
</SelectContent>
</Select>
<Button type="button" variant="outline" onClick={handleAddCondition} className="gap-2">
<Plus className="size-4" />
Nova condição
</Button>
</div>
</div>
{conditions.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-neutral-500">
Sem condições executa sempre que o gatilho ocorrer.
</p>
) : (
<div className="space-y-2">
{conditions.map((c) => (
<div
key={c.id}
className="grid gap-2 rounded-xl border border-slate-200 bg-slate-50 p-3 md:grid-cols-[1.1fr_0.9fr_1.4fr_auto]"
>
<div className="space-y-1">
<Label className="text-xs">Campo</Label>
<Select
value={c.field}
onValueChange={(value) => {
setConditions((prev) =>
prev.map((item) =>
item.id === c.id
? {
...item,
field: value as ConditionField,
op: value === "chatEnabled" ? "is_true" : "eq",
value: "",
}
: item
)
)
}}
>
<SelectTrigger className="bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="companyId">Empresa</SelectItem>
<SelectItem value="queueId">Fila</SelectItem>
<SelectItem value="categoryId">Categoria</SelectItem>
<SelectItem value="subcategoryId">Subcategoria</SelectItem>
<SelectItem value="priority">Prioridade</SelectItem>
<SelectItem value="status">Status</SelectItem>
<SelectItem value="channel">Canal</SelectItem>
<SelectItem value="formTemplate">Formulário</SelectItem>
<SelectItem value="chatEnabled">Chat habilitado</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Operador</Label>
<Select
value={c.op}
onValueChange={(value) =>
setConditions((prev) =>
prev.map((item) => (item.id === c.id ? { ...item, op: value as ConditionOp } : item))
)
}
>
<SelectTrigger className="bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-xl">
{c.field === "chatEnabled" ? (
<>
<SelectItem value="is_true">é verdadeiro</SelectItem>
<SelectItem value="is_false">é falso</SelectItem>
</>
) : (
<>
<SelectItem value="eq">igual</SelectItem>
<SelectItem value="neq">diferente</SelectItem>
</>
)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Valor</Label>
{c.field === "chatEnabled" ? (
<Input value={c.op === "is_true" ? "Sim" : "Não"} disabled className="bg-white" />
) : c.field === "priority" ? (
<Select
value={c.value}
onValueChange={(value) =>
setConditions((prev) => prev.map((item) => (item.id === c.id ? { ...item, value } : item)))
}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{PRIORITIES.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : c.field === "status" ? (
<Select
value={c.value}
onValueChange={(value) =>
setConditions((prev) => prev.map((item) => (item.id === c.id ? { ...item, value } : item)))
}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{STATUSES.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : c.field === "channel" ? (
<Select
value={c.value}
onValueChange={(value) =>
setConditions((prev) => prev.map((item) => (item.id === c.id ? { ...item, value } : item)))
}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{CHANNELS.map((ch) => (
<SelectItem key={ch.value} value={ch.value}>
{ch.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : c.field === "companyId" ? (
<Select
value={c.value}
onValueChange={(value) =>
setConditions((prev) => prev.map((item) => (item.id === c.id ? { ...item, value } : item)))
}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{(companies ?? []).map((company) => (
<SelectItem key={company.id} value={String(company.id)}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : c.field === "queueId" ? (
<Select
value={c.value}
onValueChange={(value) =>
setConditions((prev) => prev.map((item) => (item.id === c.id ? { ...item, value } : item)))
}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{(queues ?? []).map((queue) => (
<SelectItem key={queue.id} value={String(queue.id)}>
{queue.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : c.field === "categoryId" ? (
<Select
value={c.value}
onValueChange={(value) =>
setConditions((prev) => prev.map((item) => (item.id === c.id ? { ...item, value } : item)))
}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{(categories ?? []).map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : c.field === "subcategoryId" ? (
<Select
value={c.value}
onValueChange={(value) =>
setConditions((prev) => prev.map((item) => (item.id === c.id ? { ...item, value } : item)))
}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{subcategoryOptions.map((sub) => (
<SelectItem key={sub.id} value={sub.id}>
{sub.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Select
value={c.value}
onValueChange={(value) =>
setConditions((prev) => prev.map((item) => (item.id === c.id ? { ...item, value } : item)))
}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="">Nenhum</SelectItem>
{(templates ?? []).map((tpl) => (
<SelectItem key={tpl.key} value={tpl.key}>
{tpl.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveCondition(c.id)}
className="mt-6 h-8 w-8 text-slate-500 hover:bg-white"
title="Remover"
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
)}
</div>
<Separator />
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="space-y-0.5">
<p className="text-sm font-semibold text-neutral-900">Ações</p>
<p className="text-xs text-muted-foreground">O que deve acontecer quando a automação disparar.</p>
</div>
<Button type="button" variant="outline" onClick={handleAddAction} className="gap-2">
<Plus className="size-4" />
Nova ação
</Button>
</div>
<div className="space-y-2">
{actions.map((a) => (
<div key={a.id} className="rounded-xl border border-slate-200 bg-white p-3">
<div className="grid gap-3 md:grid-cols-[1.1fr_1.7fr_auto]">
<div className="space-y-1">
<Label className="text-xs">Tipo</Label>
<Select
value={a.type}
onValueChange={(value) => {
setActions((prev) =>
prev.map((item) => {
if (item.id !== a.id) return item
const next = value as ActionType
if (next === "MOVE_QUEUE") return { id: item.id, type: next, queueId: "" }
if (next === "ASSIGN_TO") return { id: item.id, type: next, assigneeId: "" }
if (next === "SET_FORM_TEMPLATE") return { id: item.id, type: next, formTemplate: null }
if (next === "SET_CHAT_ENABLED") return { id: item.id, type: next, enabled: true }
if (next === "ADD_INTERNAL_COMMENT") return { id: item.id, type: next, body: "" }
return { id: item.id, type: "SET_PRIORITY", priority: "MEDIUM" }
})
)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="SET_PRIORITY">Alterar prioridade</SelectItem>
<SelectItem value="MOVE_QUEUE">Mover para fila</SelectItem>
<SelectItem value="ASSIGN_TO">Definir responsável</SelectItem>
<SelectItem value="SET_FORM_TEMPLATE">Aplicar formulário</SelectItem>
<SelectItem value="SET_CHAT_ENABLED">Habilitar/desabilitar chat</SelectItem>
<SelectItem value="ADD_INTERNAL_COMMENT">Adicionar comentário interno</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Configuração</Label>
{a.type === "SET_PRIORITY" ? (
<Select
value={a.priority}
onValueChange={(value) =>
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, priority: value } : item)))
}
>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{PRIORITIES.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : a.type === "MOVE_QUEUE" ? (
<Select
value={a.queueId}
onValueChange={(value) =>
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, queueId: value } : item)))
}
>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{(queues ?? []).map((queue) => (
<SelectItem key={queue.id} value={String(queue.id)}>
{queue.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : a.type === "ASSIGN_TO" ? (
<Select
value={a.assigneeId}
onValueChange={(value) =>
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, assigneeId: value } : item)))
}
>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{(agents ?? []).map((u) => (
<SelectItem key={u._id} value={String(u._id)}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : a.type === "SET_FORM_TEMPLATE" ? (
<Select
value={a.formTemplate ?? ""}
onValueChange={(value) =>
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, formTemplate: value || null } : item)))
}
>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="">Nenhum</SelectItem>
{(templates ?? []).map((tpl) => (
<SelectItem key={tpl.key} value={tpl.key}>
{tpl.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : a.type === "SET_CHAT_ENABLED" ? (
<div className="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2">
<span className="text-sm text-neutral-700">Chat habilitado</span>
<Switch
checked={a.enabled}
onCheckedChange={(checked) =>
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, enabled: checked } : item)))
}
/>
</div>
) : (
<Textarea
value={a.body}
onChange={(e) =>
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, body: e.target.value } : item)))
}
placeholder="Escreva o comentário interno..."
className="min-h-24"
/>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveAction(a.id)}
className="mt-6 h-8 w-8 text-slate-500 hover:bg-slate-50"
title="Remover"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
))}
</div>
</div>
<Separator />
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="outline" onClick={onClose}>
Cancelar
</Button>
</DialogClose>
<Button type="button" onClick={handleSave} disabled={!canSave}>
{saving ? "Salvando..." : "Salvar"}
</Button>
</div>
</div>
</DialogContent>
)
}

View file

@ -0,0 +1,265 @@
"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Dialog, DialogTrigger } from "@/components/ui/dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { AutomationEditorDialog } from "@/components/automations/automation-editor-dialog"
type AutomationRow = {
id: Id<"ticketAutomations">
name: string
enabled: boolean
trigger: string
timing: string
delayMs: number | null
conditions: unknown | null
actions: unknown[]
runCount: number
lastRunAt: number | null
updatedAt: number
}
const TRIGGER_LABELS: Record<string, string> = {
TICKET_CREATED: "Abertura",
STATUS_CHANGED: "Alteração de status",
COMMENT_ADDED: "Inclusão de comentário",
TICKET_RESOLVED: "Finalização",
}
function triggerLabel(trigger: string) {
return TRIGGER_LABELS[trigger] ?? trigger
}
function formatLastRun(timestamp: number | null) {
if (!timestamp) return "—"
return new Date(timestamp).toLocaleString("pt-BR")
}
export function AutomationsManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const [queryText, setQueryText] = useState("")
const [triggerFilter, setTriggerFilter] = useState<string>("all")
const [statusFilter, setStatusFilter] = useState<string>("all")
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<AutomationRow | null>(null)
const list = useQuery(
api.automations.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as AutomationRow[] | undefined
const toggleEnabled = useMutation(api.automations.toggleEnabled)
const removeAutomation = useMutation(api.automations.remove)
const filtered = useMemo(() => {
const base = list ?? []
const q = queryText.trim().toLowerCase()
return base
.filter((item) => {
if (triggerFilter !== "all" && item.trigger !== triggerFilter) return false
if (statusFilter === "active" && !item.enabled) return false
if (statusFilter === "inactive" && item.enabled) return false
if (q && !item.name.toLowerCase().includes(q)) return false
return true
})
.sort((a, b) => b.updatedAt - a.updatedAt)
}, [list, queryText, triggerFilter, statusFilter])
const handleNew = () => {
setEditing(null)
setEditorOpen(true)
}
const handleEdit = (row: AutomationRow) => {
setEditing(row)
setEditorOpen(true)
}
const handleToggle = async (row: AutomationRow, nextEnabled: boolean) => {
if (!convexUserId) return
try {
await toggleEnabled({
tenantId,
viewerId: convexUserId as Id<"users">,
automationId: row.id,
enabled: nextEnabled,
})
toast.success(nextEnabled ? "Automação ativada" : "Automação desativada")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Falha ao atualizar automação")
}
}
const handleDelete = async (row: AutomationRow) => {
if (!convexUserId) return
const ok = confirm(`Excluir a automação "${row.name}"?`)
if (!ok) return
try {
await removeAutomation({
tenantId,
viewerId: convexUserId as Id<"users">,
automationId: row.id,
})
toast.success("Automação excluída")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Falha ao excluir automação")
}
}
return (
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Automações</CardTitle>
<CardDescription className="text-neutral-600">
Crie gatilhos para executar ações automáticas (fila, prioridade, responsável, formulário, chat).
</CardDescription>
<CardAction>
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<Input
value={queryText}
onChange={(e) => setQueryText(e.target.value)}
placeholder="Buscar automação..."
className="w-full sm:w-64"
/>
<Select value={triggerFilter} onValueChange={setTriggerFilter}>
<SelectTrigger className="w-full sm:w-44">
<SelectValue placeholder="Quando" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="TICKET_CREATED">Abertura</SelectItem>
<SelectItem value="STATUS_CHANGED">Alteração</SelectItem>
<SelectItem value="COMMENT_ADDED">Comentário</SelectItem>
<SelectItem value="TICKET_RESOLVED">Finalização</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="active">Ativas</SelectItem>
<SelectItem value="inactive">Inativas</SelectItem>
</SelectContent>
</Select>
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
<DialogTrigger asChild>
<Button onClick={handleNew} className="gap-2">
<Plus className="size-4" />
Nova automação
</Button>
</DialogTrigger>
<AutomationEditorDialog
automation={editing}
onClose={() => setEditorOpen(false)}
/>
</Dialog>
</div>
</CardAction>
</CardHeader>
<CardContent>
{!list ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : filtered.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
Nenhuma automação cadastrada.
</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full border-separate border-spacing-y-2">
<thead>
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-neutral-500">
<th className="px-2 py-1">Nome</th>
<th className="px-2 py-1">Quando</th>
<th className="px-2 py-1">Ações</th>
<th className="px-2 py-1">Execuções</th>
<th className="px-2 py-1">Última</th>
<th className="px-2 py-1">Status</th>
<th className="px-2 py-1 text-right"> </th>
</tr>
</thead>
<tbody>
{filtered.map((row) => (
<tr key={row.id} className="rounded-xl border border-slate-200 bg-white">
<td className="px-2 py-2 text-sm font-medium text-neutral-900">{row.name}</td>
<td className="px-2 py-2 text-sm text-neutral-700">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="rounded-full">
{triggerLabel(row.trigger)}
</Badge>
{row.timing === "DELAYED" && row.delayMs ? (
<span className="text-xs text-neutral-500">
+{Math.round(row.delayMs / 60000)}m
</span>
) : null}
</div>
</td>
<td className="px-2 py-2 text-sm text-neutral-700">{row.actions?.length ?? 0}</td>
<td className="px-2 py-2 text-sm text-neutral-700">{row.runCount ?? 0}</td>
<td className="px-2 py-2 text-sm text-neutral-700">{formatLastRun(row.lastRunAt)}</td>
<td className="px-2 py-2">
<div className="flex items-center gap-2">
<Switch
checked={row.enabled}
onCheckedChange={(checked) => handleToggle(row, checked)}
/>
<span className="text-xs text-neutral-600">{row.enabled ? "Ativa" : "Inativa"}</span>
</div>
</td>
<td className="px-2 py-2 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="rounded-xl">
<DropdownMenuItem onClick={() => handleEdit(row)} className="gap-2">
<Pencil className="size-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(row)}
className="gap-2 text-red-600 focus:text-red-600"
>
<Trash2 className="size-4" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
)
}

View file

@ -2,6 +2,8 @@
import dynamic from "next/dynamic"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import { isAgentOrAdmin } from "@/lib/authz"
// Verifica se a API do liveChat existe
function checkLiveChatApiExists() {
@ -19,6 +21,11 @@ const ChatWidget = dynamic(
)
export function ChatWidgetProvider() {
const { role, isLoading } = useAuth()
if (isLoading) return null
if (!isAgentOrAdmin(role)) return null
// Nao renderiza se a API nao existir (Convex nao sincronizado)
if (!checkLiveChatApiExists()) {
return null

View file

@ -14,6 +14,11 @@ export function isAdmin(role?: string | null) {
return normalizeRole(role) === ADMIN_ROLE
}
export function isAgentOrAdmin(role?: string | null) {
const normalized = normalizeRole(role)
return normalized === "admin" || normalized === "agent"
}
export function isStaff(role?: string | null) {
return STAFF_ROLES.has(normalizeRole(role) ?? "")
}