All checks were successful
- 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>
1123 lines
43 KiB
TypeScript
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(/ /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>
|
|
)
|
|
}
|