feat: melhorias no vínculo de tickets e exportação

This commit is contained in:
Esdras Renan 2025-11-06 13:07:01 -03:00
parent 1b32638eb5
commit 9495b54a28
7 changed files with 226 additions and 16 deletions

View file

@ -131,7 +131,8 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
},
mode: "onTouched",
})
const { convexUserId, isStaff, role } = useAuth()
const { convexUserId, isStaff, role, session, machineContext } = useAuth()
const sessionUser = session?.user ?? null
const queuesEnabled = Boolean(isStaff && convexUserId)
useDefaultQueues(DEFAULT_TENANT_ID)
const queuesRemote = useQuery(
@ -220,10 +221,28 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
api.users.listCustomers,
directoryQueryEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
)
const customers = useMemo(
const rawCustomers = useMemo(
() => (Array.isArray(customersRemote) ? (customersRemote as CustomerOption[]) : []),
[customersRemote]
)
const viewerCustomer = useMemo<CustomerOption | null>(() => {
if (!convexUserId || !sessionUser) return null
return {
id: convexUserId,
name: sessionUser.name ?? sessionUser.email,
email: sessionUser.email,
role: sessionUser.role ?? "customer",
companyId: machineContext?.companyId ?? null,
companyName: null,
companyIsAvulso: false,
avatarUrl: sessionUser.avatarUrl,
}
}, [convexUserId, sessionUser, machineContext?.companyId])
const customers = useMemo(() => {
if (!viewerCustomer) return rawCustomers
const exists = rawCustomers.some((customer) => customer.id === viewerCustomer.id)
return exists ? rawCustomers : [...rawCustomers, viewerCustomer]
}, [rawCustomers, viewerCustomer])
const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
const [customersInitialized, setCustomersInitialized] = useState(false)
const attachmentsTotalBytes = useMemo(

View file

@ -1,9 +1,10 @@
"use client"
import Link from "next/link"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconClock, IconDownload, IconPlayerPause, IconPlayerPlay, IconPencil } from "@tabler/icons-react"
import { IconClock, IconDownload, IconLink, IconPlayerPause, IconPlayerPlay, IconPencil } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
@ -283,6 +284,39 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}, [selectedCategoryId, selectedSubcategoryId, currentCategoryId, currentSubcategoryId])
const currentQueueName = ticket.queue ?? ""
const isAvulso = Boolean(((ticket.company ?? null) as { isAvulso?: boolean } | null)?.isAvulso ?? false)
const resolvedWithSummary = useMemo(() => {
if (!ticket.resolvedWithTicketId) return null
const linkedId = String(ticket.resolvedWithTicketId)
let reference: string | null = null
let subject: string | null = null
for (const event of ticket.timeline ?? []) {
if (!event || event.type !== "TICKET_LINKED") continue
const payload = (event.payload ?? {}) as {
linkedTicketId?: unknown
linkedReference?: unknown
linkedSubject?: unknown
}
if (String(payload.linkedTicketId ?? "") !== linkedId) continue
if (reference === null) {
const rawReference = payload.linkedReference
if (typeof rawReference === "number") {
reference = rawReference.toString()
} else if (typeof rawReference === "string" && rawReference.trim().length > 0) {
reference = rawReference.trim()
}
}
if (subject === null) {
const rawSubject = payload.linkedSubject
if (typeof rawSubject === "string" && rawSubject.trim().length > 0) {
subject = rawSubject.trim()
}
}
if (reference !== null && subject !== null) {
break
}
}
return { id: linkedId, reference, subject }
}, [ticket.resolvedWithTicketId, ticket.timeline])
const [queueSelection, setQueueSelection] = useState(currentQueueName)
const queueDirty = useMemo(() => queueSelection !== currentQueueName, [queueSelection, currentQueueName])
const currentRequesterRecord = useMemo(
@ -1152,6 +1186,23 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
) : null}
{canReopenTicket && reopenDeadlineLabel ? (
<p className="text-xs text-neutral-500">Prazo para reabrir: {reopenDeadlineLabel}</p>
) : null}
{resolvedWithSummary ? (
<Link
href={`/tickets/${resolvedWithSummary.id}`}
className="flex items-center gap-3 rounded-2xl border border-primary/20 bg-primary/5 px-4 py-3 text-sm font-semibold text-primary-700 transition hover:border-primary hover:bg-primary/10 hover:text-primary-800"
>
<span className="flex size-9 items-center justify-center rounded-full border border-primary/30 bg-white text-primary">
<IconLink className="size-5" strokeWidth={1.7} />
</span>
<span className="flex flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-primary/70">Chamado vinculado</span>
<span className="text-sm font-semibold text-neutral-900">
{resolvedWithSummary.reference ? `#${resolvedWithSummary.reference}` : "Ver ticket"}
{resolvedWithSummary.subject ? ` · ${resolvedWithSummary.subject}` : ""}
</span>
</span>
</Link>
) : null}
{isPlaying ? (
<Tooltip>

View file

@ -1,3 +1,4 @@
import Link from "next/link"
import { useEffect, useMemo, useState, type ReactNode, type ComponentType } from "react"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
@ -10,6 +11,7 @@ import {
IconSquareCheck,
IconStar,
IconUserCircle,
IconLink,
} from "@tabler/icons-react"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
@ -46,6 +48,7 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
VISIT_SCHEDULED: IconCalendar,
CSAT_RECEIVED: IconStar,
CSAT_RATED: IconStar,
TICKET_LINKED: IconLink,
}
const timelineLabels: Record<string, string> = TICKET_TIMELINE_LABELS
@ -248,11 +251,103 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
}
let message: ReactNode = null
if (entry.type === "TICKET_LINKED") {
const payloadLink = payload as {
linkedTicketId?: string
linkedReference?: number | string | null
linkedSubject?: string | null
kind?: string | null
}
const linkedTicketId =
typeof payloadLink.linkedTicketId === "string" && payloadLink.linkedTicketId.trim().length > 0
? payloadLink.linkedTicketId.trim()
: null
const referenceValue = payloadLink.linkedReference
const referenceLabelRaw =
typeof referenceValue === "number"
? referenceValue.toString()
: typeof referenceValue === "string"
? referenceValue.trim()
: null
const referenceLabel = referenceLabelRaw && referenceLabelRaw.length > 0 ? referenceLabelRaw : null
const linkContent = referenceLabel ? `#${referenceLabel}` : "Ver ticket"
const subject =
typeof payloadLink.linkedSubject === "string" && payloadLink.linkedSubject.trim().length > 0
? payloadLink.linkedSubject.trim()
: null
const kindRaw =
typeof payloadLink.kind === "string" && payloadLink.kind.trim().length > 0
? payloadLink.kind.trim()
: "related"
const labelPrefix =
kindRaw === "resolution_parent"
? "Ticket vinculado a este chamado"
: kindRaw === "resolved_with"
? "Ticket resolvido com"
: "Ticket vinculado"
message = (
<div className="space-y-1">
<span className="block text-sm text-neutral-600">
<span className="font-semibold text-neutral-800">{labelPrefix}</span>{" "}
{linkedTicketId ? (
<Link
href={`/tickets/${linkedTicketId}`}
className="font-semibold text-primary transition hover:text-primary/80 hover:underline"
>
{linkContent}
</Link>
) : (
<span className="font-medium text-neutral-800">{linkContent}</span>
)}
{subject ? <span className="text-neutral-500"> {subject}</span> : null}
</span>
</div>
)
}
if (entry.type === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
message = "Status alterado para " + (payload.toLabel || payload.to)
}
if (entry.type === "ASSIGNEE_CHANGED" && (payload.assigneeName || payload.assigneeId)) {
message = "Responsável alterado" + (payload.assigneeName ? " para " + payload.assigneeName : "")
if (entry.type === "ASSIGNEE_CHANGED") {
const nextAssigneeRaw = payload.assigneeName ?? payload.assigneeId ?? null
const previousAssigneeRaw = (payload as { previousAssigneeName?: unknown }).previousAssigneeName ?? null
const nextAssignee =
typeof nextAssigneeRaw === "string" && nextAssigneeRaw.trim().length > 0
? nextAssigneeRaw.trim()
: typeof nextAssigneeRaw === "number"
? String(nextAssigneeRaw)
: null
const previousAssignee =
typeof previousAssigneeRaw === "string" && previousAssigneeRaw.trim().length > 0
? previousAssigneeRaw.trim()
: null
const reasonRaw = (payload as { reason?: unknown }).reason
const reason =
typeof reasonRaw === "string" && reasonRaw.trim().length > 0
? reasonRaw.trim()
: null
message = (
<div className="space-y-1">
<span className="block text-sm text-neutral-600">
<span className="font-semibold text-neutral-800">Responsável alterado</span>
{nextAssignee ? (
<>
{" "}
para <span className="font-semibold text-neutral-900">{nextAssignee}</span>
</>
) : (
<>.</>
)}
{previousAssignee ? (
<span className="text-neutral-500"> (antes: {previousAssignee})</span>
) : null}
</span>
{reason ? (
<p className="whitespace-pre-line rounded-lg bg-slate-100 px-3 py-2 text-xs text-neutral-600">
{reason}
</p>
) : null}
</div>
)
}
if (entry.type === "QUEUE_CHANGED" && (payload.queueName || payload.queueId)) {
message = "Fila alterada" + (payload.queueName ? " para " + payload.queueName : "")