feat: melhorias no vínculo de tickets e exportação
This commit is contained in:
parent
1b32638eb5
commit
9495b54a28
7 changed files with 226 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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 [summary, setSummary] = useState("")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 : "")
|
||||
|
|
|
|||
|
|
@ -17,4 +17,5 @@ export const TICKET_TIMELINE_LABELS: Record<string, string> = {
|
|||
VISIT_SCHEDULED: "Visita agendada",
|
||||
CSAT_RECEIVED: "CSAT recebido",
|
||||
CSAT_RATED: "CSAT avaliado",
|
||||
TICKET_LINKED: "Chamado vinculado",
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue