From 3396e930d4a12ab4e5375747897cf531e0497456 Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Tue, 9 Dec 2025 20:17:22 -0300 Subject: [PATCH] feat(frontend): implementar paginacao numerada em listagens de tickets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.lock | 33 ++- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src/chat/convexMachineClient.ts | 17 +- apps/desktop/src/main.tsx | 9 +- bun.lock | 3 +- convex/tickets.ts | 255 +++++++++++++++++++ src/components/portal/portal-ticket-list.tsx | 216 ++++++++++++++-- src/components/tickets/tickets-view.tsx | 243 ++++++++++++++++-- stack.yml | 2 +- 10 files changed, 704 insertions(+), 78 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 345e4a7..00e9106 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -13,7 +13,7 @@ "dependencies": { "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", - "@tauri-apps/api": "^2", + "@tauri-apps/api": "^2.9.1", "@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index c73a23a..ecd824f 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -4369,9 +4369,9 @@ dependencies = [ [[package]] name = "tao" -version = "0.34.3" +version = "0.34.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7" +checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.9.4", "block2 0.6.2", @@ -4437,9 +4437,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.8.5" +version = "2.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d1d3b3dc4c101ac989fd7db77e045cc6d91a25349cd410455cb5c57d510c1c" +checksum = "8a3868da5508446a7cd08956d523ac3edf0a8bc20bf7e4038f9a95c2800d2033" dependencies = [ "anyhow", "bytes", @@ -4480,7 +4480,6 @@ dependencies = [ "tokio", "tray-icon", "url", - "urlpattern", "webkit2gtk", "webview2-com", "window-vibrancy", @@ -4489,9 +4488,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.4.1" +version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c432ccc9ff661803dab74c6cd78de11026a578a9307610bbc39d3c55be7943f" +checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08" dependencies = [ "anyhow", "cargo_toml", @@ -4511,9 +4510,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.4.0" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ab3a62cf2e6253936a8b267c2e95839674e7439f104fa96ad0025e149d54d8a" +checksum = "9fa9844cefcf99554a16e0a278156ae73b0d8680bbc0e2ad1e4287aadd8489cf" dependencies = [ "base64 0.22.1", "brotli", @@ -4538,9 +4537,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.4.0" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4368ea8094e7045217edb690f493b55b30caf9f3e61f79b4c24b6db91f07995e" +checksum = "3764a12f886d8245e66b7ee9b43ccc47883399be2019a61d80cf0f4117446fde" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -4689,9 +4688,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.8.0" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4cfc9ad45b487d3fded5a4731a567872a4812e9552e3964161b08edabf93846" +checksum = "87f766fe9f3d1efc4b59b17e7a891ad5ed195fa8d23582abb02e6c9a01137892" dependencies = [ "cookie", "dpi", @@ -4714,9 +4713,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.8.1" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fe9d48bd122ff002064e88cfcd7027090d789c4302714e68fcccba0f4b7807" +checksum = "187a3f26f681bdf028f796ccf57cf478c1ee422c50128e5a0a6ebeb3f5910065" dependencies = [ "gtk", "http", @@ -4741,9 +4740,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.7.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a3852fdf9a4f8fbeaa63dc3e9a85284dd6ef7200751f0bd66ceee30c93f212" +checksum = "76a423c51176eb3616ee9b516a9fa67fed5f0e78baaba680e44eb5dd2cc37490" dependencies = [ "anyhow", "brotli", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 7732e3c..750b7e4 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2.4.1", features = [] } [dependencies] -tauri = { version = "2.8.5", features = ["wry", "devtools", "tray-icon"] } +tauri = { version = "2.9", features = ["wry", "devtools", "tray-icon"] } tauri-plugin-opener = "2.5.0" tauri-plugin-store = "2.4.0" tauri-plugin-updater = "2.9.0" diff --git a/apps/desktop/src/chat/convexMachineClient.ts b/apps/desktop/src/chat/convexMachineClient.ts index 94a6879..e64cae0 100644 --- a/apps/desktop/src/chat/convexMachineClient.ts +++ b/apps/desktop/src/chat/convexMachineClient.ts @@ -34,17 +34,12 @@ type MachineUpdatePayload = { totalUnread: number } -type MessagesPayload = { - messages: ChatMessage[] - hasSession: boolean -} - -// Convex self-hosted exige o formato "module.js:function" -const FN_CHECK_UPDATES = "liveChat.js:checkMachineUpdates" as const -const FN_LIST_MESSAGES = "liveChat.js:listMachineMessages" as const -const FN_POST_MESSAGE = "liveChat.js:postMachineMessage" as const -const FN_MARK_READ = "liveChat.js:markMachineMessagesRead" as const -const FN_UPLOAD_URL = "liveChat.js:generateMachineUploadUrl" as const +// Nomes das functions no Convex (formato module:function) +const FN_CHECK_UPDATES = "liveChat:checkMachineUpdates" as const +const FN_LIST_MESSAGES = "liveChat:listMachineMessages" as const +const FN_POST_MESSAGE = "liveChat:postMachineMessage" as const +const FN_MARK_READ = "liveChat:markMachineMessagesRead" as const +const FN_UPLOAD_URL = "liveChat:generateMachineUploadUrl" as const async function loadStore(): Promise { const appData = await appLocalDataDir() diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 5d281d2..1a7e730 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -1106,7 +1106,14 @@ const resolvedAppUrl = useMemo(() => { prevUnread = totalUnread }, - (err) => console.error("chat updates (Convex) erro:", err) + (err) => { + console.error("chat updates (Convex) erro:", err) + const msg = (err?.message || "").toLowerCase() + if (msg.includes("token de máquina") || msg.includes("revogado") || msg.includes("expirado") || msg.includes("inválido")) { + // Token inválido/expirado no Convex → tenta autoregistrar de novo + attemptSelfHeal("convex-subscribe").catch(console.error) + } + } ).then((u) => { unsub = u }) diff --git a/bun.lock b/bun.lock index f3c7b2d..d603ae8 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "web", @@ -104,7 +103,7 @@ "dependencies": { "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", - "@tauri-apps/api": "^2", + "@tauri-apps/api": "^2.9.1", "@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2", diff --git a/convex/tickets.ts b/convex/tickets.ts index 60e3944..6621bca 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -1,5 +1,6 @@ // CI touch: enable server-side assignee filtering and trigger redeploy import { mutation, query } from "./_generated/server"; +import { paginationOptsValidator } from "convex/server"; import { api } from "./_generated/api"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { ConvexError, v } from "convex/values"; @@ -4773,3 +4774,257 @@ export const reassignTicketsByEmail = mutation({ } }, }) + +/** + * Query paginada para listagem de tickets. + * Utiliza a paginação nativa do Convex para evitar carregar todos os tickets de uma vez. + * Filtros são aplicados no servidor para melhor performance. + */ +export const listPaginated = query({ + args: { + viewerId: v.optional(v.id("users")), + tenantId: v.string(), + status: v.optional(v.string()), + priority: v.optional(v.union(v.string(), v.array(v.string()))), + channel: v.optional(v.string()), + queueId: v.optional(v.id("queues")), + assigneeId: v.optional(v.id("users")), + requesterId: v.optional(v.id("users")), + search: v.optional(v.string()), + paginationOpts: paginationOptsValidator, + }, + handler: async (ctx, args) => { + if (!args.viewerId) { + return { page: [], isDone: true, continueCursor: "" }; + } + const viewerId = args.viewerId as Id<"users">; + const { user, role } = await requireUser(ctx, viewerId, args.tenantId); + if (role === "MANAGER" && !user.companyId) { + throw new ConvexError("Gestor não possui empresa vinculada"); + } + + const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null; + const normalizedPriorityFilter = normalizePriorityFilter(args.priority); + const prioritySet = normalizedPriorityFilter.length > 0 ? new Set(normalizedPriorityFilter) : null; + const normalizedChannelFilter = args.channel ? args.channel.toUpperCase() : null; + const searchTerm = args.search?.trim().toLowerCase() ?? null; + + // Constrói a query base com índice apropriado + let baseQuery; + if (role === "MANAGER") { + baseQuery = ctx.db + .query("tickets") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!)); + } else if (args.assigneeId) { + baseQuery = ctx.db + .query("tickets") + .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!)); + } else if (args.requesterId) { + baseQuery = ctx.db + .query("tickets") + .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!)); + } else if (args.queueId) { + baseQuery = ctx.db + .query("tickets") + .withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!)); + } else if (normalizedStatusFilter) { + baseQuery = ctx.db + .query("tickets") + .withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", normalizedStatusFilter)); + } else { + baseQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)); + } + + // Executa a paginação + const paginationResult = await baseQuery.order("desc").paginate(args.paginationOpts); + + // Aplica filtros que não puderam ser feitos via índice + let filtered = paginationResult.page; + if (role === "MANAGER") { + filtered = filtered.filter((t) => t.companyId === user.companyId); + } + if (prioritySet) { + filtered = filtered.filter((t) => prioritySet.has(t.priority)); + } + if (normalizedChannelFilter) { + filtered = filtered.filter((t) => t.channel === normalizedChannelFilter); + } + if (args.assigneeId && !args.assigneeId) { + filtered = filtered.filter((t) => String(t.assigneeId ?? "") === String(args.assigneeId)); + } + if (args.requesterId && !args.requesterId) { + filtered = filtered.filter((t) => String(t.requesterId) === String(args.requesterId)); + } + if (normalizedStatusFilter && !args.queueId && !args.assigneeId && !args.requesterId && role !== "MANAGER") { + filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter); + } + if (searchTerm) { + filtered = filtered.filter( + (t) => + t.subject.toLowerCase().includes(searchTerm) || + t.summary?.toLowerCase().includes(searchTerm) || + `#${t.reference}`.toLowerCase().includes(searchTerm) + ); + } + + // Enriquece os dados dos tickets + if (filtered.length === 0) { + return { + page: [], + isDone: paginationResult.isDone, + continueCursor: paginationResult.continueCursor, + }; + } + + const [ + requesterDocs, + assigneeDocs, + queueDocs, + companyDocs, + machineDocs, + activeSessionDocs, + categoryDocs, + subcategoryDocs, + ] = await Promise.all([ + loadDocs(ctx, filtered.map((t) => t.requesterId)), + loadDocs(ctx, filtered.map((t) => (t.assigneeId as Id<"users"> | null) ?? null)), + loadDocs(ctx, filtered.map((t) => (t.queueId as Id<"queues"> | null) ?? null)), + loadDocs(ctx, filtered.map((t) => (t.companyId as Id<"companies"> | null) ?? null)), + loadDocs(ctx, filtered.map((t) => (t.machineId as Id<"machines"> | null) ?? null)), + loadDocs(ctx, filtered.map((t) => (t.activeSessionId as Id<"ticketWorkSessions"> | null) ?? null)), + loadDocs(ctx, filtered.map((t) => (t.categoryId as Id<"ticketCategories"> | null) ?? null)), + loadDocs(ctx, filtered.map((t) => (t.subcategoryId as Id<"ticketSubcategories"> | null) ?? null)), + ]); + + const serverNow = Date.now(); + const page = filtered.map((t) => { + const requesterSnapshot = t.requesterSnapshot as UserSnapshot | undefined; + const requesterDoc = requesterDocs.get(String(t.requesterId)) ?? null; + const requesterSummary = requesterDoc + ? buildRequesterSummary(requesterDoc, t.requesterId, { ticketId: t._id }) + : buildRequesterFromSnapshot(t.requesterId, requesterSnapshot, { ticketId: t._id }); + + const assigneeDoc = t.assigneeId ? assigneeDocs.get(String(t.assigneeId)) ?? null : null; + const assigneeSummary = t.assigneeId + ? assigneeDoc + ? { + id: assigneeDoc._id, + name: assigneeDoc.name, + email: assigneeDoc.email, + avatarUrl: assigneeDoc.avatarUrl, + teams: normalizeTeams(assigneeDoc.teams), + } + : buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined) + : null; + + const queueDoc = t.queueId ? queueDocs.get(String(t.queueId)) ?? null : null; + const queueName = normalizeQueueName(queueDoc); + + const companyDoc = t.companyId ? companyDocs.get(String(t.companyId)) ?? null : null; + const companySummary = companyDoc + ? { id: companyDoc._id, name: companyDoc.name, isAvulso: companyDoc.isAvulso ?? false } + : t.companyId || t.companySnapshot + ? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined) + : null; + + const machineSnapshot = t.machineSnapshot as + | { hostname?: string; persona?: string; assignedUserName?: string; assignedUserEmail?: string; status?: string } + | undefined; + const machineDoc = t.machineId ? machineDocs.get(String(t.machineId)) ?? null : null; + let machineSummary: { + id: Id<"machines"> | null; + hostname: string | null; + persona: string | null; + assignedUserName: string | null; + assignedUserEmail: string | null; + status: string | null; + } | null = null; + if (t.machineId) { + machineSummary = { + id: t.machineId, + hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null, + persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null, + assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null, + assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null, + status: machineDoc?.status ?? machineSnapshot?.status ?? null, + }; + } else if (machineSnapshot) { + machineSummary = { + id: null, + hostname: machineSnapshot.hostname ?? null, + persona: machineSnapshot.persona ?? null, + assignedUserName: machineSnapshot.assignedUserName ?? null, + assignedUserEmail: machineSnapshot.assignedUserEmail ?? null, + status: machineSnapshot.status ?? null, + }; + } + + const categoryDoc = t.categoryId ? categoryDocs.get(String(t.categoryId)) ?? null : null; + const categorySummary = categoryDoc ? { id: categoryDoc._id, name: categoryDoc.name } : null; + + const subcategoryDoc = t.subcategoryId ? subcategoryDocs.get(String(t.subcategoryId)) ?? null : null; + const subcategorySummary = subcategoryDoc + ? { id: subcategoryDoc._id, name: subcategoryDoc.name, categoryId: subcategoryDoc.categoryId } + : null; + + const activeSessionDoc = t.activeSessionId ? activeSessionDocs.get(String(t.activeSessionId)) ?? null : null; + const activeSession = activeSessionDoc + ? { + id: activeSessionDoc._id, + agentId: activeSessionDoc.agentId, + startedAt: activeSessionDoc.startedAt, + workType: activeSessionDoc.workType ?? "INTERNAL", + } + : null; + + return { + id: t._id, + reference: t.reference, + tenantId: t.tenantId, + subject: t.subject, + summary: t.summary, + status: normalizeStatus(t.status), + priority: t.priority, + channel: t.channel, + queue: queueName, + csatScore: typeof t.csatScore === "number" ? t.csatScore : null, + csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null, + csatComment: typeof t.csatComment === "string" && t.csatComment.trim().length > 0 ? t.csatComment.trim() : null, + csatRatedAt: t.csatRatedAt ?? null, + csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null, + formTemplate: t.formTemplate ?? null, + formTemplateLabel: resolveFormTemplateLabel(t.formTemplate ?? null, t.formTemplateLabel ?? null), + company: companySummary, + requester: requesterSummary, + assignee: assigneeSummary, + slaPolicy: null, + dueAt: t.dueAt ?? null, + visitStatus: t.visitStatus ?? null, + visitPerformedAt: t.visitPerformedAt ?? null, + firstResponseAt: t.firstResponseAt ?? null, + resolvedAt: t.resolvedAt ?? null, + updatedAt: t.updatedAt, + createdAt: t.createdAt, + tags: t.tags ?? [], + lastTimelineEntry: null, + metrics: null, + category: categorySummary, + subcategory: subcategorySummary, + machine: machineSummary, + workSummary: { + totalWorkedMs: t.totalWorkedMs ?? 0, + internalWorkedMs: t.internalWorkedMs ?? 0, + externalWorkedMs: t.externalWorkedMs ?? 0, + serverNow, + activeSession, + }, + }; + }); + + return { + page, + isDone: paginationResult.isDone, + continueCursor: paginationResult.continueCursor, + }; + }, +}) diff --git a/src/components/portal/portal-ticket-list.tsx b/src/components/portal/portal-ticket-list.tsx index db2f9ca..f5296a5 100644 --- a/src/components/portal/portal-ticket-list.tsx +++ b/src/components/portal/portal-ticket-list.tsx @@ -1,7 +1,7 @@ "use client" import { useEffect, useMemo, useState } from "react" -import { useQuery } from "convex/react" +import { usePaginatedQuery } from "convex/react" import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import { DEFAULT_TENANT_ID } from "@/lib/constants" @@ -19,23 +19,41 @@ import { defaultPortalTicketFilters, type PortalTicketFiltersState, } from "@/components/portal/portal-ticket-filters" +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react" + +/** Número de tickets por página no portal */ +const TICKETS_PER_PAGE = 20 export function PortalTicketList() { const { convexUserId, session, machineContext, role } = useAuth() const viewerId = (convexUserId ?? machineContext?.assignedUserId ?? null) as Id<"users"> | null - const ticketsRaw = useQuery( - api.tickets.list, - viewerId - ? { - tenantId: session?.user.tenantId ?? DEFAULT_TENANT_ID, - viewerId, - limit: 100, - } - : "skip" + // Argumentos para a query paginada + const ticketsArgs = useMemo(() => { + if (!viewerId) return "skip" as const + return { + tenantId: session?.user.tenantId ?? DEFAULT_TENANT_ID, + viewerId, + } + }, [viewerId, session?.user.tenantId]) + + // 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(() => { if (!ticketsRaw) return [] return mapTicketsFromServerList((ticketsRaw as unknown[]) ?? []) @@ -46,9 +64,9 @@ export function PortalTicketList() { useEffect(() => { if (initialLoadCompleted) return if (!viewerId) return - if (ticketsRaw === undefined) return + if (isLoadingFirstPage) return setInitialLoadCompleted(true) - }, [initialLoadCompleted, viewerId, ticketsRaw]) + }, [initialLoadCompleted, viewerId, isLoadingFirstPage]) const [filters, setFilters] = useState(defaultPortalTicketFilters) @@ -152,8 +170,27 @@ export function PortalTicketList() { const handleResetFilters = () => { setFilters(defaultPortalTicketFilters) + setCurrentPage(1) } + // Estado de paginação no cliente + 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 + 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]) + const isLoading = !initialLoadCompleted if (isLoading) { @@ -241,12 +278,157 @@ export function PortalTicketList() { ) : ( -
- {filteredTickets.map((ticket) => ( - - ))} -
+ <> +
+ {paginatedTickets.map((ticket) => ( + + ))} +
+ + {/* Controles de paginação numerada */} + {totalFilteredTickets > itemsPerPage && ( +
+
+ Mostrando {((currentPage - 1) * itemsPerPage) + 1} a {Math.min(currentPage * itemsPerPage, totalFilteredTickets)} de {totalFilteredTickets} tickets + {canLoadMore && ( + (mais disponíveis) + )} +
+ +
+ {/* Primeira página */} + + + {/* Página anterior */} + + + {/* Números das páginas */} +
+ {generatePageNumbers(currentPage, totalPages).map((pageNum, idx) => + pageNum === "..." ? ( + + ... + + ) : ( + + ) + )} +
+ + {/* Próxima página */} + + + {/* Última página */} + +
+ + {/* Botão para carregar mais do servidor */} + {canLoadMore && currentPage === totalPages && ( + + )} +
+ )} + )} ) } + +/** + * 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 +} diff --git a/src/components/tickets/tickets-view.tsx b/src/components/tickets/tickets-view.tsx index 65b06eb..63540c9 100644 --- a/src/components/tickets/tickets-view.tsx +++ b/src/components/tickets/tickets-view.tsx @@ -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 @@ -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([]) const [enteringIds, setEnteringIds] = useState>(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 (
@@ -268,26 +309,174 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
- {ticketsRaw === undefined ? ( + {isLoadingFirstPage ? (
-
- {Array.from({ length: 6 }).map((_, i) => ( -
-
-
-
- ))} +
+ + Carregando tickets...
) : viewMode === "board" ? ( - + ) : ( - + + )} + + {/* Controles de paginação numerada */} + {!isLoadingFirstPage && filteredTickets.length > 0 && ( +
+
+ Mostrando {((currentPage - 1) * itemsPerPage) + 1} a {Math.min(currentPage * itemsPerPage, totalFilteredTickets)} de {totalFilteredTickets} tickets + {canLoadMore && ( + (mais disponíveis) + )} +
+ +
+ {/* Primeira página */} + + + {/* Página anterior */} + + + {/* Números das páginas */} +
+ {generatePageNumbers(currentPage, totalPages).map((pageNum, idx) => + pageNum === "..." ? ( + + ... + + ) : ( + + ) + )} +
+ + {/* Próxima página */} + + + {/* Última página */} + +
+ + {/* Botão para carregar mais do servidor */} + {canLoadMore && currentPage === totalPages && ( + + )} +
+ )} + + {/* Mensagem quando não há tickets */} + {!isLoadingFirstPage && filteredTickets.length === 0 && ( +
+

+ Nenhum ticket encontrado com os filtros selecionados. +

+
)}
) } +/** + * 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)) diff --git a/stack.yml b/stack.yml index 2593ec6..667d74f 100644 --- a/stack.yml +++ b/stack.yml @@ -22,7 +22,7 @@ services: # Não use o hostname interno do Swarm aqui, pois o browser não consegue resolvê-lo. NEXT_PUBLIC_CONVEX_URL: "${NEXT_PUBLIC_CONVEX_URL}" # URLs consumidas apenas pelo backend/SSR podem usar o hostname interno - CONVEX_INTERNAL_URL: "http://convex_backend:3210" + CONVEX_INTERNAL_URL: "http://sistema_convex_backend:3210" # URLs públicas do app (evita fallback para localhost) NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL}" BETTER_AUTH_URL: "${BETTER_AUTH_URL}"