feat(automations): redesign da acao de enviar e-mail com UX melhorada

- Cria componente EmailActionConfig dedicado para configuracao de e-mail
- Layout expandido (full-width) para melhor aproveitamento do espaco
- Variaveis como badges clicaveis que inserem no campo ativo
- Editor TipTap para mensagem com suporte a variaveis inline
- Autocomplete de variaveis ao digitar {{
- Organizacao visual melhorada com secoes claras

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-13 23:49:38 -03:00
parent e401053667
commit a4144dd39e
2 changed files with 630 additions and 140 deletions

View file

@ -19,6 +19,7 @@ import { Select, SelectContent, SelectEmptyState, SelectItem, SelectTrigger, Sel
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">
@ -848,7 +849,19 @@ export function AutomationEditorDialog({
</div>
<div className="space-y-2">
{actions.map((a) => (
{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">
@ -1043,144 +1056,6 @@ export function AutomationEditorDialog({
)}
</SelectContent>
</Select>
) : 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) => {
const nextValue = value === CLEAR_SELECT_VALUE ? "" : value
setActions((prev) =>
prev.map((item) => (item.id === a.id ? { ...item, toUserId: nextValue } : 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"
/>
) : (
<>
<SelectItem value={CLEAR_SELECT_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}
@ -1205,7 +1080,8 @@ export function AutomationEditorDialog({
</Button>
</div>
</div>
))}
)
)}
</div>
</div>

View file

@ -0,0 +1,614 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useEditor, EditorContent } from "@tiptap/react"
import type { Editor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Placeholder from "@tiptap/extension-placeholder"
import Mention from "@tiptap/extension-mention"
import { ReactRenderer } from "@tiptap/react"
import tippy, { type Instance, type Props as TippyProps } from "tippy.js"
import { Braces, Trash2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectEmptyState, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
// Tipos
type EmailCtaTarget = "AUTO" | "PORTAL" | "STAFF"
type SendEmailAction = {
id: string
type: "SEND_EMAIL"
subject: string
message: string
toRequester: boolean
toAssignee: boolean
toUserId: string
toEmails: string
ctaTarget: EmailCtaTarget
ctaLabel: string
}
type Agent = {
_id: string
name: string
}
type EmailActionConfigProps = {
action: SendEmailAction
onChange: (action: SendEmailAction) => void
onRemove: () => void
agents: Agent[]
}
// Variaveis disponiveis para interpolacao
const EMAIL_VARIABLES = [
{ key: "ticket.reference", label: "Referencia", description: "Numero do chamado (ex: #1234)" },
{ key: "ticket.subject", label: "Assunto", description: "Titulo do chamado" },
{ key: "ticket.status", label: "Status", description: "Status atual do chamado" },
{ key: "ticket.priority", label: "Prioridade", description: "Nivel de prioridade" },
{ key: "company.name", label: "Empresa", description: "Nome da empresa do solicitante" },
{ key: "requester.name", label: "Solicitante", description: "Nome de quem abriu o chamado" },
{ key: "assignee.name", label: "Responsavel", description: "Nome do agente responsavel" },
] as const
type EmailVariable = (typeof EMAIL_VARIABLES)[number]
const CLEAR_SELECT_VALUE = "__clear__"
// Estilos do badge de variavel
const VARIABLE_BADGE_CLASSES =
"inline-flex items-center gap-1 rounded bg-sky-50 border border-sky-200 px-1.5 py-0.5 text-xs font-mono text-sky-700 whitespace-nowrap"
// Extensao TipTap para variaveis de e-mail
const EmailVariableMentionExtension = Mention.extend({
name: "emailVariable",
priority: 1000,
group: "inline",
inline: true,
selectable: true,
atom: true,
addAttributes() {
return {
id: { default: null },
label: { default: null },
}
},
addKeyboardShortcuts() {
return {
Backspace: ({ editor }: { editor: Editor }) => {
const { state } = editor
const { selection } = state
if (selection.empty) {
const { $from } = selection
const nodeBefore = $from.nodeBefore
if (nodeBefore?.type?.name === this.name) {
const from = $from.pos - nodeBefore.nodeSize
const to = $from.pos
editor.chain().focus().deleteRange({ from, to }).run()
return true
}
}
return false
},
}
},
parseHTML() {
return [
{
tag: `span[data-email-variable]`,
getAttrs: (dom: HTMLElement | string) => {
if (dom instanceof HTMLElement) {
return {
id: dom.dataset.variableId ?? null,
label: dom.dataset.variableLabel ?? dom.textContent ?? null,
}
}
return {}
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
const id = String(HTMLAttributes.id ?? "")
const label = String(HTMLAttributes.label ?? id)
return [
"span",
{
"data-email-variable": "true",
"data-variable-id": id,
"data-variable-label": label,
class: VARIABLE_BADGE_CLASSES,
contenteditable: "false",
},
`{{${id}}}`,
]
},
addNodeView() {
return ({ node }) => {
const dom = document.createElement("span")
dom.className = VARIABLE_BADGE_CLASSES
dom.contentEditable = "false"
dom.dataset.emailVariable = "true"
dom.dataset.variableId = String(node.attrs.id ?? "")
dom.dataset.variableLabel = String(node.attrs.label ?? "")
dom.textContent = `{{${node.attrs.id}}}`
return { dom }
}
},
}).configure({
suggestion: {
char: "{{",
allowSpaces: false,
startOfLine: false,
render: () => {
let component: ReactRenderer | null = null
let popup: Instance<TippyProps> | null = null
let keydownHandler: ((event: KeyboardEvent) => boolean) | null = null
const registerHandler = (handler: ((event: KeyboardEvent) => boolean) | null) => {
keydownHandler = handler
}
return {
onStart: (props) => {
component = new ReactRenderer(EmailVariableList, {
props: { ...props, onRegister: registerHandler },
editor: props.editor,
})
if (!props.clientRect) return
popup = tippy(document.body, {
getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(),
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
zIndex: 99999,
})
},
onUpdate(props) {
component?.updateProps({ ...props, onRegister: registerHandler })
if (!props.clientRect) return
popup?.setProps({
getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(),
})
},
onKeyDown(props) {
if (props.event.key === "Escape") {
popup?.hide()
return true
}
if (keydownHandler?.(props.event)) {
return true
}
return false
},
onExit() {
popup?.destroy()
component?.destroy()
keydownHandler = null
},
}
},
items: ({ query }) => {
const q = query.toLowerCase().replace(/^\{*/, "")
return EMAIL_VARIABLES.filter(
(v) =>
v.key.toLowerCase().includes(q) ||
v.label.toLowerCase().includes(q) ||
v.description.toLowerCase().includes(q)
)
},
},
})
// Lista de sugestoes de variaveis
type EmailVariableListProps = {
items: EmailVariable[]
command: (item: { id: string; label: string }) => void
onRegister?: (handler: ((event: KeyboardEvent) => boolean) | null) => void
}
function EmailVariableList({ items, command, onRegister }: EmailVariableListProps) {
const [selectedIndex, setSelectedIndex] = useState(0)
const selectItem = useCallback(
(index: number) => {
const item = items[index]
if (item) {
command({ id: item.key, label: item.label })
}
},
[command, items]
)
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === "ArrowUp") {
setSelectedIndex((prev) => (prev + items.length - 1) % items.length)
return true
}
if (event.key === "ArrowDown") {
setSelectedIndex((prev) => (prev + 1) % items.length)
return true
}
if (event.key === "Enter") {
selectItem(selectedIndex)
return true
}
return false
},
[items.length, selectItem, selectedIndex]
)
useEffect(() => {
onRegister?.(handleKeyDown)
return () => onRegister?.(null)
}, [handleKeyDown, onRegister])
useEffect(() => {
setSelectedIndex(0)
}, [items])
if (!items.length) {
return (
<div className="rounded-lg border bg-white p-3 text-sm text-muted-foreground shadow-lg">
Nenhuma variavel encontrada
</div>
)
}
return (
<div className="max-h-64 min-w-[280px] overflow-y-auto rounded-lg border bg-white p-1 shadow-lg">
{items.map((item, index) => (
<button
key={item.key}
type="button"
className={cn(
"flex w-full flex-col gap-0.5 rounded-md px-3 py-2 text-left transition",
index === selectedIndex ? "bg-sky-50" : "hover:bg-slate-50"
)}
onMouseEnter={() => setSelectedIndex(index)}
onMouseDown={(e) => {
e.preventDefault()
selectItem(index)
}}
>
<span className="font-mono text-sm text-sky-700">{`{{${item.key}}}`}</span>
<span className="text-xs text-muted-foreground">{item.description}</span>
</button>
))}
</div>
)
}
// Converte HTML com badges para texto com {{variaveis}}
function htmlToPlainVariables(html: string): string {
if (!html) return ""
// Remove tags HTML mantendo o texto
const div = document.createElement("div")
div.innerHTML = html
// Badges de variavel ja tem o texto {{variavel}} dentro
return div.textContent ?? ""
}
// Converte texto com {{variaveis}} para HTML com badges
function plainVariablesToHtml(text: string): string {
if (!text) return ""
// Escapa HTML primeiro
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
// Converte {{var}} para badges
return escaped.replace(/\{\{([^}]+)\}\}/g, (_, varKey) => {
const variable = EMAIL_VARIABLES.find((v) => v.key === varKey)
const label = variable?.label ?? varKey
return `<span data-email-variable="true" data-variable-id="${varKey}" data-variable-label="${label}" class="${VARIABLE_BADGE_CLASSES}">{{${varKey}}}</span>`
})
}
// Editor de mensagem com suporte a variaveis
type MessageEditorProps = {
value: string
onChange: (value: string) => void
onInsertVariable: (ref: { insertVariable: (key: string) => void } | null) => void
}
function MessageEditor({ value, onChange, onInsertVariable }: MessageEditorProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialContent deve ser calculado apenas uma vez na montagem
const initialContent = useMemo(() => plainVariablesToHtml(value), [])
const editor = useEditor({
extensions: [
StarterKit.configure({
bulletList: false,
orderedList: false,
blockquote: false,
codeBlock: false,
heading: false,
horizontalRule: false,
}),
Placeholder.configure({ placeholder: "Escreva a mensagem do e-mail..." }),
EmailVariableMentionExtension,
],
content: initialContent,
editorProps: {
attributes: {
class: "prose prose-sm max-w-none focus:outline-none min-h-[100px] p-3 text-sm",
},
},
onUpdate({ editor }) {
const html = editor.getHTML()
const plain = htmlToPlainVariables(html)
onChange(plain)
},
immediatelyRender: false,
})
// Expoe metodo para inserir variavel externamente
useEffect(() => {
if (!editor) {
onInsertVariable(null)
return
}
onInsertVariable({
insertVariable: (key: string) => {
const variable = EMAIL_VARIABLES.find((v) => v.key === key)
if (!variable) return
editor
.chain()
.focus()
.insertContent({
type: "emailVariable",
attrs: { id: key, label: variable.label },
})
.run()
},
})
return () => onInsertVariable(null)
}, [editor, onInsertVariable])
// Sincroniza valor externo
useEffect(() => {
if (!editor) return
const currentPlain = htmlToPlainVariables(editor.getHTML())
if (currentPlain !== value) {
editor.commands.setContent(plainVariablesToHtml(value), { emitUpdate: false })
}
}, [value, editor])
if (!editor) return null
return (
<div className="rounded-lg border bg-white focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
<EditorContent editor={editor} />
</div>
)
}
// Componente principal
export function EmailActionConfig({ action, onChange, onRemove, agents }: EmailActionConfigProps) {
const subjectInputRef = useRef<HTMLInputElement>(null)
const messageEditorRef = useRef<{ insertVariable: (key: string) => void } | null>(null)
const [activeField, setActiveField] = useState<"subject" | "message">("message")
const handleChange = useCallback(
<K extends keyof SendEmailAction>(key: K, value: SendEmailAction[K]) => {
onChange({ ...action, [key]: value })
},
[action, onChange]
)
const handleInsertVariable = useCallback(
(variable: EmailVariable) => {
if (activeField === "subject" && subjectInputRef.current) {
const input = subjectInputRef.current
const start = input.selectionStart ?? input.value.length
const end = input.selectionEnd ?? input.value.length
const varText = `{{${variable.key}}}`
const newValue = input.value.slice(0, start) + varText + input.value.slice(end)
handleChange("subject", newValue)
// Reposiciona cursor apos a variavel
requestAnimationFrame(() => {
input.focus()
const newPos = start + varText.length
input.setSelectionRange(newPos, newPos)
})
} else if (activeField === "message" && messageEditorRef.current) {
messageEditorRef.current.insertVariable(variable.key)
}
},
[activeField, handleChange]
)
return (
<div className="space-y-4">
{/* Header com tipo e botao de remover */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg bg-sky-100">
<Braces className="size-4 text-sky-600" />
</div>
<span className="font-medium text-neutral-900">Enviar e-mail</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
className="h-8 w-8 text-slate-500 hover:bg-red-50 hover:text-red-700"
title="Remover acao"
>
<Trash2 className="size-4" />
</Button>
</div>
{/* Assunto */}
<div className="space-y-1.5">
<Label className="text-sm font-medium">Assunto do e-mail</Label>
<Input
ref={subjectInputRef}
value={action.subject}
onChange={(e) => handleChange("subject", e.target.value)}
onFocus={() => setActiveField("subject")}
placeholder="Ex.: Atualizacao do chamado #{{ticket.reference}}"
/>
</div>
{/* Mensagem */}
<div className="space-y-1.5">
<Label className="text-sm font-medium">Mensagem</Label>
<div onFocus={() => setActiveField("message")}>
<MessageEditor
value={action.message}
onChange={(v) => handleChange("message", v)}
onInsertVariable={(ref) => {
messageEditorRef.current = ref
}}
/>
</div>
</div>
{/* Paleta de variaveis */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Variaveis disponiveis (clique para inserir no {activeField === "subject" ? "assunto" : "corpo"})
</Label>
<div className="flex flex-wrap gap-1.5">
{EMAIL_VARIABLES.map((variable) => (
<Tooltip key={variable.key}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleInsertVariable(variable)}
className={cn(
VARIABLE_BADGE_CLASSES,
"cursor-pointer transition hover:bg-sky-100 hover:border-sky-300"
)}
>
{`{{${variable.key}}}`}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{variable.description}
</TooltipContent>
</Tooltip>
))}
</div>
</div>
{/* Destinatarios */}
<div className="space-y-3 rounded-lg border border-slate-200 bg-slate-50/50 p-4">
<Label className="text-sm font-medium">Destinatarios</Label>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 text-sm text-neutral-700">
<Checkbox
checked={action.toRequester}
onCheckedChange={(checked) => handleChange("toRequester", Boolean(checked))}
/>
Solicitante do ticket
</label>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<Checkbox
checked={action.toAssignee}
onCheckedChange={(checked) => handleChange("toAssignee", Boolean(checked))}
/>
Responsavel do ticket
</label>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Agente especifico (opcional)</Label>
<Select
value={action.toUserId}
onValueChange={(value) => {
const nextValue = value === CLEAR_SELECT_VALUE ? "" : value
handleChange("toUserId", nextValue)
}}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{agents.length === 0 ? (
<SelectEmptyState
message="Nenhum agente disponivel"
createLabel="Gerenciar usuarios"
createHref="/admin/users"
/>
) : (
<>
<SelectItem value={CLEAR_SELECT_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 text-muted-foreground">E-mails adicionais</Label>
<Input
value={action.toEmails}
onChange={(e) => handleChange("toEmails", e.target.value)}
placeholder="cliente@empresa.com, outro@dominio.com"
className="bg-white"
/>
</div>
</div>
</div>
{/* Botao do e-mail */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Link do botao</Label>
<Select
value={action.ctaTarget}
onValueChange={(value) => handleChange("ctaTarget", value as EmailCtaTarget)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="AUTO">Auto (detecta pelo destinatario)</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 text-muted-foreground">Texto do botao</Label>
<Input
value={action.ctaLabel}
onChange={(e) => handleChange("ctaLabel", e.target.value)}
placeholder="Abrir chamado"
/>
</div>
</div>
</div>
)
}