226 lines
8.6 KiB
TypeScript
226 lines
8.6 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useState } from "react"
|
|
import { useMutation, useQuery } from "convex/react"
|
|
import { api } from "@/convex/_generated/api"
|
|
import type { Id } from "@/convex/_generated/dataModel"
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Spinner } from "@/components/ui/spinner"
|
|
import { RichTextEditor, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
|
import { toast } from "sonner"
|
|
|
|
type ClosingTemplate = { id: string; title: string; body: string }
|
|
|
|
const DEFAULT_PHONE_NUMBER = "(11) 4173-5368"
|
|
|
|
const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
|
|
{
|
|
id: "default-standard",
|
|
title: "Encerramento padrão",
|
|
body: sanitizeEditorHtml(`
|
|
<p>Olá {{cliente}},</p>
|
|
<p>A equipe da Raven agradece o contato. Este ticket está sendo encerrado.</p>
|
|
<p>Se surgirem novas questões, você pode reabrir o ticket em até 7 dias ou nos contatar pelo número <strong>${DEFAULT_PHONE_NUMBER}</strong>. Obrigado.</p>
|
|
<p>👍 👀 🙌<br />Gabriel Henrique · Raven</p>
|
|
`),
|
|
},
|
|
{
|
|
id: "default-no-contact",
|
|
title: "Tentativa de contato sem sucesso",
|
|
body: sanitizeEditorHtml(`
|
|
<p>Prezado(a) {{cliente}},</p>
|
|
<p>Realizamos uma tentativa de contato, mas não obtivemos sucesso.</p>
|
|
<p>Por favor, retorne assim que possível para seguirmos com as verificações necessárias.</p>
|
|
<p>Este ticket será encerrado após 3 tentativas realizadas sem sucesso.</p>
|
|
<p>Telefone para contato: <strong>${DEFAULT_PHONE_NUMBER}</strong>.</p>
|
|
<p>👍 👀 🙌<br />Gabriel Henrique · Raven</p>
|
|
`),
|
|
},
|
|
{
|
|
id: "default-closed-after-attempts",
|
|
title: "Encerramento após 3 tentativas",
|
|
body: sanitizeEditorHtml(`
|
|
<p>Prezado(a) {{cliente}},</p>
|
|
<p>Esse ticket está sendo encerrado pois realizamos 3 tentativas sem retorno.</p>
|
|
<p>Você pode reabrir este ticket em até 7 dias ou entrar em contato pelo telefone <strong>${DEFAULT_PHONE_NUMBER}</strong> quando preferir.</p>
|
|
<p>👍 👀 🙌<br />Gabriel Henrique · Raven</p>
|
|
`),
|
|
},
|
|
]
|
|
|
|
function applyTemplatePlaceholders(html: string, customerName?: string | null) {
|
|
const normalizedName = customerName?.trim()
|
|
const fallback = normalizedName && normalizedName.length > 0 ? normalizedName : "cliente"
|
|
return html.replace(/{{\s*(cliente|customer|customername|nome|nomecliente)\s*}}/gi, fallback)
|
|
}
|
|
|
|
export function CloseTicketDialog({
|
|
open,
|
|
onOpenChange,
|
|
ticketId,
|
|
tenantId,
|
|
actorId,
|
|
requesterName,
|
|
onSuccess,
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
ticketId: string
|
|
tenantId: string
|
|
actorId: Id<"users"> | null
|
|
requesterName?: string | null
|
|
onSuccess: () => void
|
|
}) {
|
|
const updateStatus = useMutation(api.tickets.updateStatus)
|
|
const addComment = useMutation(api.tickets.addComment)
|
|
|
|
const closingTemplates = useQuery(
|
|
actorId && open ? api.commentTemplates.list : "skip",
|
|
actorId && open ? { tenantId, viewerId: actorId, kind: "closing" as const } : "skip"
|
|
) as { id: string; title: string; body: string }[] | undefined
|
|
|
|
const templatesLoading = Boolean(actorId && open && closingTemplates === undefined)
|
|
const templates = useMemo<ClosingTemplate[]>(() => {
|
|
if (closingTemplates && closingTemplates.length > 0) {
|
|
return closingTemplates.map((t) => ({ id: t.id, title: t.title, body: t.body }))
|
|
}
|
|
return DEFAULT_CLOSING_TEMPLATES
|
|
}, [closingTemplates])
|
|
|
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
|
|
const [message, setMessage] = useState<string>("")
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setSelectedTemplateId(null)
|
|
setMessage("")
|
|
setIsSubmitting(false)
|
|
return
|
|
}
|
|
if (templates.length > 0 && !selectedTemplateId && !message) {
|
|
const first = templates[0]
|
|
const hydrated = sanitizeEditorHtml(applyTemplatePlaceholders(first.body, requesterName))
|
|
setSelectedTemplateId(first.id)
|
|
setMessage(hydrated)
|
|
}
|
|
}, [open, templates, requesterName, selectedTemplateId, message])
|
|
|
|
const handleTemplateSelect = (template: ClosingTemplate) => {
|
|
setSelectedTemplateId(template.id)
|
|
const filled = sanitizeEditorHtml(applyTemplatePlaceholders(template.body, requesterName))
|
|
setMessage(filled)
|
|
}
|
|
|
|
const handleSubmit = async () => {
|
|
if (!actorId) {
|
|
toast.error("É necessário estar autenticado para encerrar o ticket.")
|
|
return
|
|
}
|
|
setIsSubmitting(true)
|
|
toast.loading("Encerrando ticket...", { id: "close-ticket" })
|
|
try {
|
|
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: "RESOLVED", actorId })
|
|
const withPlaceholders = applyTemplatePlaceholders(message, requesterName)
|
|
const sanitized = sanitizeEditorHtml(withPlaceholders)
|
|
const hasContent = sanitized.replace(/<[^>]*>/g, "").trim().length > 0
|
|
if (hasContent) {
|
|
await addComment({
|
|
ticketId: ticketId as unknown as Id<"tickets">,
|
|
authorId: actorId,
|
|
visibility: "PUBLIC",
|
|
body: sanitized,
|
|
attachments: [],
|
|
})
|
|
}
|
|
toast.success("Ticket encerrado com sucesso!", { id: "close-ticket" })
|
|
onOpenChange(false)
|
|
onSuccess()
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error("Não foi possível encerrar o ticket.", { id: "close-ticket" })
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Encerrar ticket</DialogTitle>
|
|
<DialogDescription>
|
|
Confirme a mensagem de encerramento que será enviada ao cliente. Você pode personalizar o texto antes de concluir.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium text-neutral-800">Modelos rápidos</p>
|
|
{templatesLoading ? (
|
|
<div className="flex items-center gap-2 text-sm text-neutral-600">
|
|
<Spinner className="size-4" /> Carregando templates...
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-wrap gap-2">
|
|
{templates.map((template) => (
|
|
<Button
|
|
key={template.id}
|
|
type="button"
|
|
variant={selectedTemplateId === template.id ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => handleTemplateSelect(template)}
|
|
>
|
|
{template.title}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<p className="text-xs text-neutral-500">
|
|
Use <code className="rounded bg-slate-100 px-1 py-0.5">{"{{cliente}}"}</code> dentro do template para inserir automaticamente o nome do solicitante.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium text-neutral-800">Mensagem de encerramento</p>
|
|
<RichTextEditor
|
|
value={message}
|
|
onChange={setMessage}
|
|
minHeight={220}
|
|
placeholder="Escreva uma mensagem final para o cliente..."
|
|
/>
|
|
<p className="text-xs text-neutral-500">Você pode editar o conteúdo antes de enviar. Deixe em branco para encerrar sem comentário adicional.</p>
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="flex flex-wrap justify-between gap-3 pt-4">
|
|
<div className="text-xs text-neutral-500">
|
|
O comentário será público e ficará registrado no histórico do ticket.
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={isSubmitting}
|
|
>
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setMessage("")
|
|
setSelectedTemplateId(null)
|
|
}}
|
|
disabled={isSubmitting}
|
|
>
|
|
Limpar mensagem
|
|
</Button>
|
|
<Button type="button" onClick={handleSubmit} disabled={isSubmitting || !actorId}>
|
|
{isSubmitting ? <Spinner className="size-4 text-white" /> : "Encerrar ticket"}
|
|
</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|