"use client" import { useCallback, 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, stripLeadingEmptyParagraphs } from "@/components/ui/rich-text-editor" import { toast } from "sonner" import { Checkbox } from "@/components/ui/checkbox" import { Label } from "@/components/ui/label" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" type ClosingTemplate = { id: string; title: string; body: string } const DEFAULT_PHONE_NUMBER = "(11) 4173-5368" const DEFAULT_COMPANY_NAME = "Rever Tecnologia" const sanitizeTemplate = (html: string) => stripLeadingEmptyParagraphs(sanitizeEditorHtml(html.trim())) export type AdjustWorkSummaryResult = { ticketId: Id<"tickets"> totalWorkedMs: number internalWorkedMs: number externalWorkedMs: number serverNow?: number perAgentTotals?: Array<{ agentId: string agentName: string | null agentEmail: string | null avatarUrl: string | null totalWorkedMs: number internalWorkedMs: number externalWorkedMs: number }> } const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [ { id: "default-standard", title: "Encerramento padrão", body: sanitizeTemplate(`

Olá {{cliente}},

A equipe da ${DEFAULT_COMPANY_NAME} 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.

{{agente}} · ${DEFAULT_COMPANY_NAME}

`), }, { id: "default-no-contact", title: "Tentativa de contato sem sucesso", body: sanitizeTemplate(`

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}.

{{agente}} · ${DEFAULT_COMPANY_NAME}

`), }, { id: "default-closed-after-attempts", title: "Encerramento após 3 tentativas", body: sanitizeTemplate(`

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.

{{agente}} · ${DEFAULT_COMPANY_NAME}

