"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 (
)
}