- Badges de variáveis agora com fundo preto e texto branco - Corrige todos os textos sem acentuação (Referência, Título, etc.) - Adiciona suporte a drag and drop das badges de variáveis 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
619 lines
20 KiB
TypeScript
619 lines
20 KiB
TypeScript
"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[]
|
|
}
|
|
|
|
// Variáveis disponíveis para interpolação
|
|
const EMAIL_VARIABLES = [
|
|
{ key: "ticket.reference", label: "Referência", description: "Número do chamado (ex: #1234)" },
|
|
{ key: "ticket.subject", label: "Assunto", description: "Título do chamado" },
|
|
{ key: "ticket.status", label: "Status", description: "Status atual do chamado" },
|
|
{ key: "ticket.priority", label: "Prioridade", description: "Nível 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: "Responsável", description: "Nome do agente responsável" },
|
|
] as const
|
|
|
|
type EmailVariable = (typeof EMAIL_VARIABLES)[number]
|
|
|
|
const CLEAR_SELECT_VALUE = "__clear__"
|
|
|
|
// Estilos do badge de variável (fundo preto com texto branco)
|
|
const VARIABLE_BADGE_CLASSES =
|
|
"inline-flex items-center gap-1 rounded-md bg-neutral-900 border border-neutral-700 px-1.5 py-0.5 text-xs font-mono text-white 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-neutral-100" : "hover:bg-slate-50"
|
|
)}
|
|
onMouseEnter={() => setSelectedIndex(index)}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault()
|
|
selectItem(index)
|
|
}}
|
|
>
|
|
<span className="font-mono text-sm text-neutral-900">{`{{${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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
// 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 botão 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-neutral-900">
|
|
<Braces className="size-4 text-white" />
|
|
</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 ação"
|
|
>
|
|
<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 variáveis */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs text-muted-foreground">
|
|
Variáveis disponíveis (arraste ou 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"
|
|
draggable
|
|
onClick={() => handleInsertVariable(variable)}
|
|
onDragStart={(e) => {
|
|
e.dataTransfer.setData("text/plain", `{{${variable.key}}}`)
|
|
e.dataTransfer.effectAllowed = "copy"
|
|
}}
|
|
className={cn(
|
|
VARIABLE_BADGE_CLASSES,
|
|
"cursor-grab transition hover:bg-neutral-800 active:cursor-grabbing"
|
|
)}
|
|
>
|
|
{`{{${variable.key}}}`}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
{variable.description}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Destinatários */}
|
|
<div className="space-y-3 rounded-lg border border-slate-200 bg-slate-50/50 p-4">
|
|
<Label className="text-sm font-medium">Destinatários</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))}
|
|
/>
|
|
Responsável 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 específico (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 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 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>
|
|
|
|
{/* Botão 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 botão</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 botão</Label>
|
|
<Input
|
|
value={action.ctaLabel}
|
|
onChange={(e) => handleChange("ctaLabel", e.target.value)}
|
|
placeholder="Abrir chamado"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|