sistema-de-chamados/src/components/tickets/tickets-view.tsx
esdrasrenan 638faeb287 fix(convex): corrigir memory leak com .collect() sem limite e adicionar otimizacoes
Problema: Convex backend consumindo 16GB+ de RAM causando OOM kills

Correcoes aplicadas:
- Substituido todos os .collect() por .take(LIMIT) em 27+ arquivos
- Adicionado indice by_usbPolicyStatus para otimizar query de maquinas
- Corrigido N+1 problem em alerts.ts usando Map lookup
- Corrigido full table scan em usbPolicy.ts
- Corrigido subscription leaks no frontend (tickets-view, use-ticket-categories)
- Atualizado versao do Convex backend para precompiled-2025-12-04-cc6af4c

Arquivos principais modificados:
- convex/*.ts - limites em todas as queries .collect()
- convex/schema.ts - novo indice by_usbPolicyStatus
- convex/alerts.ts - N+1 fix com Map
- convex/usbPolicy.ts - uso do novo indice
- src/components/tickets/tickets-view.tsx - skip condicional
- src/hooks/use-ticket-categories.ts - skip condicional

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 21:41:30 -03:00

503 lines
17 KiB
TypeScript

"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import { usePaginatedQuery, useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import type { Ticket, TicketQueueSummary } from "@/lib/schemas/ticket"
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
import { TicketsTable } from "@/components/tickets/tickets-table"
import { TicketsBoard } from "@/components/tickets/tickets-board"
import { useAuth } from "@/lib/auth-client"
import { useDefaultQueues } from "@/hooks/use-default-queues"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { LayoutGrid, List, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"
import { isVisitTicket } from "@/lib/ticket-matchers"
import { useTicketCategories } from "@/hooks/use-ticket-categories"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
/** Número de tickets por página */
const TICKETS_PER_PAGE = 25
type TicketsViewProps = {
initialFilters?: Partial<TicketFiltersState>
}
export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
const mergedInitialFilters = useMemo(
() => ({
...defaultTicketFilters,
...(initialFilters ?? {}),
}),
[initialFilters]
)
const [filters, setFilters] = useState<TicketFiltersState>(mergedInitialFilters)
const [viewMode, setViewMode] = useState<"table" | "board">("table")
useEffect(() => {
setFilters(mergedInitialFilters)
}, [mergedInitialFilters])
const { session, convexUserId, isStaff, role } = useAuth()
const userId = session?.user?.id ?? null
const tenantId = session?.user?.tenantId ?? DEFAULT_TENANT_ID
const viewModeStorageKey = useMemo(() => {
const userKey = userId ?? (convexUserId ? String(convexUserId) : "anonymous")
return `tickets:view-mode:${tenantId}:${userKey}`
}, [tenantId, userId, convexUserId])
useEffect(() => {
try {
const stored = localStorage.getItem(viewModeStorageKey)
if (stored === "table" || stored === "board") {
setViewMode(stored)
}
} catch {
// ignore
}
}, [viewModeStorageKey])
useEffect(() => {
try {
localStorage.setItem(viewModeStorageKey, viewMode)
} catch {
// ignore
}
}, [viewMode, viewModeStorageKey])
useDefaultQueues(tenantId)
const { categories: ticketCategories } = useTicketCategories(tenantId)
const queuesEnabled = Boolean(isStaff && convexUserId)
const queuesResult = useQuery(
api.queues.summary,
queuesEnabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
)
const queues: TicketQueueSummary[] = Array.isArray(queuesResult) ? queuesResult : []
const agents = useQuery(
api.users.listAgents,
isStaff && convexUserId ? { tenantId } : "skip"
) as { _id: string; name: string }[] | undefined
// Argumentos para a query paginada de tickets
const ticketsArgs = useMemo(() => {
if (!convexUserId) return "skip" as const
return {
tenantId,
viewerId: convexUserId as Id<"users">,
status: filters.status ?? undefined,
priority: filters.priority ?? undefined,
channel: filters.channel ?? undefined,
queueId: undefined, // Filtro por nome da fila aplicado no cliente
assigneeId: filters.assigneeId ? (filters.assigneeId as unknown as Id<"users">) : undefined,
search: filters.search || undefined,
}
}, [convexUserId, tenantId, filters.status, filters.priority, filters.channel, filters.assigneeId, filters.search])
// Query paginada para evitar carregar todos os tickets de uma vez
const {
results: ticketsRaw,
status: paginationStatus,
loadMore,
} = usePaginatedQuery(
api.tickets.listPaginated,
ticketsArgs,
{ initialNumItems: TICKETS_PER_PAGE }
)
// Indicadores de estado da paginação
const isLoadingFirstPage = paginationStatus === "LoadingFirstPage"
const isLoadingMore = paginationStatus === "LoadingMore"
const canLoadMore = paginationStatus === "CanLoadMore"
const tickets = useMemo(
() => mapTicketsFromServerList(Array.isArray(ticketsRaw) ? (ticketsRaw as unknown[]) : []),
[ticketsRaw]
)
const [companies, setCompanies] = useState<string[]>([])
useEffect(() => {
let aborted = false
async function loadCompanies() {
try {
const r = await fetch("/api/admin/companies", { credentials: "include" })
const json = (await r.json().catch(() => ({}))) as { companies?: Array<{ name: string }> }
const names = Array.isArray(json.companies) ? json.companies.map((c) => c.name).filter(Boolean) : []
if (!aborted) setCompanies(names.sort((a, b) => a.localeCompare(b, "pt-BR")))
} catch {
if (!aborted) setCompanies([])
}
}
void loadCompanies()
return () => {
aborted = true
}
}, [])
useEffect(() => {
if (!convexUserId) return
try {
const key = `tickets:filters:${tenantId}:${String(convexUserId)}`
const saved = localStorage.getItem(key)
if (saved) {
const parsed = JSON.parse(saved) as Partial<TicketFiltersState>
setFilters((prev) => ({ ...prev, ...parsed }))
}
} catch {
// ignore
}
}, [tenantId, convexUserId])
const handleSaveDefault = () => {
if (!convexUserId) return
try {
const key = `tickets:filters:${tenantId}:${String(convexUserId)}`
localStorage.setItem(key, JSON.stringify(filters))
toast.success("Filtro salvo como padrão")
} catch {
// ignore
}
}
const handleClearDefault = () => {
if (!convexUserId) return
try {
const key = `tickets:filters:${tenantId}:${String(convexUserId)}`
localStorage.removeItem(key)
toast.success("Padrão de filtro limpo")
} catch {
// ignore
}
}
const filteredTickets = useMemo(() => {
const completedStatuses = new Set<Ticket["status"]>(["RESOLVED"])
let working = tickets
if (!filters.status) {
if (filters.view === "active") {
working = working.filter((t) => !completedStatuses.has(t.status))
} else if (filters.view === "completed") {
working = working.filter((t) => completedStatuses.has(t.status))
}
}
if (filters.queue) {
working = working.filter((t) => t.queue === filters.queue)
}
if (filters.company) {
working = working.filter((t) => (((t as unknown as { company?: { name?: string } })?.company?.name) ?? null) === filters.company)
}
if (filters.focusVisits) {
working = working.filter((t) => isVisitTicket(t))
}
if (filters.categoryId) {
working = working.filter((t) => (t.category?.id ?? null) === filters.categoryId)
}
const fromDate = parseDateInput(filters.dateFrom)
if (fromDate) {
working = working.filter((t) => getTicketDate(t) >= fromDate.getTime())
}
const toDate = parseDateInput(filters.dateTo, { endOfDay: true })
if (toDate) {
working = working.filter((t) => getTicketDate(t) <= toDate.getTime())
}
const sorted = [...working].sort((a, b) => {
const diff = getTicketDate(a) - getTicketDate(b)
return filters.sort === "oldest" ? diff : -diff
})
return sorted
}, [
tickets,
filters.queue,
filters.status,
filters.view,
filters.company,
filters.focusVisits,
filters.categoryId,
filters.dateFrom,
filters.dateTo,
filters.sort,
])
const previousIdsRef = useRef<string[]>([])
const [enteringIds, setEnteringIds] = useState<Set<string>>(new Set())
// Estado de paginação no cliente para a visão atual
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = TICKETS_PER_PAGE
const totalFilteredTickets = filteredTickets.length
const totalPages = Math.max(1, Math.ceil(totalFilteredTickets / itemsPerPage))
// Tickets da página atual (paginação no cliente sobre os resultados carregados)
const paginatedTickets = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage
const endIndex = startIndex + itemsPerPage
return filteredTickets.slice(startIndex, endIndex)
}, [filteredTickets, currentPage, itemsPerPage])
// Reseta para a primeira página quando os filtros mudam
useEffect(() => {
setCurrentPage(1)
}, [filters])
useEffect(() => {
if (isLoadingFirstPage) {
previousIdsRef.current = []
setEnteringIds(new Set())
return
}
const ids = filteredTickets.map((ticket) => ticket.id)
const previous = previousIdsRef.current
previousIdsRef.current = ids
if (!previous.length) return
const newIds = ids.filter((id) => !previous.includes(id))
if (newIds.length === 0) return
const highlight = new Set(newIds)
setEnteringIds(highlight)
const timeout = window.setTimeout(() => setEnteringIds(new Set()), 600)
return () => window.clearTimeout(timeout)
}, [filteredTickets, isLoadingFirstPage])
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketsFilters
onChange={setFilters}
queues={queues.map((q) => q.name)}
companies={companies}
assignees={(agents ?? []).map((a) => ({ id: a._id, name: a.name }))}
categories={ticketCategories.map((category) => ({ id: category.id, name: category.name }))}
initialState={mergedInitialFilters}
viewerRole={role}
/>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(next) => {
if (!next) return
setViewMode(next as "table" | "board")
}}
variant="outline"
className="inline-flex rounded-md border border-border/60 bg-muted/30"
>
<ToggleGroupItem value="table" aria-label="Listagem em tabela" className="min-w-[96px] justify-center gap-2">
<List className="size-4" />
<span>Tabela</span>
</ToggleGroupItem>
<ToggleGroupItem value="board" aria-label="Visão em quadro" className="min-w-[96px] justify-center gap-2">
<LayoutGrid className="size-4" />
<span>Quadro</span>
</ToggleGroupItem>
</ToggleGroup>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={handleSaveDefault}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-800 hover:bg-slate-50"
>
Salvar filtro como padrão
</button>
<button
type="button"
onClick={handleClearDefault}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-slate-50"
>
Limpar padrão
</button>
</div>
</div>
{isLoadingFirstPage ? (
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-center gap-2 py-12">
<Spinner className="size-5 text-neutral-500" />
<span className="text-sm text-neutral-600">Carregando tickets...</span>
</div>
</div>
) : viewMode === "board" ? (
<TicketsBoard tickets={paginatedTickets} enteringIds={enteringIds} />
) : (
<TicketsTable tickets={paginatedTickets} enteringIds={enteringIds} />
)}
{/* Controles de paginação numerada */}
{!isLoadingFirstPage && filteredTickets.length > 0 && (
<div className="flex flex-col items-center gap-4 rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm sm:flex-row sm:justify-between">
<div className="text-sm text-neutral-600">
Mostrando {((currentPage - 1) * itemsPerPage) + 1} a {Math.min(currentPage * itemsPerPage, totalFilteredTickets)} de {totalFilteredTickets} tickets
{canLoadMore && (
<span className="ml-1 text-neutral-400">(mais disponíveis)</span>
)}
</div>
<div className="flex items-center gap-1">
{/* Primeira página */}
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="h-8 w-8 p-0"
title="Primeira página"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
{/* Página anterior */}
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="h-8 w-8 p-0"
title="Página anterior"
>
<ChevronLeft className="h-4 w-4" />
</Button>
{/* Números das páginas */}
<div className="flex items-center gap-1 px-2">
{generatePageNumbers(currentPage, totalPages).map((pageNum, idx) =>
pageNum === "..." ? (
<span key={`ellipsis-${idx}`} className="px-2 text-neutral-400">
...
</span>
) : (
<Button
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(pageNum as number)}
className="h-8 min-w-[32px] px-2"
>
{pageNum}
</Button>
)
)}
</div>
{/* Próxima página */}
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="h-8 w-8 p-0"
title="Próxima página"
>
<ChevronRight className="h-4 w-4" />
</Button>
{/* Última página */}
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="h-8 w-8 p-0"
title="Última página"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
{/* Botão para carregar mais do servidor */}
{canLoadMore && currentPage === totalPages && (
<Button
variant="outline"
size="sm"
onClick={() => loadMore(TICKETS_PER_PAGE)}
disabled={isLoadingMore}
className="flex items-center gap-2"
>
{isLoadingMore ? (
<>
<Spinner className="size-4" />
Carregando...
</>
) : (
"Carregar mais tickets"
)}
</Button>
)}
</div>
)}
{/* Mensagem quando não há tickets */}
{!isLoadingFirstPage && filteredTickets.length === 0 && (
<div className="rounded-2xl border border-dashed border-slate-200 bg-white p-8 text-center shadow-sm">
<p className="text-sm text-neutral-500">
Nenhum ticket encontrado com os filtros selecionados.
</p>
</div>
)}
</div>
)
}
/**
* Gera array de números de página para exibição.
* Mostra primeira, última e páginas próximas à atual com reticências.
*/
function generatePageNumbers(currentPage: number, totalPages: number): (number | "...")[] {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1)
}
const pages: (number | "...")[] = []
// Sempre mostra a primeira página
pages.push(1)
if (currentPage > 3) {
pages.push("...")
}
// Páginas próximas à atual
const start = Math.max(2, currentPage - 1)
const end = Math.min(totalPages - 1, currentPage + 1)
for (let i = start; i <= end; i++) {
if (!pages.includes(i)) {
pages.push(i)
}
}
if (currentPage < totalPages - 2) {
pages.push("...")
}
// Sempre mostra a última página
if (!pages.includes(totalPages)) {
pages.push(totalPages)
}
return pages
}
function parseDateInput(value: string | null, options?: { endOfDay?: boolean }) {
if (!value) return null
const [year, month, day] = value.split("-").map((part) => Number(part))
if (!year || !month || !day) return null
const date = new Date(
year,
month - 1,
day,
options?.endOfDay ? 23 : 0,
options?.endOfDay ? 59 : 0,
options?.endOfDay ? 59 : 0,
options?.endOfDay ? 999 : 0
)
if (Number.isNaN(date.getTime())) return null
return date
}
function getTicketDate(ticket: Ticket) {
const createdAt = ticket.createdAt instanceof Date ? ticket.createdAt : new Date(ticket.createdAt)
return createdAt.getTime()
}