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

@ -1777,7 +1777,15 @@ export const create = mutation({
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId: id, ticketId: id,
type: "ASSIGNEE_CHANGED", type: "ASSIGNEE_CHANGED",
payload: { assigneeId: initialAssigneeId, assigneeName: initialAssignee.name, actorId: args.actorId }, payload: {
assigneeId: initialAssigneeId,
assigneeName: initialAssignee.name,
actorId: args.actorId,
actorName: actorUser.name,
actorAvatar: actorUser.avatarUrl ?? undefined,
previousAssigneeId: null,
previousAssigneeName: "Não atribuído",
},
createdAt: now, createdAt: now,
}) })
} }
@ -2336,6 +2344,8 @@ export const changeAssignee = mutation({
assigneeId, assigneeId,
assigneeName: assignee.name, assigneeName: assignee.name,
actorId, actorId,
actorName: viewerUser.name,
actorAvatar: viewerUser.avatarUrl ?? undefined,
previousAssigneeId: currentAssigneeId, previousAssigneeId: currentAssigneeId,
previousAssigneeName, previousAssigneeName,
reason: normalizedReason.length > 0 ? normalizedReason : undefined, reason: normalizedReason.length > 0 ? normalizedReason : undefined,
@ -3170,6 +3180,9 @@ export const startWork = mutation({
} }
let assigneePatched = false let assigneePatched = false
const previousAssigneeIdForStart = currentAssigneeId
const previousAssigneeNameForStart =
((ticketDoc.assigneeSnapshot as { name?: string } | null)?.name as string | undefined) ?? "Não atribuído"
if (!currentAssigneeId) { if (!currentAssigneeId) {
const assigneeSnapshot = { const assigneeSnapshot = {
@ -3201,7 +3214,15 @@ export const startWork = mutation({
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId, ticketId,
type: "ASSIGNEE_CHANGED", type: "ASSIGNEE_CHANGED",
payload: { assigneeId: actorId, assigneeName: viewer.user.name, actorId }, payload: {
assigneeId: actorId,
assigneeName: viewer.user.name,
actorId,
actorName: viewer.user.name,
actorAvatar: viewer.user.avatarUrl ?? undefined,
previousAssigneeId: previousAssigneeIdForStart,
previousAssigneeName: previousAssigneeNameForStart,
},
createdAt: now, createdAt: now,
}) })
} }

View file

@ -1,4 +1,4 @@
import { NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import type { Browser } from "playwright" import type { Browser } from "playwright"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
@ -36,8 +36,12 @@ function buildDisposition(filename: string) {
return `attachment; filename="${filename}"; filename*=UTF-8''${encoded}` return `attachment; filename="${filename}"; filename*=UTF-8''${encoded}`
} }
export async function GET(request: Request, { params }: { params: { id: string; format: string } }) { export async function GET(
const formatParam = params.format?.toLowerCase() request: NextRequest,
context: { params: Promise<{ id: string; format: string }> },
) {
const { id, format } = await context.params
const formatParam = format?.toLowerCase()
if (!isSupportedFormat(formatParam)) { if (!isSupportedFormat(formatParam)) {
return NextResponse.json({ error: "Formato não suportado" }, { status: 400 }) return NextResponse.json({ error: "Formato não suportado" }, { status: 400 })
} }
@ -74,10 +78,10 @@ export async function GET(request: Request, { params }: { params: { id: string;
detail = await convex.query(api.dashboards.get, { detail = await convex.query(api.dashboards.get, {
tenantId, tenantId,
viewerId, viewerId,
dashboardId: params.id as Id<"dashboards">, dashboardId: id as Id<"dashboards">,
}) })
} catch (error) { } catch (error) {
console.error("[dashboards.export] Falha ao obter dashboard", error, { tenantId, dashboardId: params.id }) console.error("[dashboards.export] Falha ao obter dashboard", error, { tenantId, dashboardId: id })
return NextResponse.json({ error: "Não foi possível carregar o dashboard" }, { status: 500 }) return NextResponse.json({ error: "Não foi possível carregar o dashboard" }, { status: 500 })
} }
@ -85,8 +89,8 @@ export async function GET(request: Request, { params }: { params: { id: string;
return NextResponse.json({ error: "Dashboard não encontrado" }, { status: 404 }) return NextResponse.json({ error: "Dashboard não encontrado" }, { status: 404 })
} }
const requestUrl = new URL(request.url) const requestUrl = request.nextUrl
const printUrl = new URL(`/dashboards/${params.id}/print`, requestUrl.origin).toString() const printUrl = new URL(`/dashboards/${id}/print`, requestUrl.origin).toString()
const waitForSelector = detail.dashboard.readySelector ?? WAIT_SELECTOR_DEFAULT const waitForSelector = detail.dashboard.readySelector ?? WAIT_SELECTOR_DEFAULT
const width = Number(requestUrl.searchParams.get("width") ?? DEFAULT_VIEWPORT_WIDTH) || DEFAULT_VIEWPORT_WIDTH const width = Number(requestUrl.searchParams.get("width") ?? DEFAULT_VIEWPORT_WIDTH) || DEFAULT_VIEWPORT_WIDTH
const height = Number(requestUrl.searchParams.get("height") ?? DEFAULT_VIEWPORT_HEIGHT) || DEFAULT_VIEWPORT_HEIGHT const height = Number(requestUrl.searchParams.get("height") ?? DEFAULT_VIEWPORT_HEIGHT) || DEFAULT_VIEWPORT_HEIGHT

View file

@ -44,7 +44,8 @@ const NO_REQUESTER_VALUE = "__no_requester__"
export default function NewTicketPage() { export default function NewTicketPage() {
const router = useRouter() const router = useRouter()
const { convexUserId, isStaff, role } = useAuth() const { convexUserId, isStaff, role, session, machineContext } = useAuth()
const sessionUser = session?.user ?? null
const queuesEnabled = Boolean(isStaff && convexUserId) const queuesEnabled = Boolean(isStaff && convexUserId)
const queuesRemote = useQuery( const queuesRemote = useQuery(
api.queues.summary, api.queues.summary,
@ -81,10 +82,28 @@ export default function NewTicketPage() {
api.users.listCustomers, api.users.listCustomers,
directoryQueryEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip" directoryQueryEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
) )
const customers = useMemo( const rawCustomers = useMemo(
() => (Array.isArray(customersRemote) ? (customersRemote as CustomerOption[]) : []), () => (Array.isArray(customersRemote) ? (customersRemote as CustomerOption[]) : []),
[customersRemote] [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 [subject, setSubject] = useState("") const [subject, setSubject] = useState("")
const [summary, setSummary] = useState("") const [summary, setSummary] = useState("")

View file

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

View file

@ -1,9 +1,10 @@
"use client" "use client"
import Link from "next/link"
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { format, formatDistanceToNow } from "date-fns" import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale" 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 { useMutation, useQuery } from "convex/react"
import { toast } from "sonner" import { toast } from "sonner"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
@ -283,6 +284,39 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}, [selectedCategoryId, selectedSubcategoryId, currentCategoryId, currentSubcategoryId]) }, [selectedCategoryId, selectedSubcategoryId, currentCategoryId, currentSubcategoryId])
const currentQueueName = ticket.queue ?? "" const currentQueueName = ticket.queue ?? ""
const isAvulso = Boolean(((ticket.company ?? null) as { isAvulso?: boolean } | null)?.isAvulso ?? false) 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 [queueSelection, setQueueSelection] = useState(currentQueueName)
const queueDirty = useMemo(() => queueSelection !== currentQueueName, [queueSelection, currentQueueName]) const queueDirty = useMemo(() => queueSelection !== currentQueueName, [queueSelection, currentQueueName])
const currentRequesterRecord = useMemo( const currentRequesterRecord = useMemo(
@ -1152,6 +1186,23 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
) : null} ) : null}
{canReopenTicket && reopenDeadlineLabel ? ( {canReopenTicket && reopenDeadlineLabel ? (
<p className="text-xs text-neutral-500">Prazo para reabrir: {reopenDeadlineLabel}</p> <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} ) : null}
{isPlaying ? ( {isPlaying ? (
<Tooltip> <Tooltip>

View file

@ -1,3 +1,4 @@
import Link from "next/link"
import { useEffect, useMemo, useState, type ReactNode, type ComponentType } from "react" import { useEffect, useMemo, useState, type ReactNode, type ComponentType } from "react"
import { format } from "date-fns" import { format } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
@ -10,6 +11,7 @@ import {
IconSquareCheck, IconSquareCheck,
IconStar, IconStar,
IconUserCircle, IconUserCircle,
IconLink,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import type { TicketWithDetails } from "@/lib/schemas/ticket" import type { TicketWithDetails } from "@/lib/schemas/ticket"
@ -46,6 +48,7 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
VISIT_SCHEDULED: IconCalendar, VISIT_SCHEDULED: IconCalendar,
CSAT_RECEIVED: IconStar, CSAT_RECEIVED: IconStar,
CSAT_RATED: IconStar, CSAT_RATED: IconStar,
TICKET_LINKED: IconLink,
} }
const timelineLabels: Record<string, string> = TICKET_TIMELINE_LABELS const timelineLabels: Record<string, string> = TICKET_TIMELINE_LABELS
@ -248,11 +251,103 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
} }
let message: ReactNode = null 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)) { if (entry.type === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
message = "Status alterado para " + (payload.toLabel || payload.to) message = "Status alterado para " + (payload.toLabel || payload.to)
} }
if (entry.type === "ASSIGNEE_CHANGED" && (payload.assigneeName || payload.assigneeId)) { if (entry.type === "ASSIGNEE_CHANGED") {
message = "Responsável alterado" + (payload.assigneeName ? " para " + payload.assigneeName : "") 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)) { if (entry.type === "QUEUE_CHANGED" && (payload.queueName || payload.queueId)) {
message = "Fila alterada" + (payload.queueName ? " para " + payload.queueName : "") message = "Fila alterada" + (payload.queueName ? " para " + payload.queueName : "")

View file

@ -17,4 +17,5 @@ export const TICKET_TIMELINE_LABELS: Record<string, string> = {
VISIT_SCHEDULED: "Visita agendada", VISIT_SCHEDULED: "Visita agendada",
CSAT_RECEIVED: "CSAT recebido", CSAT_RECEIVED: "CSAT recebido",
CSAT_RATED: "CSAT avaliado", CSAT_RATED: "CSAT avaliado",
TICKET_LINKED: "Chamado vinculado",
}; };