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
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue