ui: keep close dialog content unchanged; refactor to shared component; layout tweaks per request
This commit is contained in:
parent
040965b148
commit
741a0b5b70
3 changed files with 249 additions and 171 deletions
226
src/components/tickets/close-ticket-dialog.tsx
Normal file
226
src/components/tickets/close-ticket-dialog.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
"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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue