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>
503 lines
17 KiB
TypeScript
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()
|
|
}
|