"use client" import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react" import { CheckCircle2, Eraser } from "lucide-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" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import type { TicketStatus } from "@/lib/schemas/ticket" import { cn } from "@/lib/utils" type ClosingTemplate = { id: string; title: string; body: string } type TicketLinkSuggestion = { id: string reference: number subject: string status: string priority: string } const DEFAULT_PHONE_NUMBER = "(11) 4173-5368" const DEFAULT_COMPANY_NAME = "Rever Tecnologia" const sanitizeTemplate = (html: string) => stripLeadingEmptyParagraphs(sanitizeEditorHtml(html.trim())) const htmlToPlainText = (value: string) => value .replace(/<[^>]+>/g, " ") .replace(/ /gi, " ") .replace(/\s+/g, " ") .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é 14 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é 14 dias ou entrar em contato pelo telefone ${DEFAULT_PHONE_NUMBER} quando preferir.

{{agente}} · ${DEFAULT_COMPANY_NAME}

`), }, ] const WIZARD_STEPS = [ { key: "message", title: "Mensagem", description: "Personalize o texto enviado ao cliente." }, { key: "time", title: "Tempo", description: "Revise e ajuste o esforço registrado." }, { key: "confirm", title: "Confirmações", description: "Vincule tickets e defina reabertura." }, ] as const type WizardStepKey = (typeof WIZARD_STEPS)[number]["key"] const DRAFT_STORAGE_PREFIX = "close-ticket-draft:" type CloseTicketDraft = { selectedTemplateId: string | null message: string shouldAdjustTime: boolean internalHours: string internalMinutes: string internalSeconds: string externalHours: string externalMinutes: string externalSeconds: string adjustReason: string linkedReference: string reopenWindowDays: string step: number } 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 totalSeconds = Math.floor(safeMs / 1000) const hours = Math.floor(totalSeconds / 3600) const minutes = Math.floor((totalSeconds % 3600) / 60) const seconds = totalSeconds % 60 return { hours, minutes, seconds } } const formatDurationLabel = (ms: number) => { const { hours, minutes, seconds } = splitDuration(ms) if (hours > 0) { return `${hours}h ${minutes.toString().padStart(2, "0")}m` } if (minutes > 0) { return `${minutes}m ${seconds.toString().padStart(2, "0")}s` } return `${seconds}s` } const STATUS_LABELS: Record = { PENDING: "Pendente", AWAITING_ATTENDANCE: "Em atendimento", PAUSED: "Pausado", RESOLVED: "Resolvido", } function formatTicketStatusLabel(status: string) { const normalized = (status ?? "").toUpperCase() if (normalized in STATUS_LABELS) { return STATUS_LABELS[normalized as TicketStatus] } return status } export function CloseTicketDialog({ open, onOpenChange, ticketId, tenantId, actorId, ticketReference, requesterName, agentName, onSuccess, workSummary, onWorkSummaryAdjusted, canAdjustTime = false, }: { open: boolean onOpenChange: (open: boolean) => void ticketId: string tenantId: string actorId: Id<"users"> | null ticketReference?: number | null requesterName?: string | null agentName?: string | null onSuccess: () => void workSummary?: { totalWorkedMs: number internalWorkedMs: number externalWorkedMs: number } | null onWorkSummaryAdjusted?: (result: AdjustWorkSummaryResult) => void canAdjustTime?: boolean }) { const resolveTicketMutation = useMutation(api.tickets.resolveTicket) 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( api.commentTemplates.list, closingTemplateArgs ?? "skip" ) 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 [messageWarning, setMessageWarning] = useState(false) const messagePlainText = useMemo(() => htmlToPlainText(message ?? ""), [message]) const hasMessage = messagePlainText.length > 0 const [isSubmitting, setIsSubmitting] = useState(false) const [shouldAdjustTime, setShouldAdjustTime] = useState(false) const [internalHours, setInternalHours] = useState("0") const [internalMinutes, setInternalMinutes] = useState("0") const [internalSeconds, setInternalSeconds] = useState("0") const [externalHours, setExternalHours] = useState("0") const [externalMinutes, setExternalMinutes] = useState("0") const [externalSeconds, setExternalSeconds] = useState("0") const [adjustReason, setAdjustReason] = useState("") const enableAdjustment = Boolean(canAdjustTime && workSummary) const [linkedReference, setLinkedReference] = useState("") const [linkSuggestions, setLinkSuggestions] = useState([]) const [linkedTicketSelection, setLinkedTicketSelection] = useState(null) const [showLinkSuggestions, setShowLinkSuggestions] = useState(false) const [isSearchingLinks, setIsSearchingLinks] = useState(false) const linkSuggestionsAbortRef = useRef(null) const linkedReferenceInputRef = useRef(null) const suggestionHideTimeoutRef = useRef | null>(null) const [reopenWindowDays, setReopenWindowDays] = useState("14") const [currentStep, setCurrentStep] = useState(0) const [draftLoaded, setDraftLoaded] = useState(false) const [hasStoredDraft, setHasStoredDraft] = useState(false) const draftStorageKey = useMemo(() => `${DRAFT_STORAGE_PREFIX}${ticketId}`, [ticketId]) useEffect(() => { if (messageWarning && hasMessage) { setMessageWarning(false) } }, [hasMessage, messageWarning]) const digitsOnlyReference = linkedReference.replace(/[^0-9]/g, "").trim() const normalizedReference = useMemo(() => { if (!digitsOnlyReference) return null const parsed = Number(digitsOnlyReference) if (!Number.isFinite(parsed) || parsed <= 0) return null if (ticketReference && parsed === ticketReference) return null return parsed }, [digitsOnlyReference, ticketReference]) const linkedTicket = useQuery( api.tickets.findByReference, actorId && normalizedReference ? { tenantId, viewerId: actorId, reference: normalizedReference } : "skip" ) as { id: Id<"tickets">; reference: number; subject: string; status: string } | null | undefined const isLinkLoading = Boolean(actorId && normalizedReference && linkedTicket === undefined) const hasSufficientDigits = digitsOnlyReference.length >= 3 const linkedTicketCandidate = linkedTicketSelection ?? (linkedTicket ?? null) const linkNotFound = Boolean( hasSufficientDigits && !isLinkLoading && !linkedTicketSelection && linkedTicket === null && linkSuggestions.length === 0 ) const hydrateTemplateBody = useCallback((templateHtml: string) => { const withPlaceholders = applyTemplatePlaceholders(templateHtml, requesterName, agentName) return stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders)) }, [requesterName, agentName]) const resetFormState = useCallback(() => { setSelectedTemplateId(null) setMessage("") setIsSubmitting(false) setShouldAdjustTime(false) setAdjustReason("") setInternalHours("0") setInternalMinutes("0") setInternalSeconds("0") setExternalHours("0") setExternalMinutes("0") setExternalSeconds("0") setLinkedReference("") setLinkedTicketSelection(null) setLinkSuggestions([]) setShowLinkSuggestions(false) setReopenWindowDays("14") setCurrentStep(0) }, []) const loadDraft = useCallback(() => { if (typeof window === "undefined") { setHasStoredDraft(false) return } const stored = window.localStorage.getItem(draftStorageKey) if (!stored) { setHasStoredDraft(false) setCurrentStep(0) resetFormState() return } try { const parsed = JSON.parse(stored) as CloseTicketDraft setSelectedTemplateId(parsed.selectedTemplateId ?? null) setMessage(parsed.message ?? "") setShouldAdjustTime(Boolean(parsed.shouldAdjustTime)) setInternalHours(parsed.internalHours ?? "0") setInternalMinutes(parsed.internalMinutes ?? "0") setInternalSeconds(parsed.internalSeconds ?? "0") setExternalHours(parsed.externalHours ?? "0") setExternalMinutes(parsed.externalMinutes ?? "0") setExternalSeconds(parsed.externalSeconds ?? "0") setAdjustReason(parsed.adjustReason ?? "") setLinkedReference(parsed.linkedReference ?? "") setLinkedTicketSelection(null) setLinkSuggestions([]) setShowLinkSuggestions(false) setReopenWindowDays(parsed.reopenWindowDays ?? "14") setCurrentStep(Math.min(parsed.step ?? 0, WIZARD_STEPS.length - 1)) setHasStoredDraft(true) } catch (error) { console.error("Erro ao carregar rascunho de encerramento", error) window.localStorage.removeItem(draftStorageKey) setHasStoredDraft(false) resetFormState() } }, [draftStorageKey, resetFormState]) const handleSaveDraft = useCallback(() => { if (typeof window === "undefined") return const payload: CloseTicketDraft = { selectedTemplateId, message, shouldAdjustTime, internalHours, internalMinutes, internalSeconds, externalHours, externalMinutes, externalSeconds, adjustReason, linkedReference, reopenWindowDays, step: currentStep, } window.localStorage.setItem(draftStorageKey, JSON.stringify(payload)) setHasStoredDraft(true) toast.success("Rascunho salvo.") }, [ adjustReason, currentStep, draftStorageKey, externalHours, externalMinutes, externalSeconds, internalHours, internalMinutes, internalSeconds, linkedReference, message, reopenWindowDays, selectedTemplateId, shouldAdjustTime, ]) const handleDiscardDraft = useCallback(() => { if (typeof window !== "undefined") { window.localStorage.removeItem(draftStorageKey) } setHasStoredDraft(false) resetFormState() toast.success("Rascunho descartado.") }, [draftStorageKey, resetFormState]) useEffect(() => { if (open && !draftLoaded) { loadDraft() setDraftLoaded(true) } if (!open && draftLoaded) { setDraftLoaded(false) resetFormState() } }, [open, draftLoaded, loadDraft, resetFormState]) useEffect(() => { return () => { linkSuggestionsAbortRef.current?.abort() if (suggestionHideTimeoutRef.current) { clearTimeout(suggestionHideTimeoutRef.current) } } }, []) 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()) setInternalSeconds(internal.seconds.toString()) setExternalHours(external.hours.toString()) setExternalMinutes(external.minutes.toString()) setExternalSeconds(external.seconds.toString()) }, [ open, enableAdjustment, shouldAdjustTime, workSummary?.internalWorkedMs, workSummary?.externalWorkedMs, ]) useEffect(() => { if (!shouldAdjustTime) { setAdjustReason("") } }, [shouldAdjustTime]) useEffect(() => { if (!open) return const rawQuery = linkedReference.trim() if (rawQuery.length < 2) { linkSuggestionsAbortRef.current?.abort() setIsSearchingLinks(false) setLinkSuggestions([]) setShowLinkSuggestions(false) return } const digitsForSelection = String(linkedTicketSelection?.reference ?? "") if (linkedTicketSelection && digitsForSelection === digitsOnlyReference) { setLinkSuggestions([]) setIsSearchingLinks(false) return } linkSuggestionsAbortRef.current?.abort() const controller = new AbortController() linkSuggestionsAbortRef.current = controller setIsSearchingLinks(true) if (linkedReferenceInputRef.current && document.activeElement === linkedReferenceInputRef.current) { setShowLinkSuggestions(true) } fetch(`/api/tickets/mentions?q=${encodeURIComponent(rawQuery)}`, { signal: controller.signal, }) .then(async (response) => { if (!response.ok) { return { items: [] as TicketLinkSuggestion[] } } const json = (await response.json()) as { items?: Array<{ id: string; reference: number; subject?: string | null; status?: string | null; priority?: string | null }> } const items = Array.isArray(json.items) ? json.items : [] return { items: items .filter((item) => String(item.id) !== ticketId && Number(item.reference) !== ticketReference) .map((item) => ({ id: String(item.id), reference: Number(item.reference), subject: item.subject ?? "", status: item.status ?? "PENDING", priority: item.priority ?? "MEDIUM", })), } }) .then(({ items }) => { if (controller.signal.aborted) { return } setLinkSuggestions(items) if (linkedReferenceInputRef.current && document.activeElement === linkedReferenceInputRef.current) { setShowLinkSuggestions(true) } }) .catch((error) => { if ((error as Error).name !== "AbortError") { console.error("Falha ao buscar tickets para vincular", error) } }) .finally(() => { if (!controller.signal.aborted) { setIsSearchingLinks(false) } }) return () => { controller.abort() } }, [open, linkedReference, digitsOnlyReference, linkedTicketSelection, ticketId, ticketReference]) const handleTemplateSelect = (template: ClosingTemplate) => { setSelectedTemplateId(template.id) setMessage(hydrateTemplateBody(template.body)) } const handleLinkedReferenceChange = (value: string) => { setLinkedReference(value) const digits = value.replace(/[^0-9]/g, "").trim() if (value.trim().length === 0) { setLinkedTicketSelection(null) setLinkSuggestions([]) setShowLinkSuggestions(false) return } if (linkedTicketSelection && String(linkedTicketSelection.reference) !== digits) { setLinkedTicketSelection(null) } if (value.trim().length >= 2) { setShowLinkSuggestions(true) } else { setShowLinkSuggestions(false) } } const handleLinkedReferenceFocus = () => { if (suggestionHideTimeoutRef.current) { clearTimeout(suggestionHideTimeoutRef.current) suggestionHideTimeoutRef.current = null } if (linkSuggestions.length > 0) { setShowLinkSuggestions(true) } } const handleLinkedReferenceBlur = () => { suggestionHideTimeoutRef.current = setTimeout(() => { setShowLinkSuggestions(false) }, 150) } const handleSelectLinkSuggestion = (suggestion: TicketLinkSuggestion) => { if (suggestionHideTimeoutRef.current) { clearTimeout(suggestionHideTimeoutRef.current) suggestionHideTimeoutRef.current = null } setLinkedTicketSelection(suggestion) setLinkedReference(`#${suggestion.reference}`) setShowLinkSuggestions(false) setLinkSuggestions([]) } const handleLinkedReferenceKeyDown = (event: KeyboardEvent) => { if (event.key === "Enter") { if (linkSuggestions.length > 0) { event.preventDefault() handleSelectLinkSuggestion(linkSuggestions[0]) } } } const hasFormChanges = Boolean(message.trim().length) || Boolean(selectedTemplateId) || shouldAdjustTime || adjustReason.trim().length > 0 || linkedReference.trim().length > 0 const canSaveDraft = hasFormChanges && !isSubmitting const goToStep = (index: number) => { if (index < 0 || index >= WIZARD_STEPS.length) return setCurrentStep(index) } const goToNextStep = useCallback(() => { if (currentStep === 0 && !hasMessage) { setMessageWarning(true) toast.error("Escreva uma mensagem de encerramento antes de continuar.") return } setCurrentStep((prev) => Math.min(prev + 1, WIZARD_STEPS.length - 1)) }, [currentStep, hasMessage]) const goToPreviousStep = () => setCurrentStep((prev) => Math.max(prev - 1, 0)) const isLastStep = currentStep === WIZARD_STEPS.length - 1 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 internalSecondsValue = parsePart(internalSeconds, "segundos internos") if (internalSecondsValue === null) return if (internalSecondsValue >= 60) { toast.error("Os segundos 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 } const externalSecondsValue = parsePart(externalSeconds, "segundos externos") if (externalSecondsValue === null) return if (externalSecondsValue >= 60) { toast.error("Os segundos externos devem estar entre 0 e 59.") return } targetInternalMs = (internalHoursValue * 3600 + internalMinutesValue * 60 + internalSecondsValue) * 1000 targetExternalMs = (externalHoursValue * 3600 + externalMinutesValue * 60 + externalSecondsValue) * 1000 trimmedReason = adjustReason.trim() if (trimmedReason.length < 5) { toast.error("Descreva o motivo do ajuste (mínimo de 5 caracteres).") return } } const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName) const sanitizedMessage = stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders)) const hasContent = sanitizedMessage.replace(/<[^>]*>/g, "").trim().length > 0 if (!hasContent) { toast.error("Inclua uma mensagem de encerramento antes de finalizar o ticket.", { id: "close-ticket" }) return } toast.dismiss("close-ticket") setIsSubmitting(true) toast.loading(applyAdjustment ? "Ajustando tempo e encerrando ticket..." : "Encerrando ticket...", { id: "close-ticket" }) try { if (linkedReference.trim().length > 0) { if (isLinkLoading) { toast.error("Aguarde carregar o ticket vinculado antes de encerrar.", { id: "close-ticket" }) return } if (linkNotFound || !linkedTicketCandidate) { toast.error("Não encontramos o ticket informado para vincular. Verifique o número e tente novamente.", { id: "close-ticket", }) return } } 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 resolveTicketMutation({ ticketId: ticketId as unknown as Id<"tickets">, actorId, resolvedWithTicketId: linkedTicketCandidate ? (linkedTicketCandidate.id as Id<"tickets">) : undefined, }) await addComment({ ticketId: ticketId as unknown as Id<"tickets">, authorId: actorId, visibility: "PUBLIC", body: sanitizedMessage, attachments: [], }) toast.success("Ticket encerrado com sucesso!", { id: "close-ticket" }) if (typeof window !== "undefined") { window.localStorage.removeItem(draftStorageKey) } setHasStoredDraft(false) onOpenChange(false) onSuccess() } catch (error) { console.error(error) const fallback = applyAdjustment ? "Não foi possível ajustar o tempo ou encerrar o ticket." : "Não foi possível encerrar o ticket." const message = error instanceof Error && error.message.trim().length > 0 ? error.message : fallback toast.error(message, { id: "close-ticket" }) } finally { setIsSubmitting(false) } } const renderStepContent = () => { const stepKey = WIZARD_STEPS[currentStep]?.key ?? "message" if (stepKey === "time") { if (!enableAdjustment) { return (
Os ajustes de tempo não estão disponíveis para o seu perfil. Apenas administradores e agentes podem alterar o tempo registrado antes de encerrar um ticket.
) } return (

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} />
setInternalSeconds(event.target.value)} disabled={isSubmitting} />

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

Tempo externo

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

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