`), }, ] function applyTemplatePlaceholders(html: string, customerName?: string | null, agentName?: string | null) { const normalizedCustomer = customerName?.trim() const customerFallback = normalizedCustomer && normalizedCustomer.length > 0 ? normalizedCustomer : "cliente" const normalizedAgent = agentName?.trim() const agentFallback = normalizedAgent && normalizedAgent.length > 0 ? normalizedAgent : "Equipe Rever" return html .replace(/{{\s*(cliente|customer|customername|nome|nomecliente)\s*}}/gi, customerFallback) .replace(/{{\s*(agente|agent|atendente|responsavel|usu[aá]rio|usuario)\s*}}/gi, agentFallback) .replace(/{{\s*(empresa|company|companhia)\s*}}/gi, DEFAULT_COMPANY_NAME) } const splitDuration = (ms: number) => { const safeMs = Number.isFinite(ms) && ms > 0 ? ms : 0 const totalMinutes = Math.round(safeMs / 60000) const hours = Math.floor(totalMinutes / 60) const minutes = totalMinutes % 60 return { hours, minutes } } const formatDurationLabel = (ms: number) => { const { hours, minutes } = splitDuration(ms) if (hours > 0 && minutes > 0) return `${hours}h ${minutes}min` if (hours > 0) return `${hours}h` return `${minutes}min` } export function CloseTicketDialog({ open, onOpenChange, ticketId, tenantId, actorId, requesterName, agentName, onSuccess, workSummary, onWorkSummaryAdjusted, canAdjustTime = false, }: { open: boolean onOpenChange: (open: boolean) => void ticketId: string tenantId: string actorId: Id<"users"> | null requesterName?: string | null agentName?: string | null onSuccess: () => void workSummary?: { totalWorkedMs: number internalWorkedMs: number externalWorkedMs: number } | null onWorkSummaryAdjusted?: (result: AdjustWorkSummaryResult) => void canAdjustTime?: boolean }) { const updateStatus = useMutation(api.tickets.updateStatus) const addComment = useMutation(api.tickets.addComment) const adjustWorkSummary = useMutation(api.tickets.adjustWorkSummary) const closingTemplateArgs = actorId && open ? { tenantId, viewerId: actorId, kind: "closing" as const } : undefined const closingTemplatesRemote = useQuery( actorId && open ? api.commentTemplates.list : undefined, closingTemplateArgs ) const closingTemplates = Array.isArray(closingTemplatesRemote) ? (closingTemplatesRemote as { id: string; title: string; body: string }[]) : undefined const templatesLoading = Boolean(actorId && open && !Array.isArray(closingTemplatesRemote)) 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) const [shouldAdjustTime, setShouldAdjustTime] = useState(false) const [internalHours, setInternalHours] = useState("0") const [internalMinutes, setInternalMinutes] = useState("0") const [externalHours, setExternalHours] = useState("0") const [externalMinutes, setExternalMinutes] = useState("0") const [adjustReason, setAdjustReason] = useState("") const enableAdjustment = Boolean(canAdjustTime && workSummary) const hydrateTemplateBody = useCallback((templateHtml: string) => { const withPlaceholders = applyTemplatePlaceholders(templateHtml, requesterName, agentName) return stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders)) }, [requesterName, agentName]) useEffect(() => { if (open) return setSelectedTemplateId(null) setMessage("") setIsSubmitting(false) setShouldAdjustTime(false) setAdjustReason("") setInternalHours("0") setInternalMinutes("0") setExternalHours("0") setExternalMinutes("0") }, [open]) useEffect(() => { if (!open) return if (templates.length > 0 && !selectedTemplateId && !message) { const first = templates[0] const hydrated = hydrateTemplateBody(first.body) setSelectedTemplateId(first.id) setMessage(hydrated) } }, [open, templates, selectedTemplateId, message, hydrateTemplateBody]) useEffect(() => { if (!open || !enableAdjustment || !shouldAdjustTime) return const internal = splitDuration(workSummary?.internalWorkedMs ?? 0) const external = splitDuration(workSummary?.externalWorkedMs ?? 0) setInternalHours(internal.hours.toString()) setInternalMinutes(internal.minutes.toString()) setExternalHours(external.hours.toString()) setExternalMinutes(external.minutes.toString()) }, [ open, enableAdjustment, shouldAdjustTime, workSummary?.internalWorkedMs, workSummary?.externalWorkedMs, ]) useEffect(() => { if (!shouldAdjustTime) { setAdjustReason("") } }, [shouldAdjustTime]) const handleTemplateSelect = (template: ClosingTemplate) => { setSelectedTemplateId(template.id) setMessage(hydrateTemplateBody(template.body)) } const handleSubmit = async () => { if (!actorId) { toast.error("É necessário estar autenticado para encerrar o ticket.") return } const applyAdjustment = enableAdjustment && shouldAdjustTime let targetInternalMs = 0 let targetExternalMs = 0 let trimmedReason = "" if (applyAdjustment) { const parsePart = (value: string, label: string) => { const trimmed = value.trim() if (trimmed.length === 0) return 0 if (!/^\d+$/u.test(trimmed)) { toast.error(`Informe um número válido para ${label}.`) return null } return Number.parseInt(trimmed, 10) } const internalHoursValue = parsePart(internalHours, "horas internas") if (internalHoursValue === null) return const internalMinutesValue = parsePart(internalMinutes, "minutos internos") if (internalMinutesValue === null) return if (internalMinutesValue >= 60) { toast.error("Os minutos internos devem estar entre 0 e 59.") return } const externalHoursValue = parsePart(externalHours, "horas externas") if (externalHoursValue === null) return const externalMinutesValue = parsePart(externalMinutes, "minutos externos") if (externalMinutesValue === null) return if (externalMinutesValue >= 60) { toast.error("Os minutos externos devem estar entre 0 e 59.") return } targetInternalMs = (internalHoursValue * 60 + internalMinutesValue) * 60000 targetExternalMs = (externalHoursValue * 60 + externalMinutesValue) * 60000 trimmedReason = adjustReason.trim() if (trimmedReason.length < 5) { toast.error("Descreva o motivo do ajuste (mínimo de 5 caracteres).") return } } toast.dismiss("close-ticket") setIsSubmitting(true) toast.loading(applyAdjustment ? "Ajustando tempo e encerrando ticket..." : "Encerrando ticket...", { id: "close-ticket" }) try { if (applyAdjustment) { const result = (await adjustWorkSummary({ ticketId: ticketId as unknown as Id<"tickets">, actorId, internalWorkedMs: targetInternalMs, externalWorkedMs: targetExternalMs, reason: trimmedReason, })) as AdjustWorkSummaryResult onWorkSummaryAdjusted?.(result) } await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: "RESOLVED", actorId }) const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName) const sanitized = stripLeadingEmptyParagraphs(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(applyAdjustment ? "Não foi possível ajustar o tempo ou encerrar o ticket." : "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.

{enableAdjustment ? (

Ajustar tempo antes de encerrar

Atualize o tempo registrado e informe um motivo para o ajuste. O comentário fica visível apenas para a equipe.

setShouldAdjustTime(Boolean(checked))} disabled={isSubmitting} />
{shouldAdjustTime ? (

Tempo interno

setInternalHours(event.target.value)} disabled={isSubmitting} />
setInternalMinutes(event.target.value)} disabled={isSubmitting} />

Atual: {formatDurationLabel(workSummary?.internalWorkedMs ?? 0)}

Tempo externo

setExternalHours(event.target.value)} disabled={isSubmitting} />
setExternalMinutes(event.target.value)} disabled={isSubmitting} />

Atual: {formatDurationLabel(workSummary?.externalWorkedMs ?? 0)}