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

@ -13,7 +13,7 @@
"dependencies": { "dependencies": {
"@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13", "@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-dialog": "^2.4.2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-process": "^2",

View file

@ -4369,9 +4369,9 @@ dependencies = [
[[package]] [[package]]
name = "tao" name = "tao"
version = "0.34.3" version = "0.34.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7" checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
dependencies = [ dependencies = [
"bitflags 2.9.4", "bitflags 2.9.4",
"block2 0.6.2", "block2 0.6.2",
@ -4437,9 +4437,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]] [[package]]
name = "tauri" name = "tauri"
version = "2.8.5" version = "2.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d1d3b3dc4c101ac989fd7db77e045cc6d91a25349cd410455cb5c57d510c1c" checksum = "8a3868da5508446a7cd08956d523ac3edf0a8bc20bf7e4038f9a95c2800d2033"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@ -4480,7 +4480,6 @@ dependencies = [
"tokio", "tokio",
"tray-icon", "tray-icon",
"url", "url",
"urlpattern",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
"window-vibrancy", "window-vibrancy",
@ -4489,9 +4488,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-build" name = "tauri-build"
version = "2.4.1" version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c432ccc9ff661803dab74c6cd78de11026a578a9307610bbc39d3c55be7943f" checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cargo_toml", "cargo_toml",
@ -4511,9 +4510,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-codegen" name = "tauri-codegen"
version = "2.4.0" version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ab3a62cf2e6253936a8b267c2e95839674e7439f104fa96ad0025e149d54d8a" checksum = "9fa9844cefcf99554a16e0a278156ae73b0d8680bbc0e2ad1e4287aadd8489cf"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"brotli", "brotli",
@ -4538,9 +4537,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-macros" name = "tauri-macros"
version = "2.4.0" version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4368ea8094e7045217edb690f493b55b30caf9f3e61f79b4c24b6db91f07995e" checksum = "3764a12f886d8245e66b7ee9b43ccc47883399be2019a61d80cf0f4117446fde"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
@ -4689,9 +4688,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.8.0" version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4cfc9ad45b487d3fded5a4731a567872a4812e9552e3964161b08edabf93846" checksum = "87f766fe9f3d1efc4b59b17e7a891ad5ed195fa8d23582abb02e6c9a01137892"
dependencies = [ dependencies = [
"cookie", "cookie",
"dpi", "dpi",
@ -4714,9 +4713,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-runtime-wry" name = "tauri-runtime-wry"
version = "2.8.1" version = "2.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1fe9d48bd122ff002064e88cfcd7027090d789c4302714e68fcccba0f4b7807" checksum = "187a3f26f681bdf028f796ccf57cf478c1ee422c50128e5a0a6ebeb3f5910065"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
@ -4741,9 +4740,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-utils" name = "tauri-utils"
version = "2.7.0" version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a3852fdf9a4f8fbeaa63dc3e9a85284dd6ef7200751f0bd66ceee30c93f212" checksum = "76a423c51176eb3616ee9b516a9fa67fed5f0e78baaba680e44eb5dd2cc37490"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"brotli", "brotli",

View file

@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2.4.1", features = [] } tauri-build = { version = "2.4.1", features = [] }
[dependencies] [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-opener = "2.5.0"
tauri-plugin-store = "2.4.0" tauri-plugin-store = "2.4.0"
tauri-plugin-updater = "2.9.0" tauri-plugin-updater = "2.9.0"

View file

@ -34,17 +34,12 @@ type MachineUpdatePayload = {
totalUnread: number totalUnread: number
} }
type MessagesPayload = { // Nomes das functions no Convex (formato module:function)
messages: ChatMessage[] const FN_CHECK_UPDATES = "liveChat:checkMachineUpdates" as const
hasSession: boolean const FN_LIST_MESSAGES = "liveChat:listMachineMessages" as const
} const FN_POST_MESSAGE = "liveChat:postMachineMessage" as const
const FN_MARK_READ = "liveChat:markMachineMessagesRead" as const
// Convex self-hosted exige o formato "module.js:function" const FN_UPLOAD_URL = "liveChat:generateMachineUploadUrl" as const
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
async function loadStore(): Promise<MachineStoreData> { async function loadStore(): Promise<MachineStoreData> {
const appData = await appLocalDataDir() const appData = await appLocalDataDir()

View file

@ -1106,7 +1106,14 @@ const resolvedAppUrl = useMemo(() => {
prevUnread = totalUnread 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) => { ).then((u) => {
unsub = u unsub = u
}) })

View file

@ -1,6 +1,5 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "web", "name": "web",
@ -104,7 +103,7 @@
"dependencies": { "dependencies": {
"@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13", "@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-dialog": "^2.4.2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-process": "^2",

View file

@ -1,5 +1,6 @@
// CI touch: enable server-side assignee filtering and trigger redeploy // CI touch: enable server-side assignee filtering and trigger redeploy
import { mutation, query } from "./_generated/server"; import { mutation, query } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
import { api } from "./_generated/api"; import { api } from "./_generated/api";
import type { MutationCtx, QueryCtx } from "./_generated/server"; import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values"; 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,
};
},
})

View file

@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useQuery } from "convex/react" import { usePaginatedQuery } from "convex/react"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
@ -19,23 +19,41 @@ import {
defaultPortalTicketFilters, defaultPortalTicketFilters,
type PortalTicketFiltersState, type PortalTicketFiltersState,
} from "@/components/portal/portal-ticket-filters" } 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() { export function PortalTicketList() {
const { convexUserId, session, machineContext, role } = useAuth() const { convexUserId, session, machineContext, role } = useAuth()
const viewerId = (convexUserId ?? machineContext?.assignedUserId ?? null) as Id<"users"> | null const viewerId = (convexUserId ?? machineContext?.assignedUserId ?? null) as Id<"users"> | null
const ticketsRaw = useQuery( // Argumentos para a query paginada
api.tickets.list, const ticketsArgs = useMemo(() => {
viewerId if (!viewerId) return "skip" as const
? { return {
tenantId: session?.user.tenantId ?? DEFAULT_TENANT_ID, tenantId: session?.user.tenantId ?? DEFAULT_TENANT_ID,
viewerId, viewerId,
limit: 100, }
} }, [viewerId, session?.user.tenantId])
: "skip"
// 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(() => { const tickets = useMemo(() => {
if (!ticketsRaw) return [] if (!ticketsRaw) return []
return mapTicketsFromServerList((ticketsRaw as unknown[]) ?? []) return mapTicketsFromServerList((ticketsRaw as unknown[]) ?? [])
@ -46,9 +64,9 @@ export function PortalTicketList() {
useEffect(() => { useEffect(() => {
if (initialLoadCompleted) return if (initialLoadCompleted) return
if (!viewerId) return if (!viewerId) return
if (ticketsRaw === undefined) return if (isLoadingFirstPage) return
setInitialLoadCompleted(true) setInitialLoadCompleted(true)
}, [initialLoadCompleted, viewerId, ticketsRaw]) }, [initialLoadCompleted, viewerId, isLoadingFirstPage])
const [filters, setFilters] = useState<PortalTicketFiltersState>(defaultPortalTicketFilters) const [filters, setFilters] = useState<PortalTicketFiltersState>(defaultPortalTicketFilters)
@ -152,8 +170,27 @@ export function PortalTicketList() {
const handleResetFilters = () => { const handleResetFilters = () => {
setFilters(defaultPortalTicketFilters) 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 const isLoading = !initialLoadCompleted
if (isLoading) { if (isLoading) {
@ -241,12 +278,157 @@ export function PortalTicketList() {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="grid gap-4"> <>
{filteredTickets.map((ticket) => ( <div className="grid gap-4">
<PortalTicketCard key={ticket.id} ticket={ticket} /> {paginatedTickets.map((ticket) => (
))} <PortalTicketCard key={ticket.id} ticket={ticket} />
</div> ))}
</div>
{/* Controles de paginação numerada */}
{totalFilteredTickets > itemsPerPage && (
<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 rounded-full 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 rounded-full 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] rounded-full 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 rounded-full 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 rounded-full 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 rounded-full"
>
{isLoadingMore ? (
<>
<Spinner className="size-4" />
Carregando...
</>
) : (
"Carregar mais tickets"
)}
</Button>
)}
</div>
)}
</>
)} )}
</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
}

View file

@ -2,7 +2,7 @@
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { useQuery } from "convex/react" import { usePaginatedQuery, useQuery } from "convex/react"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants" 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 { useAuth } from "@/lib/auth-client"
import { useDefaultQueues } from "@/hooks/use-default-queues" import { useDefaultQueues } from "@/hooks/use-default-queues"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" 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 { isVisitTicket } from "@/lib/ticket-matchers"
import { useTicketCategories } from "@/hooks/use-ticket-categories" 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 = { type TicketsViewProps = {
initialFilters?: Partial<TicketFiltersState> initialFilters?: Partial<TicketFiltersState>
@ -74,19 +79,37 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
) )
const queues: TicketQueueSummary[] = Array.isArray(queuesResult) ? queuesResult : [] const queues: TicketQueueSummary[] = Array.isArray(queuesResult) ? queuesResult : []
const agents = useQuery(api.users.listAgents, { tenantId }) as { _id: string; name: string }[] | undefined const agents = useQuery(api.users.listAgents, { tenantId }) as { _id: string; name: string }[] | undefined
const ticketsArgs = convexUserId
? { // Argumentos para a query paginada de tickets
tenantId, const ticketsArgs = useMemo(() => {
viewerId: convexUserId as Id<"users">, if (!convexUserId) return "skip" as const
status: filters.status ?? undefined, return {
priority: filters.priority ?? undefined, tenantId,
channel: filters.channel ?? undefined, viewerId: convexUserId as Id<"users">,
queueId: undefined, // simplified: filter by queue name on client status: filters.status ?? undefined,
assigneeId: filters.assigneeId ? (filters.assigneeId as unknown as Id<"users">) : undefined, priority: filters.priority ?? undefined,
search: filters.search || undefined, channel: filters.channel ?? undefined,
} queueId: undefined, // Filtro por nome da fila aplicado no cliente
: "skip" assigneeId: filters.assigneeId ? (filters.assigneeId as unknown as Id<"users">) : undefined,
const ticketsRaw = useQuery(api.tickets.list, ticketsArgs) 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( const tickets = useMemo(
() => mapTicketsFromServerList(Array.isArray(ticketsRaw) ? (ticketsRaw as unknown[]) : []), () => mapTicketsFromServerList(Array.isArray(ticketsRaw) ? (ticketsRaw as unknown[]) : []),
@ -202,8 +225,26 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
const previousIdsRef = useRef<string[]>([]) const previousIdsRef = useRef<string[]>([])
const [enteringIds, setEnteringIds] = useState<Set<string>>(new Set()) 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(() => { useEffect(() => {
if (ticketsRaw === undefined) { setCurrentPage(1)
}, [filters])
useEffect(() => {
if (isLoadingFirstPage) {
previousIdsRef.current = [] previousIdsRef.current = []
setEnteringIds(new Set()) setEnteringIds(new Set())
return return
@ -218,7 +259,7 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
setEnteringIds(highlight) setEnteringIds(highlight)
const timeout = window.setTimeout(() => setEnteringIds(new Set()), 600) const timeout = window.setTimeout(() => setEnteringIds(new Set()), 600)
return () => window.clearTimeout(timeout) return () => window.clearTimeout(timeout)
}, [filteredTickets, ticketsRaw]) }, [filteredTickets, isLoadingFirstPage])
return ( return (
<div className="flex flex-col gap-6 px-4 lg:px-6"> <div className="flex flex-col gap-6 px-4 lg:px-6">
@ -268,26 +309,174 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
</button> </button>
</div> </div>
</div> </div>
{ticketsRaw === undefined ? ( {isLoadingFirstPage ? (
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"> <div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="grid gap-3"> <div className="flex items-center justify-center gap-2 py-12">
{Array.from({ length: 6 }).map((_, i) => ( <Spinner className="size-5 text-neutral-500" />
<div key={i} className="flex items-center justify-between gap-3"> <span className="text-sm text-neutral-600">Carregando tickets...</span>
<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> </div>
</div> </div>
) : viewMode === "board" ? ( ) : 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> </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 }) { function parseDateInput(value: string | null, options?: { endOfDay?: boolean }) {
if (!value) return null if (!value) return null
const [year, month, day] = value.split("-").map((part) => Number(part)) const [year, month, day] = value.split("-").map((part) => Number(part))

View file

@ -22,7 +22,7 @@ services:
# Não use o hostname interno do Swarm aqui, pois o browser não consegue resolvê-lo. # 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}" NEXT_PUBLIC_CONVEX_URL: "${NEXT_PUBLIC_CONVEX_URL}"
# URLs consumidas apenas pelo backend/SSR podem usar o hostname interno # 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) # URLs públicas do app (evita fallback para localhost)
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL}" NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL}"
BETTER_AUTH_URL: "${BETTER_AUTH_URL}" BETTER_AUTH_URL: "${BETTER_AUTH_URL}"