sistema-de-chamados/src/components/tickets/close-ticket-dialog.tsx
rever-tecnologia ce52a4393b
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m15s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 3m24s
fix(close-ticket): adiciona segundos na formatacao e ajuste de tempo
- Corrige formatacao de tempo para exibir segundos (ex: 2m 04s)
- Adiciona campo de segundos nos inputs de ajuste de tempo
- Melhora espacamento entre secoes de tempo interno e externo

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:59:43 -03:00

1123 lines
43 KiB
TypeScript

"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(/&nbsp;/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(`
<p>Olá {{cliente}},</p>
<p>A equipe da ${DEFAULT_COMPANY_NAME} agradece o contato. Este ticket está sendo encerrado.</p>
<p>Se surgirem novas questões, você pode reabrir o ticket em até 14 dias ou nos contatar pelo número <strong>${DEFAULT_PHONE_NUMBER}</strong>. Obrigado.</p>
<p>{{agente}} · ${DEFAULT_COMPANY_NAME}</p>
`),
},
{
id: "default-no-contact",
title: "Tentativa de contato sem sucesso",
body: sanitizeTemplate(`
<p>Prezado(a) {{cliente}},</p>
<p>Realizamos uma tentativa de contato, mas não obtivemos sucesso.</p>
<p>Por favor, retorne assim que possível para seguirmos com as verificações necessárias.</p>
<p>Este ticket será encerrado após 3 tentativas realizadas sem sucesso.</p>
<p>Telefone para contato: <strong>${DEFAULT_PHONE_NUMBER}</strong>.</p>
<p>{{agente}} · ${DEFAULT_COMPANY_NAME}</p>
`),
},
{
id: "default-closed-after-attempts",
title: "Encerramento após 3 tentativas",
body: sanitizeTemplate(`
<p>Prezado(a) {{cliente}},</p>
<p>Esse ticket está sendo encerrado pois realizamos 3 tentativas sem retorno.</p>
<p>Você pode reabrir este ticket em até 14 dias ou entrar em contato pelo telefone <strong>${DEFAULT_PHONE_NUMBER}</strong> quando preferir.</p>
<p>{{agente}} · ${DEFAULT_COMPANY_NAME}</p>
`),
},
]
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<TicketStatus, string> = {
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<ClosingTemplate[]>(() => {
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<string | null>(null)
const [message, setMessage] = useState<string>("")
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<boolean>(false)
const [internalHours, setInternalHours] = useState<string>("0")
const [internalMinutes, setInternalMinutes] = useState<string>("0")
const [internalSeconds, setInternalSeconds] = useState<string>("0")
const [externalHours, setExternalHours] = useState<string>("0")
const [externalMinutes, setExternalMinutes] = useState<string>("0")
const [externalSeconds, setExternalSeconds] = useState<string>("0")
const [adjustReason, setAdjustReason] = useState<string>("")
const enableAdjustment = Boolean(canAdjustTime && workSummary)
const [linkedReference, setLinkedReference] = useState<string>("")
const [linkSuggestions, setLinkSuggestions] = useState<TicketLinkSuggestion[]>([])
const [linkedTicketSelection, setLinkedTicketSelection] = useState<TicketLinkSuggestion | null>(null)
const [showLinkSuggestions, setShowLinkSuggestions] = useState(false)
const [isSearchingLinks, setIsSearchingLinks] = useState(false)
const linkSuggestionsAbortRef = useRef<AbortController | null>(null)
const linkedReferenceInputRef = useRef<HTMLInputElement | null>(null)
const suggestionHideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [reopenWindowDays, setReopenWindowDays] = useState<string>("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<HTMLInputElement>) => {
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 (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-sm text-neutral-600">
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.
</div>
)
}
return (
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-5 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-neutral-900">Ajustar tempo antes de encerrar</p>
<p className="text-xs text-neutral-500">
Atualize o tempo registrado e informe um motivo para o ajuste. O comentário fica visível apenas para a equipe.
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="toggle-time-adjustment"
checked={shouldAdjustTime}
onCheckedChange={(checked) => setShouldAdjustTime(Boolean(checked))}
disabled={isSubmitting}
/>
<Label htmlFor="toggle-time-adjustment" className="text-sm font-medium text-neutral-800">
Incluir ajuste
</Label>
</div>
</div>
{shouldAdjustTime ? (
<div className="mt-4 space-y-4">
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tempo interno</p>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-1">
<Label htmlFor="adjust-internal-hours" className="text-xs text-neutral-600">
Horas
</Label>
<Input
id="adjust-internal-hours"
type="number"
min={0}
step={1}
inputMode="numeric"
value={internalHours}
onChange={(event) => setInternalHours(event.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-1">
<Label htmlFor="adjust-internal-minutes" className="text-xs text-neutral-600">
Minutos
</Label>
<Input
id="adjust-internal-minutes"
type="number"
min={0}
max={59}
step={1}
inputMode="numeric"
value={internalMinutes}
onChange={(event) => setInternalMinutes(event.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-1">
<Label htmlFor="adjust-internal-seconds" className="text-xs text-neutral-600">
Segundos
</Label>
<Input
id="adjust-internal-seconds"
type="number"
min={0}
max={59}
step={1}
inputMode="numeric"
value={internalSeconds}
onChange={(event) => setInternalSeconds(event.target.value)}
disabled={isSubmitting}
/>
</div>
</div>
<p className="text-xs text-neutral-500">Atual: {formatDurationLabel(workSummary?.internalWorkedMs ?? 0)}</p>
</div>
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tempo externo</p>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-1">
<Label htmlFor="adjust-external-hours" className="text-xs text-neutral-600">
Horas
</Label>
<Input
id="adjust-external-hours"
type="number"
min={0}
step={1}
inputMode="numeric"
value={externalHours}
onChange={(event) => setExternalHours(event.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-1">
<Label htmlFor="adjust-external-minutes" className="text-xs text-neutral-600">
Minutos
</Label>
<Input
id="adjust-external-minutes"
type="number"
min={0}
max={59}
step={1}
inputMode="numeric"
value={externalMinutes}
onChange={(event) => setExternalMinutes(event.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-1">
<Label htmlFor="adjust-external-seconds" className="text-xs text-neutral-600">
Segundos
</Label>
<Input
id="adjust-external-seconds"
type="number"
min={0}
max={59}
step={1}
inputMode="numeric"
value={externalSeconds}
onChange={(event) => setExternalSeconds(event.target.value)}
disabled={isSubmitting}
/>
</div>
</div>
<p className="text-xs text-neutral-500">Atual: {formatDurationLabel(workSummary?.externalWorkedMs ?? 0)}</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="adjust-reason" className="text-xs text-neutral-600">
Motivo do ajuste
</Label>
<Textarea
id="adjust-reason"
value={adjustReason}
onChange={(event) => setAdjustReason(event.target.value)}
placeholder="Descreva por que o tempo precisa ser ajustado..."
rows={3}
disabled={isSubmitting}
/>
<p className="text-xs text-neutral-500">
Registre o motivo para fins de auditoria interna.
</p>
</div>
</div>
) : null}
</div>
)
}
if (stepKey === "confirm") {
return (
<div className="space-y-4">
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-5 shadow-sm">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2">
<Label htmlFor="linked-reference" className="text-sm font-medium text-neutral-800">
Ticket relacionado (opcional)
</Label>
<div className="relative">
<Input
id="linked-reference"
ref={linkedReferenceInputRef}
value={linkedReference}
onChange={(event) => handleLinkedReferenceChange(event.target.value)}
onFocus={handleLinkedReferenceFocus}
onBlur={handleLinkedReferenceBlur}
onKeyDown={handleLinkedReferenceKeyDown}
placeholder="Buscar por número ou assunto do ticket"
disabled={isSubmitting}
/>
{showLinkSuggestions ? (
<div className="absolute left-0 right-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-lg border border-slate-200 bg-white shadow-lg">
{isSearchingLinks ? (
<div className="flex items-center gap-2 px-3 py-2 text-sm text-neutral-500">
<Spinner className="size-3" /> Buscando tickets relacionados...
</div>
) : linkSuggestions.length === 0 ? (
<div className="px-3 py-2 text-sm text-neutral-500">Nenhum ticket encontrado.</div>
) : (
linkSuggestions.map((suggestion) => {
const statusLabel = formatTicketStatusLabel(suggestion.status)
return (
<button
key={suggestion.id}
type="button"
onMouseDown={(event) => 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"
>
<span className="font-semibold text-neutral-900">#{suggestion.reference}</span>
{suggestion.subject ? (
<span className="text-xs text-neutral-600">{suggestion.subject}</span>
) : null}
<span className="text-xs text-neutral-500">Status: {statusLabel}</span>
</button>
)
})
)}
</div>
) : null}
</div>
{linkedReference.trim().length === 0 ? (
<p className="text-xs text-neutral-500">Informe o número de outro ticket quando o atendimento estiver relacionado.</p>
) : isLinkLoading ? (
<p className="flex items-center gap-2 text-xs text-neutral-500">
<Spinner className="size-3" /> Procurando ticket #{normalizedReference}...
</p>
) : linkNotFound ? (
<p className="text-xs text-red-500">
Ticket não encontrado ou sem acesso permitido. Verifique o número informado.
</p>
) : linkedTicketCandidate ? (
<p className="text-xs text-emerald-600">
Será registrado vínculo com o ticket #{linkedTicketCandidate.reference} {" "}
{linkedTicketCandidate.subject ?? "Sem assunto"}
</p>
) : null}
</div>
</div>
</div>
</div>
)
}
return (
<div className="space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium text-neutral-800">Modelos rápidos</p>
{templatesLoading ? (
<div className="flex items-center gap-2 text-sm text-neutral-600">
<Spinner className="size-4" /> Carregando templates...
</div>
) : (
<div className="flex flex-wrap gap-2">
{templates.map((template) => (
<Button
key={template.id}
type="button"
variant={selectedTemplateId === template.id ? "default" : "outline"}
size="sm"
onClick={() => handleTemplateSelect(template)}
disabled={isSubmitting}
>
{template.title}
</Button>
))}
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-neutral-500">
<span>
Use <code className="rounded bg-slate-100 px-1 py-0.5">{"{{cliente}}"}</code> dentro do template para inserir automaticamente o nome do solicitante.
</span>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Limpar mensagem"
onClick={() => {
setMessage("")
setSelectedTemplateId(null)
}}
disabled={isSubmitting}
>
<Eraser className="size-4" />
<span className="sr-only">Limpar mensagem</span>
</Button>
</div>
</div>
<div
className={cn(
"space-y-2 rounded-2xl border px-4 py-3 transition",
messageWarning ? "border-amber-500 bg-amber-50/70 shadow-[0_0_0_1px_rgba(251,191,36,0.4)]" : "border-transparent bg-transparent"
)}
>
<p className="text-sm font-medium text-neutral-800">Mensagem de encerramento</p>
<RichTextEditor
value={message}
onChange={setMessage}
minHeight={220}
placeholder="Descreva o encerramento para o cliente..."
/>
<p
className={cn(
"text-xs",
messageWarning ? "font-semibold text-amber-700" : "text-neutral-500"
)}
>
Este texto é enviado ao cliente e é obrigatório para encerrar o ticket.
{messageWarning ? " Digite uma mensagem para prosseguir." : ""}
</p>
</div>
</div>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Encerrar ticket</DialogTitle>
<DialogDescription>
Complete as etapas abaixo para encerrar o ticket e registrar o comunicado final.
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap">
{WIZARD_STEPS.map((step, index) => {
const isActive = index === currentStep
const isCompleted = index < currentStep
const canNavigate = index <= currentStep
return (
<div
key={step.key}
className="flex min-w-[200px] flex-1 items-center gap-3"
>
<button
type="button"
onClick={() => (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 ? <CheckCircle2 className="size-4" /> : index + 1}
</button>
<div>
<p className={cn("text-sm font-semibold", isActive ? "text-neutral-900" : "text-neutral-600")}>
{step.title}
</p>
<p className="text-xs text-neutral-500">{step.description}</p>
</div>
</div>
)
})}
</div>
</div>
{renderStepContent()}
</div>
<DialogFooter className="mt-4 w-full border-t border-slate-100 pt-4">
{hasStoredDraft ? (
<div className="w-full text-xs text-neutral-500">Rascunho salvo localmente.</div>
) : null}
<div className="flex w-full flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button
type="button"
variant="outline"
onClick={handleSaveDraft}
disabled={!canSaveDraft}
>
Salvar rascunho
</Button>
{hasStoredDraft ? (
<Button type="button" variant="ghost" onClick={handleDiscardDraft} disabled={isSubmitting}>
Descartar rascunho
</Button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{currentStep > 0 ? (
<Button type="button" variant="outline" onClick={goToPreviousStep} disabled={isSubmitting}>
Voltar
</Button>
) : null}
{!isLastStep ? (
<Button type="button" onClick={goToNextStep} disabled={isSubmitting}>
Próximo passo
</Button>
) : (
<Button type="button" onClick={handleSubmit} disabled={isSubmitting || !actorId}>
{isSubmitting ? <Spinner className="size-4 text-white" /> : "Encerrar ticket"}
</Button>
)}
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}