feat: dispositivos e ajustes de csat e relatórios
This commit is contained in:
parent
25d2a9b062
commit
e0ef66555d
86 changed files with 5811 additions and 992 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -90,6 +90,23 @@ function RequesterPreview({ customer, company }: RequesterPreviewProps) {
|
|||
|
||||
const NO_COMPANY_VALUE = "__no_company__"
|
||||
|
||||
type TicketFormFieldDefinition = {
|
||||
id: string
|
||||
key: string
|
||||
label: string
|
||||
type: string
|
||||
required: boolean
|
||||
description: string
|
||||
options: Array<{ value: string; label: string }>
|
||||
}
|
||||
|
||||
type TicketFormDefinition = {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
fields: TicketFormFieldDefinition[]
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
subject: z.string().default(""),
|
||||
summary: z.string().optional(),
|
||||
|
|
@ -158,6 +175,41 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
[companiesRemote]
|
||||
)
|
||||
|
||||
const formsRemote = useQuery(
|
||||
api.tickets.listTicketForms,
|
||||
convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
) as TicketFormDefinition[] | undefined
|
||||
|
||||
const forms = useMemo<TicketFormDefinition[]>(() => {
|
||||
const base: TicketFormDefinition = {
|
||||
key: "default",
|
||||
label: "Chamado padrão",
|
||||
description: "Formulário básico para abertura de chamados gerais.",
|
||||
fields: [],
|
||||
}
|
||||
if (Array.isArray(formsRemote) && formsRemote.length > 0) {
|
||||
return [base, ...formsRemote]
|
||||
}
|
||||
return [base]
|
||||
}, [formsRemote])
|
||||
|
||||
const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
|
||||
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
|
||||
|
||||
const selectedForm = useMemo(() => forms.find((formDef) => formDef.key === selectedFormKey) ?? forms[0], [forms, selectedFormKey])
|
||||
|
||||
const handleFormSelection = (key: string) => {
|
||||
setSelectedFormKey(key)
|
||||
setCustomFieldValues({})
|
||||
}
|
||||
|
||||
const handleCustomFieldChange = (field: TicketFormFieldDefinition, value: unknown) => {
|
||||
setCustomFieldValues((prev) => ({
|
||||
...prev,
|
||||
[field.id]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const customersRemote = useQuery(
|
||||
api.users.listCustomers,
|
||||
directoryQueryEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
|
|
@ -395,6 +447,52 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
return
|
||||
}
|
||||
|
||||
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
|
||||
if (selectedFormKey !== "default" && selectedForm?.fields?.length) {
|
||||
for (const field of selectedForm.fields) {
|
||||
const raw = customFieldValues[field.id]
|
||||
const isBooleanField = field.type === "boolean"
|
||||
const isEmpty =
|
||||
raw === undefined ||
|
||||
raw === null ||
|
||||
(typeof raw === "string" && raw.trim().length === 0)
|
||||
|
||||
if (isBooleanField) {
|
||||
const boolValue = Boolean(raw)
|
||||
customFieldsPayload.push({ fieldId: field.id as Id<"ticketFields">, value: boolValue })
|
||||
continue
|
||||
}
|
||||
|
||||
if (field.required && isEmpty) {
|
||||
toast.error(`Preencha o campo "${field.label}".`, { id: "new-ticket" })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
continue
|
||||
}
|
||||
|
||||
let value: unknown = raw
|
||||
if (field.type === "number") {
|
||||
const parsed = typeof raw === "number" ? raw : Number(raw)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
toast.error(`Informe um valor numérico válido para "${field.label}".`, { id: "new-ticket" })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
value = parsed
|
||||
} else if (field.type === "boolean") {
|
||||
value = Boolean(raw)
|
||||
} else if (field.type === "date") {
|
||||
value = String(raw)
|
||||
} else {
|
||||
value = String(raw)
|
||||
}
|
||||
|
||||
customFieldsPayload.push({ fieldId: field.id as Id<"ticketFields">, value })
|
||||
}
|
||||
}
|
||||
setLoading(true)
|
||||
toast.loading("Criando ticket…", { id: "new-ticket" })
|
||||
try {
|
||||
|
|
@ -413,6 +511,8 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
assigneeId: selectedAssignee ? (selectedAssignee as Id<"users">) : undefined,
|
||||
categoryId: values.categoryId as Id<"ticketCategories">,
|
||||
subcategoryId: values.subcategoryId as Id<"ticketSubcategories">,
|
||||
formTemplate: selectedFormKey !== "default" ? selectedFormKey : undefined,
|
||||
customFields: customFieldsPayload.length > 0 ? customFieldsPayload : undefined,
|
||||
})
|
||||
const summaryFallback = values.summary?.trim() ?? ""
|
||||
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback
|
||||
|
|
@ -446,6 +546,8 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
subcategoryId: "",
|
||||
})
|
||||
form.clearErrors()
|
||||
setSelectedFormKey("default")
|
||||
setCustomFieldValues({})
|
||||
setAssigneeInitialized(false)
|
||||
setAttachments([])
|
||||
// Navegar para o ticket recém-criado
|
||||
|
|
@ -497,6 +599,28 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{forms.length > 1 ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
|
||||
<p className="text-sm font-semibold text-neutral-800">Modelo de ticket</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{forms.map((formDef) => (
|
||||
<Button
|
||||
key={formDef.key}
|
||||
type="button"
|
||||
variant={selectedFormKey === formDef.key ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleFormSelection(formDef.key)}
|
||||
>
|
||||
{formDef.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{selectedForm?.description ? (
|
||||
<p className="mt-2 text-xs text-neutral-500">{selectedForm.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FieldSet>
|
||||
<FieldGroup className="lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||
<div className="space-y-4">
|
||||
|
|
@ -811,6 +935,118 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{selectedFormKey !== "default" && selectedForm.fields.length > 0 ? (
|
||||
<div className="space-y-4 rounded-xl border border-slate-200 bg-white px-4 py-4">
|
||||
<p className="text-sm font-semibold text-neutral-800">Informações adicionais</p>
|
||||
{selectedForm.fields.map((field) => {
|
||||
const value = customFieldValues[field.id]
|
||||
const fieldId = `custom-field-${field.id}`
|
||||
const labelSuffix = field.required ? <span className="text-destructive">*</span> : null
|
||||
const helpText = field.description ? (
|
||||
<p className="text-xs text-neutral-500">{field.description}</p>
|
||||
) : null
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2"
|
||||
>
|
||||
<input
|
||||
id={fieldId}
|
||||
type="checkbox"
|
||||
className="size-4 rounded border border-slate-300 text-[#00d6eb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40"
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => handleCustomFieldChange(field, event.target.checked)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor={fieldId} className="text-sm font-medium text-neutral-800">
|
||||
{field.label} {labelSuffix}
|
||||
</label>
|
||||
{helpText}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "select") {
|
||||
return (
|
||||
<Field key={field.id}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {labelSuffix}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onValueChange={(selected) => handleCustomFieldChange(field, selected)}
|
||||
>
|
||||
<SelectTrigger className={selectTriggerClass}>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||
{field.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className={selectItemClass}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "number") {
|
||||
return (
|
||||
<Field key={field.id}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {labelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={fieldId}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={typeof value === "number" || typeof value === "string" ? String(value) : ""}
|
||||
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "date") {
|
||||
return (
|
||||
<Field key={field.id}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {labelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={fieldId}
|
||||
type="date"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Field key={field.id}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {labelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={fieldId}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export function StatusSelect({
|
|||
value,
|
||||
tenantId,
|
||||
requesterName,
|
||||
ticketReference,
|
||||
showCloseButton = true,
|
||||
onStatusChange,
|
||||
}: {
|
||||
|
|
@ -39,6 +40,7 @@ export function StatusSelect({
|
|||
value: TicketStatus
|
||||
tenantId: string
|
||||
requesterName?: string | null
|
||||
ticketReference?: number | null
|
||||
showCloseButton?: boolean
|
||||
onStatusChange?: (next: TicketStatus) => void
|
||||
}) {
|
||||
|
|
@ -94,6 +96,7 @@ export function StatusSelect({
|
|||
ticketId={ticketId}
|
||||
tenantId={tenantId}
|
||||
actorId={actorId}
|
||||
ticketReference={ticketReference ?? null}
|
||||
requesterName={requesterName}
|
||||
agentName={agentName}
|
||||
onSuccess={() => {
|
||||
|
|
|
|||
203
src/components/tickets/ticket-chat-panel.tsx
Normal file
203
src/components/tickets/ticket-chat-panel.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
import { formatDistanceToNowStrict } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 4000
|
||||
|
||||
function formatRelative(timestamp: number) {
|
||||
try {
|
||||
return formatDistanceToNowStrict(timestamp, { locale: ptBR, addSuffix: true })
|
||||
} catch {
|
||||
return new Date(timestamp).toLocaleString("pt-BR")
|
||||
}
|
||||
}
|
||||
|
||||
type TicketChatPanelProps = {
|
||||
ticketId: string
|
||||
}
|
||||
|
||||
export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
|
||||
const { convexUserId } = useAuth()
|
||||
const viewerId = convexUserId ?? null
|
||||
const chat = useQuery(
|
||||
api.tickets.listChatMessages,
|
||||
viewerId ? { ticketId: ticketId as Id<"tickets">, viewerId: viewerId as Id<"users"> } : "skip"
|
||||
) as
|
||||
| {
|
||||
ticketId: string
|
||||
chatEnabled: boolean
|
||||
status: string
|
||||
canPost: boolean
|
||||
reopenDeadline: number | null
|
||||
messages: Array<{
|
||||
id: Id<"ticketChatMessages">
|
||||
body: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
authorId: string
|
||||
authorName: string | null
|
||||
authorEmail: string | null
|
||||
attachments: Array<{ storageId: Id<"_storage">; name: string; size: number | null; type: string | null }>
|
||||
readBy: Array<{ userId: string; readAt: number }>
|
||||
}>
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
|
||||
const markChatRead = useMutation(api.tickets.markChatRead)
|
||||
const postChatMessage = useMutation(api.tickets.postChatMessage)
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
||||
const [draft, setDraft] = useState("")
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
|
||||
const messages = chat?.messages ?? []
|
||||
const canPost = Boolean(chat?.canPost && viewerId)
|
||||
const chatEnabled = Boolean(chat?.chatEnabled)
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerId || !chat || !Array.isArray(chat.messages) || chat.messages.length === 0) return
|
||||
const unreadIds = chat.messages
|
||||
.filter((message) => {
|
||||
const alreadyRead = (message.readBy ?? []).some((entry) => entry.userId === viewerId)
|
||||
return !alreadyRead
|
||||
})
|
||||
.map((message) => message.id)
|
||||
if (unreadIds.length === 0) return
|
||||
void markChatRead({
|
||||
ticketId: ticketId as Id<"tickets">,
|
||||
actorId: viewerId as Id<"users">,
|
||||
messageIds: unreadIds,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to mark chat messages as read", error)
|
||||
})
|
||||
}, [markChatRead, chat, ticketId, viewerId])
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
}, [messages.length])
|
||||
|
||||
const disabledReason = useMemo(() => {
|
||||
if (!chatEnabled) return "Chat desativado para este ticket"
|
||||
if (!canPost) return "Você não tem permissão para enviar mensagens"
|
||||
return null
|
||||
}, [canPost, chatEnabled])
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!viewerId || !canPost || draft.trim().length === 0) return
|
||||
if (draft.length > MAX_MESSAGE_LENGTH) {
|
||||
toast.error(`Mensagem muito longa (máx. ${MAX_MESSAGE_LENGTH} caracteres).`)
|
||||
return
|
||||
}
|
||||
setIsSending(true)
|
||||
toast.dismiss("ticket-chat")
|
||||
toast.loading("Enviando mensagem...", { id: "ticket-chat" })
|
||||
try {
|
||||
await postChatMessage({
|
||||
ticketId: ticketId as Id<"tickets">,
|
||||
actorId: viewerId as Id<"users">,
|
||||
body: draft,
|
||||
})
|
||||
setDraft("")
|
||||
toast.success("Mensagem enviada!", { id: "ticket-chat" })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível enviar a mensagem.", { id: "ticket-chat" })
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!viewerId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<CardTitle className="text-base font-semibold text-neutral-800">Chat do atendimento</CardTitle>
|
||||
{!chatEnabled ? (
|
||||
<span className="text-xs font-medium text-neutral-500">Chat desativado</span>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{chat === undefined ? (
|
||||
<div className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-slate-200 py-6 text-sm text-neutral-500">
|
||||
<Spinner className="size-4" /> Carregando mensagens...
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-center text-sm text-neutral-500">
|
||||
Nenhuma mensagem registrada no chat até o momento.
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-72 space-y-3 overflow-y-auto pr-2">
|
||||
{messages.map((message) => {
|
||||
const isOwn = String(message.authorId) === String(viewerId)
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex flex-col gap-1 rounded-lg border px-3 py-2 text-sm",
|
||||
isOwn ? "border-slate-300 bg-slate-50" : "border-slate-200 bg-white"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-semibold text-neutral-800">{message.authorName ?? "Usuário"}</span>
|
||||
<span className="text-xs text-neutral-500">{formatRelative(message.createdAt)}</span>
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-neutral-700"
|
||||
dangerouslySetInnerHTML={{ __html: message.body }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
placeholder={disabledReason ?? "Digite uma mensagem"}
|
||||
rows={3}
|
||||
disabled={isSending || !canPost || !chatEnabled}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-neutral-500">
|
||||
<span>{draft.length}/{MAX_MESSAGE_LENGTH}</span>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
{!chatEnabled ? (
|
||||
<span className="text-neutral-500">Chat indisponível</span>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSend}
|
||||
disabled={isSending || !canPost || !chatEnabled || draft.trim().length === 0}
|
||||
>
|
||||
{isSending ? <Spinner className="mr-2 size-4" /> : null}
|
||||
Enviar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{disabledReason && chatEnabled ? (
|
||||
<p className="text-xs text-neutral-500">{disabledReason}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
234
src/components/tickets/ticket-csat-card.tsx
Normal file
234
src/components/tickets/ticket-csat-card.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Star } from "lucide-react"
|
||||
import { formatDistanceToNowStrict } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
type TicketCsatCardProps = {
|
||||
ticket: TicketWithDetails
|
||||
}
|
||||
|
||||
function formatRelative(timestamp: Date | null | undefined) {
|
||||
if (!timestamp) return null
|
||||
try {
|
||||
return formatDistanceToNowStrict(timestamp, { locale: ptBR, addSuffix: true })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
|
||||
const router = useRouter()
|
||||
const { session, convexUserId, role: authRole } = useAuth()
|
||||
const submitCsat = useMutation(api.tickets.submitCsat)
|
||||
|
||||
const viewerRole = (authRole ?? session?.user.role ?? "").toUpperCase()
|
||||
const viewerEmail = session?.user.email?.trim().toLowerCase() ?? ""
|
||||
const viewerId = convexUserId as Id<"users"> | undefined
|
||||
|
||||
const requesterEmail = ticket.requester.email.trim().toLowerCase()
|
||||
const isRequesterById = viewerId ? ticket.requester.id === viewerId : false
|
||||
const isRequesterByEmail = viewerEmail && requesterEmail ? viewerEmail === requesterEmail : false
|
||||
const isRequester = isRequesterById || isRequesterByEmail
|
||||
const isResolved = ticket.status === "RESOLVED"
|
||||
|
||||
const initialScore = typeof ticket.csatScore === "number" ? ticket.csatScore : 0
|
||||
const initialComment = ticket.csatComment ?? ""
|
||||
const maxScore = typeof ticket.csatMaxScore === "number" && ticket.csatMaxScore > 0 ? ticket.csatMaxScore : 5
|
||||
|
||||
const [score, setScore] = useState<number>(initialScore)
|
||||
const [comment, setComment] = useState<string>(initialComment)
|
||||
const [hasSubmitted, setHasSubmitted] = useState<boolean>(initialScore > 0)
|
||||
const [ratedAt, setRatedAt] = useState<Date | null>(ticket.csatRatedAt ?? null)
|
||||
const [hoverScore, setHoverScore] = useState<number | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setScore(initialScore)
|
||||
setComment(initialComment)
|
||||
setRatedAt(ticket.csatRatedAt ?? null)
|
||||
setHasSubmitted(initialScore > 0)
|
||||
}, [initialScore, initialComment, ticket.csatRatedAt])
|
||||
|
||||
const effectiveScore = hasSubmitted ? score : hoverScore ?? score
|
||||
const viewerIsStaff = viewerRole === "ADMIN" || viewerRole === "AGENT" || viewerRole === "MANAGER"
|
||||
const staffCanInspect = viewerIsStaff && ticket.status !== "PENDING"
|
||||
const canSubmit =
|
||||
Boolean(viewerId && viewerRole === "COLLABORATOR" && isRequester && isResolved && !hasSubmitted)
|
||||
const hasRating = hasSubmitted
|
||||
const showCard = staffCanInspect || isRequester || hasSubmitted
|
||||
|
||||
const ratedAtRelative = useMemo(() => formatRelative(ratedAt), [ratedAt])
|
||||
|
||||
if (!showCard) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!viewerId) {
|
||||
toast.error("Sessão não autenticada.")
|
||||
return
|
||||
}
|
||||
if (!canSubmit) {
|
||||
toast.error("Você não pode avaliar este chamado.")
|
||||
return
|
||||
}
|
||||
if (score < 1) {
|
||||
toast.error("Selecione uma nota de 1 a 5 estrelas.")
|
||||
return
|
||||
}
|
||||
if (comment.length > 2000) {
|
||||
toast.error("Reduza o comentário para no máximo 2000 caracteres.")
|
||||
return
|
||||
}
|
||||
try {
|
||||
setSubmitting(true)
|
||||
const result = await submitCsat({
|
||||
ticketId: ticket.id as Id<"tickets">,
|
||||
actorId: viewerId,
|
||||
score,
|
||||
maxScore,
|
||||
comment: comment.trim() ? comment.trim() : undefined,
|
||||
})
|
||||
if (result?.score) {
|
||||
setScore(result.score)
|
||||
}
|
||||
if (typeof result?.comment === "string") {
|
||||
setComment(result.comment)
|
||||
}
|
||||
if (result?.ratedAt) {
|
||||
const ratedAtDate = new Date(result.ratedAt)
|
||||
if (!Number.isNaN(ratedAtDate.getTime())) {
|
||||
setRatedAt(ratedAtDate)
|
||||
}
|
||||
}
|
||||
setHasSubmitted(true)
|
||||
toast.success("Avaliação registrada. Obrigado pelo feedback!")
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error("Failed to submit CSAT", error)
|
||||
toast.error("Não foi possível registrar a avaliação. Tente novamente.")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
setHoverScore(null)
|
||||
}
|
||||
}
|
||||
|
||||
const stars = Array.from({ length: maxScore }, (_, index) => index + 1)
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border border-slate-200 bg-gradient-to-br from-white via-white to-slate-50 shadow-sm">
|
||||
<CardHeader className="px-4 pt-5 pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg font-semibold text-neutral-900">Avaliação do atendimento</CardTitle>
|
||||
<CardDescription className="text-sm text-neutral-600">
|
||||
Conte como foi sua experiência com este chamado.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{hasRating ? (
|
||||
<div className="flex items-center gap-1 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
|
||||
Obrigado pelo feedback!
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-4 pb-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{stars.map((value) => {
|
||||
const filled = value <= effectiveScore
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex size-10 items-center justify-center rounded-full border transition",
|
||||
canSubmit
|
||||
? filled
|
||||
? "border-amber-300 bg-amber-50 text-amber-500 hover:border-amber-400 hover:bg-amber-100"
|
||||
: "border-slate-200 bg-white text-slate-300 hover:border-amber-200 hover:bg-amber-50 hover:text-amber-400"
|
||||
: filled
|
||||
? "border-amber-200 bg-amber-50 text-amber-500"
|
||||
: "border-slate-200 bg-white text-slate-300"
|
||||
)}
|
||||
onMouseEnter={() => (canSubmit ? setHoverScore(value) : undefined)}
|
||||
onMouseLeave={() => (canSubmit ? setHoverScore(null) : undefined)}
|
||||
onClick={() => (canSubmit ? setScore(value) : undefined)}
|
||||
disabled={!canSubmit}
|
||||
aria-label={`${value} estrela${value > 1 ? "s" : ""}`}
|
||||
>
|
||||
<Star
|
||||
className="size-5"
|
||||
strokeWidth={1.5}
|
||||
fill={(canSubmit && value <= (hoverScore ?? score)) || (!canSubmit && value <= score) ? "currentColor" : "none"}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{hasRating ? (
|
||||
<p className="text-sm text-neutral-600">
|
||||
Nota final:{" "}
|
||||
<span className="font-semibold text-neutral-900">
|
||||
{score}/{maxScore}
|
||||
</span>
|
||||
{ratedAtRelative ? ` • ${ratedAtRelative}` : null}
|
||||
</p>
|
||||
) : null}
|
||||
{canSubmit ? (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="csat-comment" className="text-sm font-medium text-neutral-800">
|
||||
Deixe um comentário (opcional)
|
||||
</label>
|
||||
<Textarea
|
||||
id="csat-comment"
|
||||
placeholder="O que funcionou bem? Algo poderia ser melhor?"
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
maxLength={2000}
|
||||
className="min-h-[90px] resize-y"
|
||||
/>
|
||||
<div className="flex justify-end text-xs text-neutral-500">{comment.length}/2000</div>
|
||||
</div>
|
||||
) : hasRating && comment ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700">
|
||||
<p className="whitespace-pre-line">{comment}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{viewerIsStaff && !hasRating ? (
|
||||
<p className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-sm text-neutral-500">
|
||||
Nenhuma avaliação registrada para este chamado até o momento.
|
||||
</p>
|
||||
) : null}
|
||||
{!isResolved && viewerRole === "COLLABORATOR" && isRequester && !hasSubmitted ? (
|
||||
<p className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-sm text-neutral-500">
|
||||
Assim que o chamado for encerrado, você poderá registrar sua avaliação aqui.
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
{canSubmit ? (
|
||||
<CardFooter className="flex flex-col gap-2 px-4 pb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-neutral-500">
|
||||
Sua avaliação ajuda a equipe a melhorar continuamente o atendimento.
|
||||
</p>
|
||||
<Button type="button" onClick={handleSubmit} disabled={submitting || score < 1}>
|
||||
{submitting ? "Enviando..." : "Enviar avaliação"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@ import { TicketComments } from "@/components/tickets/ticket-comments.rich";
|
|||
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
|
||||
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
|
||||
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
|
||||
import { TicketCsatCard } from "@/components/tickets/ticket-csat-card";
|
||||
import { TicketChatPanel } from "@/components/tickets/ticket-chat-panel";
|
||||
import { useAuth } from "@/lib/auth-client";
|
||||
|
||||
export function TicketDetailView({ id }: { id: string }) {
|
||||
|
|
@ -90,9 +92,11 @@ export function TicketDetailView({ id }: { id: string }) {
|
|||
return (
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
<TicketSummaryHeader ticket={ticket} />
|
||||
<TicketCsatCard ticket={ticket} />
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<div className="space-y-6">
|
||||
<TicketComments ticket={ticket} />
|
||||
<TicketChatPanel ticketId={ticket.id as string} />
|
||||
<TicketTimeline ticket={ticket} />
|
||||
</div>
|
||||
<TicketDetailsPanel ticket={ticket} />
|
||||
|
|
|
|||
|
|
@ -142,6 +142,22 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
: machineAssignedName && machineAssignedName.length > 0
|
||||
? machineAssignedName
|
||||
: null
|
||||
const viewerId = convexUserId ?? null
|
||||
const viewerRole = (role ?? "").toLowerCase()
|
||||
const [status, setStatus] = useState<TicketStatus>(ticket.status)
|
||||
const reopenDeadline = ticket.reopenDeadline ?? null
|
||||
const isRequester = Boolean(ticket.requester?.id && viewerId && ticket.requester.id === viewerId)
|
||||
const reopenWindowActive = reopenDeadline ? reopenDeadline > Date.now() : false
|
||||
const canReopenTicket =
|
||||
status === "RESOLVED" && reopenWindowActive && (isStaff || viewerRole === "manager" || isRequester)
|
||||
const reopenDeadlineLabel = useMemo(() => {
|
||||
if (!reopenDeadline) return null
|
||||
try {
|
||||
return new Date(reopenDeadline).toLocaleString("pt-BR")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [reopenDeadline])
|
||||
const viewerEmail = session?.user?.email ?? machineContext?.assignedUserEmail ?? null
|
||||
const viewerAvatar = session?.user?.avatarUrl ?? null
|
||||
const viewerAgentMeta = useMemo(
|
||||
|
|
@ -165,6 +181,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const startWork = useMutation(api.tickets.startWork)
|
||||
const pauseWork = useMutation(api.tickets.pauseWork)
|
||||
const updateCategories = useMutation(api.tickets.updateCategories)
|
||||
const reopenTicket = useMutation(api.tickets.reopenTicket)
|
||||
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
|
||||
const queuesEnabled = Boolean(isStaff && convexUserId)
|
||||
const companiesRemote = useQuery(
|
||||
|
|
@ -227,7 +244,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
| null
|
||||
| undefined
|
||||
|
||||
const [status, setStatus] = useState<TicketStatus>(ticket.status)
|
||||
const [assigneeState, setAssigneeState] = useState(ticket.assignee ?? null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [subject, setSubject] = useState(ticket.subject)
|
||||
|
|
@ -242,6 +258,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const [assigneeSelection, setAssigneeSelection] = useState(currentAssigneeId)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [pauseDialogOpen, setPauseDialogOpen] = useState(false)
|
||||
const [isReopening, setIsReopening] = useState(false)
|
||||
const [pauseReason, setPauseReason] = useState<string>(PAUSE_REASONS[0]?.value ?? "NO_CONTACT")
|
||||
const [pauseNote, setPauseNote] = useState("")
|
||||
const [pausing, setPausing] = useState(false)
|
||||
|
|
@ -326,8 +343,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId])
|
||||
const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id])
|
||||
const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty || requesterDirty
|
||||
const assigneeReasonRequired = assigneeDirty && !isManager
|
||||
const assigneeReasonValid = !assigneeReasonRequired || assigneeChangeReason.trim().length >= 5
|
||||
const normalizedAssigneeReason = assigneeChangeReason.trim()
|
||||
const assigneeReasonValid = normalizedAssigneeReason.length === 0 || normalizedAssigneeReason.length >= 5
|
||||
const saveDisabled = !formDirty || saving || !assigneeReasonValid
|
||||
const companyLabel = useMemo(() => {
|
||||
if (ticket.company?.name) return ticket.company.name
|
||||
|
|
@ -488,9 +505,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
throw new Error("assignee-not-allowed")
|
||||
}
|
||||
const reasonValue = assigneeChangeReason.trim()
|
||||
if (reasonValue.length < 5) {
|
||||
setAssigneeReasonError("Descreva o motivo com pelo menos 5 caracteres.")
|
||||
toast.error("Informe um motivo para registrar a troca do responsável.", { id: "assignee" })
|
||||
if (reasonValue.length > 0 && reasonValue.length < 5) {
|
||||
setAssigneeReasonError("Descreva o motivo com pelo menos 5 caracteres ou deixe em branco.")
|
||||
toast.error("Informe ao menos 5 caracteres no motivo ou deixe o campo vazio.", { id: "assignee" })
|
||||
return
|
||||
}
|
||||
if (reasonValue.length > 1000) {
|
||||
|
|
@ -505,7 +522,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
ticketId: ticket.id as Id<"tickets">,
|
||||
assigneeId: assigneeSelection as Id<"users">,
|
||||
actorId: convexUserId as Id<"users">,
|
||||
reason: reasonValue,
|
||||
reason: reasonValue.length > 0 ? reasonValue : undefined,
|
||||
})
|
||||
toast.success("Responsável atualizado!", { id: "assignee" })
|
||||
if (assigneeSelection) {
|
||||
|
|
@ -1008,6 +1025,26 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
}
|
||||
}, [ticket.id, ticket.reference])
|
||||
|
||||
const handleReopenTicket = useCallback(async () => {
|
||||
if (!viewerId) {
|
||||
toast.error("Não foi possível identificar o usuário atual.")
|
||||
return
|
||||
}
|
||||
toast.dismiss("ticket-reopen")
|
||||
setIsReopening(true)
|
||||
toast.loading("Reabrindo ticket...", { id: "ticket-reopen" })
|
||||
try {
|
||||
await reopenTicket({ ticketId: ticket.id as Id<"tickets">, actorId: viewerId as Id<"users"> })
|
||||
toast.success("Ticket reaberto com sucesso!", { id: "ticket-reopen" })
|
||||
setStatus("AWAITING_ATTENDANCE")
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível reabrir o ticket.", { id: "ticket-reopen" })
|
||||
} finally {
|
||||
setIsReopening(false)
|
||||
}
|
||||
}, [reopenTicket, ticket.id, viewerId])
|
||||
|
||||
return (
|
||||
<div className={cardClass}>
|
||||
<div className="absolute right-6 top-6 flex items-center gap-3">
|
||||
|
|
@ -1065,6 +1102,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
ticketId={ticket.id as unknown as string}
|
||||
tenantId={ticket.tenantId}
|
||||
actorId={convexUserId as Id<"users"> | null}
|
||||
ticketReference={ticket.reference ?? null}
|
||||
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
|
||||
agentName={agentName}
|
||||
workSummary={
|
||||
|
|
@ -1095,9 +1133,26 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
value={status}
|
||||
tenantId={ticket.tenantId}
|
||||
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
|
||||
ticketReference={ticket.reference ?? null}
|
||||
showCloseButton={false}
|
||||
onStatusChange={setStatus}
|
||||
/>
|
||||
{canReopenTicket ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white text-sm font-semibold text-neutral-700 hover:bg-slate-50"
|
||||
onClick={handleReopenTicket}
|
||||
disabled={isReopening}
|
||||
>
|
||||
{isReopening ? <Spinner className="size-4 text-neutral-600" /> : null}
|
||||
Reabrir
|
||||
</Button>
|
||||
) : null}
|
||||
{canReopenTicket && reopenDeadlineLabel ? (
|
||||
<p className="text-xs text-neutral-500">Prazo para reabrir: {reopenDeadlineLabel}</p>
|
||||
) : null}
|
||||
{isPlaying ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -1427,8 +1482,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
</p>
|
||||
{assigneeReasonError ? (
|
||||
<p className="text-xs font-semibold text-rose-600">{assigneeReasonError}</p>
|
||||
) : assigneeReasonRequired && assigneeChangeReason.trim().length < 5 ? (
|
||||
<p className="text-xs font-semibold text-rose-600">Descreva o motivo com pelo menos 5 caracteres.</p>
|
||||
) : normalizedAssigneeReason.length > 0 && normalizedAssigneeReason.length < 5 ? (
|
||||
<p className="text-xs font-semibold text-rose-600">Descreva o motivo com pelo menos 5 caracteres ou deixe em branco.</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -351,17 +351,55 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
|||
message = "CSAT recebido"
|
||||
}
|
||||
if (entry.type === "CSAT_RATED") {
|
||||
const score = typeof payload.score === "number" ? payload.score : payload.rating
|
||||
const maxScore =
|
||||
typeof payload.maxScore === "number"
|
||||
? payload.maxScore
|
||||
: typeof payload.max === "number"
|
||||
? payload.max
|
||||
const rawScoreSource = (payload as { score?: unknown; rating?: unknown }) ?? {}
|
||||
const rawScore =
|
||||
typeof rawScoreSource.score === "number"
|
||||
? rawScoreSource.score
|
||||
: typeof rawScoreSource.rating === "number"
|
||||
? rawScoreSource.rating
|
||||
: null
|
||||
const rawMaxSource = (payload as { maxScore?: unknown; max?: unknown }) ?? {}
|
||||
const rawMax =
|
||||
typeof rawMaxSource.maxScore === "number"
|
||||
? rawMaxSource.maxScore
|
||||
: typeof rawMaxSource.max === "number"
|
||||
? rawMaxSource.max
|
||||
: undefined
|
||||
message =
|
||||
typeof score === "number"
|
||||
? `CSAT avaliado: ${score}${typeof maxScore === "number" ? `/${maxScore}` : ""}`
|
||||
: "CSAT avaliado"
|
||||
const safeMax = rawMax && Number.isFinite(rawMax) && rawMax > 0 ? Math.round(rawMax) : 5
|
||||
const safeScore =
|
||||
typeof rawScore === "number" && Number.isFinite(rawScore)
|
||||
? Math.max(1, Math.min(safeMax, Math.round(rawScore)))
|
||||
: null
|
||||
const rawComment = (payload as { comment?: unknown })?.comment
|
||||
const comment =
|
||||
typeof rawComment === "string" && rawComment.trim().length > 0
|
||||
? rawComment.trim()
|
||||
: null
|
||||
message = (
|
||||
<div className="space-y-1">
|
||||
<span>
|
||||
CSAT avaliado:{" "}
|
||||
<span className="font-semibold text-neutral-900">
|
||||
{safeScore ?? "—"}/{safeMax}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-amber-500">
|
||||
{Array.from({ length: safeMax }).map((_, index) => (
|
||||
<IconStar
|
||||
key={index}
|
||||
className="size-3.5"
|
||||
strokeWidth={1.5}
|
||||
fill={safeScore !== null && index < safeScore ? "currentColor" : "none"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{comment ? (
|
||||
<span className="block rounded-lg bg-slate-100 px-3 py-1 text-xs text-neutral-600">
|
||||
“{comment}”
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!message) return null
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue