diff --git a/src/components/tickets/close-ticket-dialog.tsx b/src/components/tickets/close-ticket-dialog.tsx
new file mode 100644
index 0000000..3c974df
--- /dev/null
+++ b/src/components/tickets/close-ticket-dialog.tsx
@@ -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(`
+
Olá {{cliente}},
+ A equipe da Raven agradece o contato. Este ticket está sendo encerrado.
+ Se surgirem novas questões, você pode reabrir o ticket em até 7 dias ou nos contatar pelo número ${DEFAULT_PHONE_NUMBER}. Obrigado.
+ 👍 👀 🙌
Gabriel Henrique · Raven
+ `),
+ },
+ {
+ id: "default-no-contact",
+ title: "Tentativa de contato sem sucesso",
+ body: sanitizeEditorHtml(`
+ Prezado(a) {{cliente}},
+ Realizamos uma tentativa de contato, mas não obtivemos sucesso.
+ Por favor, retorne assim que possível para seguirmos com as verificações necessárias.
+ Este ticket será encerrado após 3 tentativas realizadas sem sucesso.
+ Telefone para contato: ${DEFAULT_PHONE_NUMBER}.
+ 👍 👀 🙌
Gabriel Henrique · Raven
+ `),
+ },
+ {
+ id: "default-closed-after-attempts",
+ title: "Encerramento após 3 tentativas",
+ body: sanitizeEditorHtml(`
+ Prezado(a) {{cliente}},
+ Esse ticket está sendo encerrado pois realizamos 3 tentativas sem retorno.
+ Você pode reabrir este ticket em até 7 dias ou entrar em contato pelo telefone ${DEFAULT_PHONE_NUMBER} quando preferir.
+ 👍 👀 🙌
Gabriel Henrique · Raven
+ `),
+ },
+]
+
+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(() => {
+ 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(null)
+ const [message, setMessage] = useState("")
+ 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 (
+
+ )
+}
diff --git a/src/components/tickets/status-select.tsx b/src/components/tickets/status-select.tsx
index 39219b3..e1c9a3f 100644
--- a/src/components/tickets/status-select.tsx
+++ b/src/components/tickets/status-select.tsx
@@ -10,10 +10,7 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { CheckCircle2 } from "lucide-react"
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
-import { Spinner } from "@/components/ui/spinner"
-import { RichTextEditor } from "@/components/ui/rich-text-editor"
-import { toast } from "sonner"
+import { CloseTicketDialog } from "@/components/tickets/close-ticket-dialog"
import { cn } from "@/lib/utils"
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
@@ -124,6 +121,7 @@ export function StatusSelect({
+ {/* Dialog control moved to header; keep for status-only contexts */}
{
@@ -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(() => {
- 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(null)
- const [message, setMessage] = useState("")
- 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 (
-
- )
-}
diff --git a/src/components/tickets/ticket-summary-header.tsx b/src/components/tickets/ticket-summary-header.tsx
index a2825dc..0033a66 100644
--- a/src/components/tickets/ticket-summary-header.tsx
+++ b/src/components/tickets/ticket-summary-header.tsx
@@ -16,6 +16,8 @@ import { Separator } from "@/components/ui/separator"
import { PrioritySelect } from "@/components/tickets/priority-select"
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -154,6 +156,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [pauseNote, setPauseNote] = useState("")
const [pausing, setPausing] = useState(false)
const [exportingPdf, setExportingPdf] = useState(false)
+ const [closeOpen, setCloseOpen] = useState(false)
const selectedCategoryId = categorySelection.categoryId
const selectedSubcategoryId = categorySelection.subcategoryId
const dirty = useMemo(
@@ -615,7 +618,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
return (
-
+
+
{workSummary ? (
@@ -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"
title="Tempo total de atendimento"
>
- Tempo total: {formattedTotalWorked}
+ {formattedTotalWorked}
@@ -658,6 +668,15 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
} />
+
| null}
+ requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
+ onSuccess={() => {}}
+ />