"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}
/>
Incluir ajuste
{shouldAdjustTime ? (
Tempo interno
Atual: {formatDurationLabel(workSummary?.internalWorkedMs ?? 0)}
Tempo externo
Atual: {formatDurationLabel(workSummary?.externalWorkedMs ?? 0)}
) : null}
)
}
if (stepKey === "confirm") {
return (
Ticket relacionado (opcional)
handleLinkedReferenceChange(event.target.value)}
onFocus={handleLinkedReferenceFocus}
onBlur={handleLinkedReferenceBlur}
onKeyDown={handleLinkedReferenceKeyDown}
placeholder="Buscar por número ou assunto do ticket"
disabled={isSubmitting}
/>
{showLinkSuggestions ? (
{isSearchingLinks ? (
Buscando tickets relacionados...
) : linkSuggestions.length === 0 ? (
Nenhum ticket encontrado.
) : (
linkSuggestions.map((suggestion) => {
const statusLabel = formatTicketStatusLabel(suggestion.status)
return (
event.preventDefault()}
onClick={() => handleSelectLinkSuggestion(suggestion)}
className="flex w-full flex-col gap-1 px-3 py-2 text-left text-sm transition hover:bg-slate-100 focus:bg-slate-100"
>
#{suggestion.reference}
{suggestion.subject ? (
{suggestion.subject}
) : null}
Status: {statusLabel}
)
})
)}
) : null}
{linkedReference.trim().length === 0 ? (
Informe o número de outro ticket quando o atendimento estiver relacionado.
) : isLinkLoading ? (
Procurando ticket #{normalizedReference}...
) : linkNotFound ? (
Ticket não encontrado ou sem acesso permitido. Verifique o número informado.
) : linkedTicketCandidate ? (
Será registrado vínculo com o ticket #{linkedTicketCandidate.reference} —{" "}
{linkedTicketCandidate.subject ?? "Sem assunto"}
) : null}
)
}
return (
Modelos rápidos
{templatesLoading ? (
Carregando templates...
) : (
{templates.map((template) => (
handleTemplateSelect(template)}
disabled={isSubmitting}
>
{template.title}
))}
)}
Use {"{{cliente}}"} dentro do template para inserir automaticamente o nome do solicitante.
{
setMessage("")
setSelectedTemplateId(null)
}}
disabled={isSubmitting}
>
Limpar mensagem
Mensagem de encerramento
Este texto é enviado ao cliente e é obrigatório para encerrar o ticket.
{messageWarning ? " Digite uma mensagem para prosseguir." : ""}
)
}
return (
Encerrar ticket
Complete as etapas abaixo para encerrar o ticket e registrar o comunicado final.
{WIZARD_STEPS.map((step, index) => {
const isActive = index === currentStep
const isCompleted = index < currentStep
const canNavigate = index <= currentStep
return (
(canNavigate && !isSubmitting ? goToStep(index) : undefined)}
disabled={!canNavigate || isSubmitting}
className={cn(
"flex size-9 items-center justify-center rounded-full border text-sm font-semibold transition",
isCompleted
? "border-emerald-500 bg-emerald-500 text-white"
: isActive
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-neutral-500"
)}
>
{isCompleted ? : index + 1}
{step.title}
{step.description}
)
})}
{renderStepContent()}
{hasStoredDraft ? (
Rascunho salvo localmente.
) : null}
onOpenChange(false)}
disabled={isSubmitting}
>
Cancelar
Salvar rascunho
{hasStoredDraft ? (
Descartar rascunho
) : null}
{currentStep > 0 ? (
Voltar
) : null}
{!isLastStep ? (
Próximo passo
) : (
{isSubmitting ? : "Encerrar ticket"}
)}
)
}