feat: automações com envio de e-mail
This commit is contained in:
parent
469608a10b
commit
58a1ed6b36
6 changed files with 958 additions and 49 deletions
|
|
@ -11,6 +11,7 @@ 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, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
|
@ -57,6 +58,9 @@ type ActionType =
|
|||
| "SET_FORM_TEMPLATE"
|
||||
| "SET_CHAT_ENABLED"
|
||||
| "ADD_INTERNAL_COMMENT"
|
||||
| "SEND_EMAIL"
|
||||
|
||||
type EmailCtaTarget = "AUTO" | "PORTAL" | "STAFF"
|
||||
|
||||
type ActionDraft =
|
||||
| { id: string; type: "SET_PRIORITY"; priority: string }
|
||||
|
|
@ -65,6 +69,18 @@ type ActionDraft =
|
|||
| { 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: "SEND_EMAIL"
|
||||
subject: string
|
||||
message: string
|
||||
toRequester: boolean
|
||||
toAssignee: boolean
|
||||
toUserId: string
|
||||
toEmails: string
|
||||
ctaTarget: EmailCtaTarget
|
||||
ctaLabel: string
|
||||
}
|
||||
|
||||
const PRIORITIES = [
|
||||
{ value: "LOW", label: "Baixa" },
|
||||
|
|
@ -109,6 +125,17 @@ 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 : []
|
||||
|
|
@ -128,6 +155,43 @@ function toDraftActions(raw: unknown[]): ActionDraft[] {
|
|||
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 }
|
||||
|
|
@ -273,6 +337,39 @@ export function AutomationEditorDialog({
|
|||
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 === "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 }
|
||||
})
|
||||
|
||||
|
|
@ -703,6 +800,20 @@ export function AutomationEditorDialog({
|
|||
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 === "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" }
|
||||
})
|
||||
)
|
||||
|
|
@ -718,6 +829,7 @@ export function AutomationEditorDialog({
|
|||
<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="SEND_EMAIL">Enviar e-mail</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -808,6 +920,131 @@ export function AutomationEditorDialog({
|
|||
className="data-[state=checked]:bg-black data-[state=unchecked]:bg-slate-300"
|
||||
/>
|
||||
</div>
|
||||
) : a.type === "SEND_EMAIL" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Assunto</Label>
|
||||
<Input
|
||||
value={a.subject}
|
||||
onChange={(e) =>
|
||||
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, subject: e.target.value } : item)))
|
||||
}
|
||||
placeholder="Ex.: Atualização do chamado #{{ticket.reference}}"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Mensagem</Label>
|
||||
<Textarea
|
||||
value={a.message}
|
||||
onChange={(e) =>
|
||||
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, message: e.target.value } : item)))
|
||||
}
|
||||
placeholder="Escreva a mensagem do e-mail..."
|
||||
className="min-h-24"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Variáveis: <code>{"{{ticket.reference}}"}</code>, <code>{"{{ticket.subject}}"}</code>,{" "}
|
||||
<code>{"{{ticket.status}}"}</code>, <code>{"{{ticket.priority}}"}</code>,{" "}
|
||||
<code>{"{{company.name}}"}</code>, <code>{"{{requester.name}}"}</code>,{" "}
|
||||
<code>{"{{assignee.name}}"}</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Destinatários</Label>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-700">
|
||||
<Checkbox
|
||||
checked={a.toRequester}
|
||||
onCheckedChange={(checked) =>
|
||||
setActions((prev) =>
|
||||
prev.map((item) => (item.id === a.id ? { ...item, toRequester: Boolean(checked) } : item))
|
||||
)
|
||||
}
|
||||
/>
|
||||
Solicitante do ticket
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-700">
|
||||
<Checkbox
|
||||
checked={a.toAssignee}
|
||||
onCheckedChange={(checked) =>
|
||||
setActions((prev) =>
|
||||
prev.map((item) => (item.id === a.id ? { ...item, toAssignee: Boolean(checked) } : item))
|
||||
)
|
||||
}
|
||||
/>
|
||||
Responsável do ticket
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Agente específico (opcional)</Label>
|
||||
<Select
|
||||
value={a.toUserId}
|
||||
onValueChange={(value) =>
|
||||
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, toUserId: value } : item)))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="">Nenhum</SelectItem>
|
||||
{(agents ?? []).map((u) => (
|
||||
<SelectItem key={u._id} value={String(u._id)}>
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">E-mails adicionais</Label>
|
||||
<Input
|
||||
value={a.toEmails}
|
||||
onChange={(e) =>
|
||||
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, toEmails: e.target.value } : item)))
|
||||
}
|
||||
placeholder="ex.: cliente@empresa.com, outro@dominio.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Link do botão</Label>
|
||||
<Select
|
||||
value={a.ctaTarget}
|
||||
onValueChange={(value) =>
|
||||
setActions((prev) =>
|
||||
prev.map((item) => (item.id === a.id ? { ...item, ctaTarget: value as EmailCtaTarget } : item))
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="AUTO">Auto</SelectItem>
|
||||
<SelectItem value="PORTAL">Portal (cliente)</SelectItem>
|
||||
<SelectItem value="STAFF">Painel (agente)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Texto do botão</Label>
|
||||
<Input
|
||||
value={a.ctaLabel}
|
||||
onChange={(e) =>
|
||||
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, ctaLabel: e.target.value } : item)))
|
||||
}
|
||||
placeholder="Abrir chamado"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={a.body}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue