feat(frontend): implementar paginacao numerada em listagens de tickets

- Adiciona tickets.listPaginated no backend com paginacao nativa Convex
- Converte TicketsView para usePaginatedQuery com controles numerados
- Converte PortalTicketList para usePaginatedQuery com controles numerados
- Atualiza tauri e @tauri-apps/api para versao 2.9

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
rever-tecnologia 2025-12-09 20:17:22 -03:00
parent 91ac6c416c
commit 3396e930d4
10 changed files with 704 additions and 78 deletions

View file

@ -2,7 +2,7 @@
import { useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import { useQuery } from "convex/react"
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"
@ -14,9 +14,14 @@ 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 } from "lucide-react"
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>
@ -74,19 +79,37 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
)
const queues: TicketQueueSummary[] = Array.isArray(queuesResult) ? queuesResult : []
const agents = useQuery(api.users.listAgents, { tenantId }) as { _id: string; name: string }[] | undefined
const ticketsArgs = convexUserId
? {
tenantId,
viewerId: convexUserId as Id<"users">,
status: filters.status ?? undefined,
priority: filters.priority ?? undefined,
channel: filters.channel ?? undefined,
queueId: undefined, // simplified: filter by queue name on client
assigneeId: filters.assigneeId ? (filters.assigneeId as unknown as Id<"users">) : undefined,
search: filters.search || undefined,
}
: "skip"
const ticketsRaw = useQuery(api.tickets.list, ticketsArgs)
// 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[]) : []),
@ -202,8 +225,26 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
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(() => {
if (ticketsRaw === undefined) {
setCurrentPage(1)
}, [filters])
useEffect(() => {
if (isLoadingFirstPage) {
previousIdsRef.current = []
setEnteringIds(new Set())
return
@ -218,7 +259,7 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
setEnteringIds(highlight)
const timeout = window.setTimeout(() => setEnteringIds(new Set()), 600)
return () => window.clearTimeout(timeout)
}, [filteredTickets, ticketsRaw])
}, [filteredTickets, isLoadingFirstPage])
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
@ -268,26 +309,174 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
</button>
</div>
</div>
{ticketsRaw === undefined ? (
{isLoadingFirstPage ? (
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="grid gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center justify-between gap-3">
<div className="h-4 w-48 animate-pulse rounded bg-slate-100" />
<div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
</div>
))}
<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={filteredTickets} enteringIds={enteringIds} />
<TicketsBoard tickets={paginatedTickets} enteringIds={enteringIds} />
) : (
<TicketsTable tickets={filteredTickets} 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))