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:
parent
91ac6c416c
commit
3396e930d4
10 changed files with 704 additions and 78 deletions
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue