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 ( + + + + Encerrar ticket + + Confirme a mensagem de encerramento que será enviada ao cliente. Você pode personalizar o texto antes de concluir. + + +
+
+

Modelos rápidos

+ {templatesLoading ? ( +
+ Carregando templates... +
+ ) : ( +
+ {templates.map((template) => ( + + ))} +
+ )} +

+ Use {"{{cliente}}"} dentro do template para inserir automaticamente o nome do solicitante. +

+
+
+

Mensagem de encerramento

+ +

Você pode editar o conteúdo antes de enviar. Deixe em branco para encerrar sem comentário adicional.

+
+
+ +
+ O comentário será público e ficará registrado no histórico do ticket. +
+
+ + + +
+
+
+
+ ) +} 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 ( - - - - Encerrar ticket - - Confirme a mensagem de encerramento que será enviada ao cliente. Você pode personalizar o texto antes de concluir. - - -
-
-

Modelos rápidos

- {templatesLoading ? ( -
- Carregando templates... -
- ) : ( -
- {templates.map((template) => ( - - ))} -
- )} -

- Use {"{{cliente}}"} dentro do template para inserir automaticamente o nome do solicitante. -

-
-
-

Mensagem de encerramento

- -

Você pode editar o conteúdo antes de enviar. Deixe em branco para encerrar sem comentário adicional.

-
-
- -
- O comentário será público e ficará registrado no histórico do ticket. -
-
- - - -
-
-
-
- ) -} 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={() => {}} + />