- Corrige sincronização do avatar no perfil após upload - Reduz tamanho dos ícones de câmera/lixeira no avatar - Remove atributos title (tooltips nativos) de toda aplicação - Adiciona regra no AGENTS.md sobre uso de tooltips - Permite desmarcar resposta no checklist (toggle) - Torna campo answer opcional na mutation setChecklistItemAnswer - Adiciona edição inline dos campos de resumo no painel de detalhes - Redesenha comentários com layout mais limpo e consistente - Cria tratamento especial para comentários automáticos de sistema - Aplica fundo ciano semi-transparente em comentários públicos - Corrige import do Loader2 no notification-preferences-form 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1101 lines
46 KiB
TypeScript
1101 lines
46 KiB
TypeScript
"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 { Checkbox } from "@/components/ui/checkbox"
|
|
import { DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Select, SelectContent, SelectEmptyState, 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"
|
|
import { EmailActionConfig } from "@/components/automations/email-action-config"
|
|
|
|
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"
|
|
| "APPLY_CHECKLIST_TEMPLATE"
|
|
| "SEND_EMAIL"
|
|
|
|
type EmailCtaTarget = "AUTO" | "PORTAL" | "STAFF"
|
|
|
|
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 }
|
|
| { id: string; type: "APPLY_CHECKLIST_TEMPLATE"; templateId: string }
|
|
| {
|
|
id: string
|
|
type: "SEND_EMAIL"
|
|
subject: string
|
|
message: string
|
|
toRequester: boolean
|
|
toAssignee: boolean
|
|
toUserId: string
|
|
toEmails: string
|
|
ctaTarget: EmailCtaTarget
|
|
ctaLabel: 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: "PRIORITY_CHANGED", label: "Alteração de prioridade" },
|
|
{ value: "QUEUE_CHANGED", label: "Alteração de fila" },
|
|
{ value: "COMMENT_ADDED", label: "Inclusão de comentário" },
|
|
{ value: "TICKET_RESOLVED", label: "Finalização" },
|
|
]
|
|
|
|
const CLEAR_SELECT_VALUE = "__clear__"
|
|
|
|
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 parseEmailAddresses(raw: string) {
|
|
return raw
|
|
.split(/[\n,;]+/g)
|
|
.map((email) => email.trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
function isValidEmail(email: string) {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
|
}
|
|
|
|
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 === "SEND_EMAIL") {
|
|
const recipientsRaw = Array.isArray(base.recipients) ? (base.recipients as unknown[]) : []
|
|
const recipientTypes = recipientsRaw
|
|
.map((r) => (r && typeof r === "object" ? (r as { type?: unknown }).type : null))
|
|
.filter((t): t is string => typeof t === "string")
|
|
.map((t) => t.trim().toUpperCase())
|
|
const toRequester = recipientTypes.includes("REQUESTER")
|
|
const toAssignee = recipientTypes.includes("ASSIGNEE")
|
|
const userEntry = recipientsRaw.find((r) => {
|
|
const rec = r as { type?: unknown; userId?: unknown }
|
|
return typeof rec?.type === "string" && rec.type.trim().toUpperCase() === "USER" && typeof rec.userId === "string"
|
|
}) as { userId?: string } | undefined
|
|
const toUserId = userEntry?.userId ?? ""
|
|
const emails = recipientsRaw
|
|
.filter((r) => {
|
|
const rec = r as { type?: unknown; email?: unknown }
|
|
return typeof rec?.type === "string" && rec.type.trim().toUpperCase() === "EMAIL" && typeof rec.email === "string"
|
|
})
|
|
.map((r) => (r as { email?: string }).email ?? "")
|
|
.filter(Boolean)
|
|
const ctaTargetRaw = safeString(base.ctaTarget).toUpperCase()
|
|
const ctaTarget: EmailCtaTarget =
|
|
ctaTargetRaw === "PORTAL" || ctaTargetRaw === "STAFF" ? (ctaTargetRaw as EmailCtaTarget) : "AUTO"
|
|
|
|
return {
|
|
id,
|
|
type,
|
|
subject: safeString(base.subject),
|
|
message: safeString(base.message),
|
|
toRequester,
|
|
toAssignee,
|
|
toUserId,
|
|
toEmails: emails.join(", "),
|
|
ctaTarget,
|
|
ctaLabel: safeString(base.ctaLabel) || "Abrir chamado",
|
|
}
|
|
}
|
|
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) }
|
|
if (type === "APPLY_CHECKLIST_TEMPLATE") return { id, type, templateId: safeString(base.templateId) }
|
|
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 checklistTemplates = useQuery(
|
|
api.checklistTemplates.listActive,
|
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
|
) as Array<{ id: Id<"ticketChecklistTemplates">; name: string; company: { id: Id<"companies">; name: string } | null }> | 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 }
|
|
if (a.type === "APPLY_CHECKLIST_TEMPLATE") {
|
|
const templateId = a.templateId.trim()
|
|
if (!templateId) throw new Error("Selecione um template de checklist.")
|
|
return { type: a.type, templateId }
|
|
}
|
|
if (a.type === "SEND_EMAIL") {
|
|
const subject = a.subject.trim()
|
|
const message = a.message.trim()
|
|
if (!subject) throw new Error("Informe o assunto do e-mail.")
|
|
if (!message) throw new Error("Informe a mensagem do e-mail.")
|
|
|
|
const recipients: Array<Record<string, unknown>> = []
|
|
if (a.toRequester) recipients.push({ type: "REQUESTER" })
|
|
if (a.toAssignee) recipients.push({ type: "ASSIGNEE" })
|
|
if (a.toUserId.trim()) recipients.push({ type: "USER", userId: a.toUserId.trim() })
|
|
|
|
const emails = parseEmailAddresses(a.toEmails)
|
|
const invalid = emails.filter((email) => !isValidEmail(email))
|
|
if (invalid.length > 0) {
|
|
throw new Error(`E-mail(s) inválido(s): ${invalid.join(", ")}`)
|
|
}
|
|
emails.forEach((email) => recipients.push({ type: "EMAIL", email }))
|
|
|
|
if (recipients.length === 0) {
|
|
throw new Error("Selecione pelo menos um destinatário para o e-mail.")
|
|
}
|
|
|
|
const ctaLabel = a.ctaLabel.trim() || "Abrir chamado"
|
|
|
|
return {
|
|
type: "SEND_EMAIL",
|
|
subject,
|
|
message,
|
|
recipients,
|
|
ctaTarget: a.ctaTarget,
|
|
ctaLabel,
|
|
}
|
|
}
|
|
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-4 pb-2">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<DialogTitle>{automation ? "Editar automação" : "Nova automação"}</DialogTitle>
|
|
<DialogDescription className="sr-only">
|
|
{automation
|
|
? "Edite as condições e ações que devem disparar automaticamente nos tickets."
|
|
: "Crie uma automação definindo condições e ações automáticas para tickets."}
|
|
</DialogDescription>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm font-medium text-neutral-700">Ativa</span>
|
|
<Switch
|
|
checked={enabled}
|
|
onCheckedChange={setEnabled}
|
|
className="data-[state=checked]:bg-black data-[state=unchecked]:bg-slate-300"
|
|
/>
|
|
</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 ?? []).length === 0 ? (
|
|
<SelectEmptyState
|
|
message="Nenhuma empresa disponível"
|
|
createLabel="Gerenciar empresas"
|
|
createHref="/admin/companies"
|
|
/>
|
|
) : (
|
|
(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 ?? []).length === 0 ? (
|
|
<SelectEmptyState
|
|
message="Nenhuma fila disponível"
|
|
createLabel="Gerenciar filas"
|
|
createHref="/settings/queues"
|
|
/>
|
|
) : (
|
|
(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 ?? []).length === 0 ? (
|
|
<SelectEmptyState
|
|
message="Nenhuma categoria disponível"
|
|
createLabel="Gerenciar categorias"
|
|
createHref="/settings/categories"
|
|
/>
|
|
) : (
|
|
(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.length === 0 ? (
|
|
<SelectEmptyState
|
|
message="Nenhuma subcategoria disponível"
|
|
createLabel="Gerenciar categorias"
|
|
createHref="/settings/categories"
|
|
/>
|
|
) : (
|
|
subcategoryOptions.map((sub) => (
|
|
<SelectItem key={sub.id} value={sub.id}>
|
|
{sub.name}
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Select
|
|
value={c.value}
|
|
onValueChange={(value) => {
|
|
const nextValue = value === CLEAR_SELECT_VALUE ? "" : value
|
|
setConditions((prev) =>
|
|
prev.map((item) => (item.id === c.id ? { ...item, value: nextValue } : item))
|
|
)
|
|
}}
|
|
>
|
|
<SelectTrigger className="bg-white">
|
|
<SelectValue placeholder="Selecione" />
|
|
</SelectTrigger>
|
|
<SelectContent className="rounded-xl">
|
|
{(templates ?? []).length === 0 ? (
|
|
<SelectEmptyState
|
|
message="Nenhum formulário disponível"
|
|
createLabel="Gerenciar formulários"
|
|
createHref="/settings/forms"
|
|
/>
|
|
) : (
|
|
<>
|
|
<SelectItem value={CLEAR_SELECT_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-red-50 hover:text-red-700"
|
|
>
|
|
<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) =>
|
|
a.type === "SEND_EMAIL" ? (
|
|
<div key={a.id} className="rounded-xl border border-slate-200 bg-white p-4">
|
|
<EmailActionConfig
|
|
action={a}
|
|
onChange={(updated) =>
|
|
setActions((prev) => prev.map((item) => (item.id === a.id ? updated : item)))
|
|
}
|
|
onRemove={() => handleRemoveAction(a.id)}
|
|
agents={(agents ?? []).map((u) => ({ _id: String(u._id), name: u.name }))}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<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: "" }
|
|
if (next === "APPLY_CHECKLIST_TEMPLATE") return { id: item.id, type: next, templateId: "" }
|
|
if (next === "SEND_EMAIL") {
|
|
return {
|
|
id: item.id,
|
|
type: next,
|
|
subject: "",
|
|
message: "",
|
|
toRequester: true,
|
|
toAssignee: false,
|
|
toUserId: "",
|
|
toEmails: "",
|
|
ctaTarget: "AUTO",
|
|
ctaLabel: "Abrir chamado",
|
|
}
|
|
}
|
|
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>
|
|
<SelectItem value="APPLY_CHECKLIST_TEMPLATE">Aplicar checklist (template)</SelectItem>
|
|
<SelectItem value="SEND_EMAIL">Enviar e-mail</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 ?? []).length === 0 ? (
|
|
<SelectEmptyState
|
|
message="Nenhuma fila disponível"
|
|
createLabel="Gerenciar filas"
|
|
createHref="/settings/queues"
|
|
/>
|
|
) : (
|
|
(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 ?? []).length === 0 ? (
|
|
<SelectEmptyState
|
|
message="Nenhum agente disponível"
|
|
createLabel="Gerenciar usuários"
|
|
createHref="/admin/users"
|
|
/>
|
|
) : (
|
|
(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) => {
|
|
const nextValue = value === CLEAR_SELECT_VALUE ? null : value
|
|
setActions((prev) =>
|
|
prev.map((item) => (item.id === a.id ? { ...item, formTemplate: nextValue } : item))
|
|
)
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Selecione" />
|
|
</SelectTrigger>
|
|
<SelectContent className="rounded-xl">
|
|
{(templates ?? []).length === 0 ? (
|
|
<SelectEmptyState
|
|
message="Nenhum formulário disponível"
|
|
createLabel="Gerenciar formulários"
|
|
createHref="/settings/forms"
|
|
/>
|
|
) : (
|
|
<>
|
|
<SelectItem value={CLEAR_SELECT_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)))
|
|
}
|
|
className="data-[state=checked]:bg-black data-[state=unchecked]:bg-slate-300"
|
|
/>
|
|
</div>
|
|
) : a.type === "APPLY_CHECKLIST_TEMPLATE" ? (
|
|
<Select
|
|
value={a.templateId}
|
|
onValueChange={(value) =>
|
|
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, templateId: value } : item)))
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Selecione" />
|
|
</SelectTrigger>
|
|
<SelectContent className="rounded-xl">
|
|
{(checklistTemplates ?? []).length === 0 ? (
|
|
<SelectEmptyState
|
|
message="Nenhum template de checklist"
|
|
createLabel="Criar template"
|
|
createHref="/settings/checklists"
|
|
/>
|
|
) : (
|
|
(checklistTemplates ?? []).map((tpl) => (
|
|
<SelectItem key={tpl.id} value={String(tpl.id)}>
|
|
{tpl.name}
|
|
{tpl.company ? ` — ${tpl.company.name}` : ""}
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<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-red-50 hover:text-red-700"
|
|
>
|
|
<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>
|
|
)
|
|
}
|