From 9495b54a282a16dc70dfaacd888d0b2eed7e040e Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Thu, 6 Nov 2025 13:07:01 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20melhorias=20no=20v=C3=ADnculo=20de=20ti?= =?UTF-8?q?ckets=20e=20exporta=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- convex/tickets.ts | 25 ++++- .../dashboards/[id]/export/[format]/route.ts | 18 ++-- src/app/tickets/new/page.tsx | 23 ++++- src/components/tickets/new-ticket-dialog.tsx | 23 ++++- .../tickets/ticket-summary-header.tsx | 53 +++++++++- src/components/tickets/ticket-timeline.tsx | 99 ++++++++++++++++++- src/lib/ticket-timeline-labels.ts | 1 + 7 files changed, 226 insertions(+), 16 deletions(-) diff --git a/convex/tickets.ts b/convex/tickets.ts index 1f00f49..cb73360 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -1777,7 +1777,15 @@ export const create = mutation({ await ctx.db.insert("ticketEvents", { ticketId: id, 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, }) } @@ -2336,6 +2344,8 @@ export const changeAssignee = mutation({ assigneeId, assigneeName: assignee.name, actorId, + actorName: viewerUser.name, + actorAvatar: viewerUser.avatarUrl ?? undefined, previousAssigneeId: currentAssigneeId, previousAssigneeName, reason: normalizedReason.length > 0 ? normalizedReason : undefined, @@ -3170,6 +3180,9 @@ export const startWork = mutation({ } let assigneePatched = false + const previousAssigneeIdForStart = currentAssigneeId + const previousAssigneeNameForStart = + ((ticketDoc.assigneeSnapshot as { name?: string } | null)?.name as string | undefined) ?? "Não atribuído" if (!currentAssigneeId) { const assigneeSnapshot = { @@ -3201,7 +3214,15 @@ export const startWork = mutation({ await ctx.db.insert("ticketEvents", { ticketId, 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, }) } diff --git a/src/app/api/dashboards/[id]/export/[format]/route.ts b/src/app/api/dashboards/[id]/export/[format]/route.ts index f6de355..b668288 100644 --- a/src/app/api/dashboards/[id]/export/[format]/route.ts +++ b/src/app/api/dashboards/[id]/export/[format]/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server" +import { NextRequest, NextResponse } from "next/server" import type { Browser } from "playwright" import { api } from "@/convex/_generated/api" @@ -36,8 +36,12 @@ function buildDisposition(filename: string) { return `attachment; filename="${filename}"; filename*=UTF-8''${encoded}` } -export async function GET(request: Request, { params }: { params: { id: string; format: string } }) { - const formatParam = params.format?.toLowerCase() +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string; format: string }> }, +) { + const { id, format } = await context.params + const formatParam = format?.toLowerCase() if (!isSupportedFormat(formatParam)) { 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, { tenantId, viewerId, - dashboardId: params.id as Id<"dashboards">, + dashboardId: id as Id<"dashboards">, }) } 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 }) } @@ -85,8 +89,8 @@ export async function GET(request: Request, { params }: { params: { id: string; return NextResponse.json({ error: "Dashboard não encontrado" }, { status: 404 }) } - const requestUrl = new URL(request.url) - const printUrl = new URL(`/dashboards/${params.id}/print`, requestUrl.origin).toString() + const requestUrl = request.nextUrl + const printUrl = new URL(`/dashboards/${id}/print`, requestUrl.origin).toString() const waitForSelector = detail.dashboard.readySelector ?? WAIT_SELECTOR_DEFAULT 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 diff --git a/src/app/tickets/new/page.tsx b/src/app/tickets/new/page.tsx index 8a18f66..c69804f 100644 --- a/src/app/tickets/new/page.tsx +++ b/src/app/tickets/new/page.tsx @@ -44,7 +44,8 @@ const NO_REQUESTER_VALUE = "__no_requester__" export default function NewTicketPage() { 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 queuesRemote = useQuery( api.queues.summary, @@ -81,10 +82,28 @@ export default function NewTicketPage() { 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(() => { + 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 [summary, setSummary] = useState("") diff --git a/src/components/tickets/new-ticket-dialog.tsx b/src/components/tickets/new-ticket-dialog.tsx index 54274a6..668240e 100644 --- a/src/components/tickets/new-ticket-dialog.tsx +++ b/src/components/tickets/new-ticket-dialog.tsx @@ -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(() => { + 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>([]) const [customersInitialized, setCustomersInitialized] = useState(false) const attachmentsTotalBytes = useMemo( diff --git a/src/components/tickets/ticket-summary-header.tsx b/src/components/tickets/ticket-summary-header.tsx index 2a0d49b..071957a 100644 --- a/src/components/tickets/ticket-summary-header.tsx +++ b/src/components/tickets/ticket-summary-header.tsx @@ -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 ? (

Prazo para reabrir: {reopenDeadlineLabel}

+ ) : null} + {resolvedWithSummary ? ( + + + + + + Chamado vinculado + + {resolvedWithSummary.reference ? `#${resolvedWithSummary.reference}` : "Ver ticket"} + {resolvedWithSummary.subject ? ` · ${resolvedWithSummary.subject}` : ""} + + + ) : null} {isPlaying ? ( diff --git a/src/components/tickets/ticket-timeline.tsx b/src/components/tickets/ticket-timeline.tsx index 4fde1bc..fc43e86 100644 --- a/src/components/tickets/ticket-timeline.tsx +++ b/src/components/tickets/ticket-timeline.tsx @@ -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> = { VISIT_SCHEDULED: IconCalendar, CSAT_RECEIVED: IconStar, CSAT_RATED: IconStar, + TICKET_LINKED: IconLink, } const timelineLabels: Record = 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 = ( +
+ + {labelPrefix}{" "} + {linkedTicketId ? ( + + {linkContent} + + ) : ( + {linkContent} + )} + {subject ? — {subject} : null} + +
+ ) + } 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 = ( +
+ + Responsável alterado + {nextAssignee ? ( + <> + {" "} + para {nextAssignee} + + ) : ( + <>. + )} + {previousAssignee ? ( + (antes: {previousAssignee}) + ) : null} + + {reason ? ( +

+ {reason} +

+ ) : null} +
+ ) } if (entry.type === "QUEUE_CHANGED" && (payload.queueName || payload.queueId)) { message = "Fila alterada" + (payload.queueName ? " para " + payload.queueName : "") diff --git a/src/lib/ticket-timeline-labels.ts b/src/lib/ticket-timeline-labels.ts index ed2be32..4580f3d 100644 --- a/src/lib/ticket-timeline-labels.ts +++ b/src/lib/ticket-timeline-labels.ts @@ -17,4 +17,5 @@ export const TICKET_TIMELINE_LABELS: Record = { VISIT_SCHEDULED: "Visita agendada", CSAT_RECEIVED: "CSAT recebido", CSAT_RATED: "CSAT avaliado", + TICKET_LINKED: "Chamado vinculado", };