feat: dispositivos e ajustes de csat e relatórios

This commit is contained in:
codex-bot 2025-11-03 19:29:50 -03:00
parent 25d2a9b062
commit e0ef66555d
86 changed files with 5811 additions and 992 deletions

View file

@ -13,6 +13,7 @@ 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"
type ClosingTemplate = { id: string; title: string; body: string }
@ -45,7 +46,7 @@ const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
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é 7 dias ou nos contatar pelo número <strong>${DEFAULT_PHONE_NUMBER}</strong>. Obrigado.</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>
`),
},
@ -67,7 +68,7 @@ const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
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é 7 dias ou entrar em contato pelo telefone <strong>${DEFAULT_PHONE_NUMBER}</strong> quando preferir.</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>
`),
},
@ -105,6 +106,7 @@ export function CloseTicketDialog({
ticketId,
tenantId,
actorId,
ticketReference,
requesterName,
agentName,
onSuccess,
@ -117,6 +119,7 @@ export function CloseTicketDialog({
ticketId: string
tenantId: string
actorId: Id<"users"> | null
ticketReference?: number | null
requesterName?: string | null
agentName?: string | null
onSuccess: () => void
@ -128,7 +131,7 @@ export function CloseTicketDialog({
onWorkSummaryAdjusted?: (result: AdjustWorkSummaryResult) => void
canAdjustTime?: boolean
}) {
const updateStatus = useMutation(api.tickets.updateStatus)
const resolveTicketMutation = useMutation(api.tickets.resolveTicket)
const addComment = useMutation(api.tickets.addComment)
const adjustWorkSummary = useMutation(api.tickets.adjustWorkSummary)
@ -160,6 +163,24 @@ export function CloseTicketDialog({
const [externalMinutes, setExternalMinutes] = useState<string>("0")
const [adjustReason, setAdjustReason] = useState<string>("")
const enableAdjustment = Boolean(canAdjustTime && workSummary)
const [linkedReference, setLinkedReference] = useState<string>("")
const [reopenWindowDays, setReopenWindowDays] = useState<string>("14")
const normalizedReference = useMemo(() => {
const digits = linkedReference.replace(/[^0-9]/g, "").trim()
if (!digits) return null
const parsed = Number(digits)
if (!Number.isFinite(parsed) || parsed <= 0) return null
if (ticketReference && parsed === ticketReference) return null
return parsed
}, [linkedReference, 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 linkNotFound = Boolean(normalizedReference && linkedTicket === null && !isLinkLoading)
const hydrateTemplateBody = useCallback((templateHtml: string) => {
const withPlaceholders = applyTemplatePlaceholders(templateHtml, requesterName, agentName)
@ -269,6 +290,21 @@ export function CloseTicketDialog({
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" })
setIsSubmitting(false)
return
}
if (linkNotFound || !linkedTicket) {
toast.error("Não encontramos o ticket informado para vincular. Verifique o número e tente novamente.", {
id: "close-ticket",
})
setIsSubmitting(false)
return
}
}
if (applyAdjustment) {
const result = (await adjustWorkSummary({
ticketId: ticketId as unknown as Id<"tickets">,
@ -280,7 +316,13 @@ export function CloseTicketDialog({
onWorkSummaryAdjusted?.(result)
}
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: "RESOLVED", actorId })
const reopenDaysNumber = Number(reopenWindowDays)
await resolveTicketMutation({
ticketId: ticketId as unknown as Id<"tickets">,
actorId,
resolvedWithTicketId: linkedTicket ? (linkedTicket.id as Id<"tickets">) : undefined,
reopenWindowDays: Number.isFinite(reopenDaysNumber) ? reopenDaysNumber : undefined,
})
const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName)
const sanitized = stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders))
const hasContent = sanitized.replace(/<[^>]*>/g, "").trim().length > 0
@ -351,6 +393,50 @@ export function CloseTicketDialog({
/>
<p className="text-xs text-neutral-500">Você pode editar o conteúdo antes de enviar. Deixe em branco para encerrar sem comentário adicional.</p>
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
<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>
<Input
id="linked-reference"
value={linkedReference}
onChange={(event) => setLinkedReference(event.target.value)}
placeholder="Número do ticket relacionado (ex.: 12345)"
disabled={isSubmitting}
/>
{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>
) : linkedTicket ? (
<p className="text-xs text-emerald-600">
Será registrado vínculo com o ticket #{linkedTicket.reference} {linkedTicket.subject ?? "Sem assunto"}
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="reopen-window" className="text-sm font-medium text-neutral-800">
Reabertura permitida
</Label>
<Select value={reopenWindowDays} onValueChange={setReopenWindowDays} disabled={isSubmitting}>
<SelectTrigger id="reopen-window">
<SelectValue placeholder="Escolha o prazo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">7 dias</SelectItem>
<SelectItem value="14">14 dias</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-neutral-500">Após esse período o ticket não poderá ser reaberto automaticamente.</p>
</div>
</div>
</div>
</div>
{enableAdjustment ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">