ui: keep close dialog content unchanged; refactor to shared component; layout tweaks per request

This commit is contained in:
codex-bot 2025-10-20 17:16:29 -03:00
parent 040965b148
commit 741a0b5b70
3 changed files with 249 additions and 171 deletions

View 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>
)
}

View file

@ -10,10 +10,7 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { CheckCircle2 } from "lucide-react" import { CheckCircle2 } from "lucide-react"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { CloseTicketDialog } from "@/components/tickets/close-ticket-dialog"
import { Spinner } from "@/components/ui/spinner"
import { RichTextEditor } from "@/components/ui/rich-text-editor"
import { toast } from "sonner"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor" import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
@ -124,6 +121,7 @@ export function StatusSelect({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
{/* Dialog control moved to header; keep for status-only contexts */}
<CloseTicketDialog <CloseTicketDialog
open={closeDialogOpen} open={closeDialogOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
@ -141,168 +139,3 @@ export function StatusSelect({
</> </>
) )
} }
type CloseTicketDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
ticketId: string
tenantId: string
actorId: Id<"users"> | null
requesterName?: string | null
onSuccess: () => void
}
function CloseTicketDialog({ open, onOpenChange, ticketId, tenantId, actorId, requesterName, onSuccess }: CloseTicketDialogProps) {
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((template) => ({ id: template.id, title: template.title, body: template.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>
)
}

View file

@ -16,6 +16,8 @@ import { Separator } from "@/components/ui/separator"
import { PrioritySelect } from "@/components/tickets/priority-select" import { PrioritySelect } from "@/components/tickets/priority-select"
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog" import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
import { StatusSelect } from "@/components/tickets/status-select" import { StatusSelect } from "@/components/tickets/status-select"
import { CloseTicketDialog } from "@/components/tickets/close-ticket-dialog"
import { CheckCircle2 } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@ -154,6 +156,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [pauseNote, setPauseNote] = useState("") const [pauseNote, setPauseNote] = useState("")
const [pausing, setPausing] = useState(false) const [pausing, setPausing] = useState(false)
const [exportingPdf, setExportingPdf] = useState(false) const [exportingPdf, setExportingPdf] = useState(false)
const [closeOpen, setCloseOpen] = useState(false)
const selectedCategoryId = categorySelection.categoryId const selectedCategoryId = categorySelection.categoryId
const selectedSubcategoryId = categorySelection.subcategoryId const selectedSubcategoryId = categorySelection.subcategoryId
const dirty = useMemo( const dirty = useMemo(
@ -615,7 +618,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
return ( return (
<div className={cardClass}> <div className={cardClass}>
<div className="absolute right-6 top-6 flex items-center gap-3"> <div className="absolute right-6 top-6 flex items-center gap-3">
<Button
type="button"
className="inline-flex items-center gap-2 rounded-lg border border-sky-300 bg-white px-3 py-1.5 text-sm font-semibold text-sky-700 transition hover:bg-sky-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200"
onClick={() => setCloseOpen(true)}
>
<CheckCircle2 className="size-4" /> Encerrar
</Button>
{workSummary ? ( {workSummary ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -623,7 +633,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
className="inline-flex h-9 cursor-help items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700" className="inline-flex h-9 cursor-help items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700"
title="Tempo total de atendimento" title="Tempo total de atendimento"
> >
<IconClock className="size-4 text-neutral-700" /> Tempo total: {formattedTotalWorked} <IconClock className="size-4 text-neutral-700" /> {formattedTotalWorked}
</Badge> </Badge>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-neutral-700 shadow-lg"> <TooltipContent className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-neutral-700 shadow-lg">
@ -658,6 +668,15 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</Button> </Button>
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} /> <DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
</div> </div>
<CloseTicketDialog
open={closeOpen}
onOpenChange={setCloseOpen}
ticketId={ticket.id as unknown as string}
tenantId={ticket.tenantId}
actorId={convexUserId as Id<"users"> | null}
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
onSuccess={() => {}}
/>
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">