chore: expand reports coverage and upgrade next
This commit is contained in:
parent
2fb587b01d
commit
8b82284e8c
21 changed files with 2952 additions and 2713 deletions
BIN
Relatório personalizadoCLIENTES_Inventario_Estações.xlsx
Normal file
BIN
Relatório personalizadoCLIENTES_Inventario_Estações.xlsx
Normal file
Binary file not shown.
|
|
@ -1,7 +1,8 @@
|
|||
// ci: trigger convex functions deploy (no-op)
|
||||
import { mutation, query } from "./_generated/server"
|
||||
import { api } from "./_generated/api"
|
||||
import { ConvexError, v } from "convex/values"
|
||||
import { paginationOptsValidator } from "convex/server"
|
||||
import { ConvexError, v, Infer } from "convex/values"
|
||||
import { sha256 } from "@noble/hashes/sha256"
|
||||
import { randomBytes } from "@noble/hashes/utils"
|
||||
import type { Doc, Id } from "./_generated/dataModel"
|
||||
|
|
@ -12,6 +13,8 @@ const DEFAULT_TENANT_ID = "tenant-atlas"
|
|||
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
|
||||
const ALLOWED_MACHINE_PERSONAS = new Set(["collaborator", "manager"])
|
||||
const DEFAULT_OFFLINE_THRESHOLD_MS = 10 * 60 * 1000
|
||||
const OPEN_TICKET_STATUSES = new Set(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"])
|
||||
const MACHINE_TICKETS_STATS_PAGE_SIZE = 200
|
||||
|
||||
type NormalizedIdentifiers = {
|
||||
macs: string[]
|
||||
|
|
@ -876,12 +879,10 @@ export const listByTenant = query({
|
|||
},
|
||||
})
|
||||
|
||||
export const getById = query({
|
||||
args: {
|
||||
id: v.id("machines"),
|
||||
includeMetadata: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
export async function getByIdHandler(
|
||||
ctx: QueryCtx,
|
||||
args: { id: Id<"machines">; includeMetadata?: boolean }
|
||||
) {
|
||||
const includeMetadata = Boolean(args.includeMetadata)
|
||||
const now = Date.now()
|
||||
|
||||
|
|
@ -992,7 +993,14 @@ export const getById = query({
|
|||
lastPostureAt,
|
||||
remoteAccess: machine.remoteAccess ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export const getById = query({
|
||||
args: {
|
||||
id: v.id("machines"),
|
||||
includeMetadata: v.optional(v.boolean()),
|
||||
},
|
||||
handler: getByIdHandler,
|
||||
})
|
||||
|
||||
export const listAlerts = query({
|
||||
|
|
@ -1029,7 +1037,7 @@ export const listOpenTickets = query({
|
|||
handler: async (ctx, { machineId, limit }) => {
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) {
|
||||
return []
|
||||
return { totalOpen: 0, hasMore: false, tickets: [] }
|
||||
}
|
||||
const takeLimit = Math.max(1, Math.min(limit ?? 10, 50))
|
||||
const candidates = await ctx.db
|
||||
|
|
@ -1041,9 +1049,13 @@ export const listOpenTickets = query({
|
|||
const openTickets = candidates
|
||||
.filter((ticket) => normalizeStatus(ticket.status) !== "RESOLVED")
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
|
||||
.slice(0, takeLimit)
|
||||
const totalOpen = openTickets.length
|
||||
const limited = openTickets.slice(0, takeLimit)
|
||||
|
||||
return openTickets.map((ticket) => ({
|
||||
return {
|
||||
totalOpen,
|
||||
hasMore: totalOpen > takeLimit,
|
||||
tickets: limited.map((ticket) => ({
|
||||
id: ticket._id,
|
||||
reference: ticket.reference,
|
||||
subject: ticket.subject,
|
||||
|
|
@ -1062,20 +1074,263 @@ export const listOpenTickets = query({
|
|||
hostname:
|
||||
((ticket.machineSnapshot as { hostname?: string } | undefined)?.hostname ?? machine.hostname ?? null),
|
||||
},
|
||||
}))
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const updatePersona = mutation({
|
||||
type MachineTicketsHistoryFilter = {
|
||||
statusFilter: "all" | "open" | "resolved"
|
||||
priorityFilter: string | null
|
||||
from: number | null
|
||||
to: number | null
|
||||
}
|
||||
|
||||
type ListTicketsHistoryArgs = {
|
||||
machineId: Id<"machines">
|
||||
status?: "all" | "open" | "resolved"
|
||||
priority?: string
|
||||
search?: string
|
||||
from?: number
|
||||
to?: number
|
||||
paginationOpts: Infer<typeof paginationOptsValidator>
|
||||
}
|
||||
|
||||
type GetTicketsHistoryStatsArgs = {
|
||||
machineId: Id<"machines">
|
||||
status?: "all" | "open" | "resolved"
|
||||
priority?: string
|
||||
search?: string
|
||||
from?: number
|
||||
to?: number
|
||||
}
|
||||
|
||||
function createMachineTicketsQuery(
|
||||
ctx: QueryCtx,
|
||||
machine: Doc<"machines">,
|
||||
machineId: Id<"machines">,
|
||||
filters: MachineTicketsHistoryFilter
|
||||
) {
|
||||
let working = ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_machine", (q) => q.eq("tenantId", machine.tenantId).eq("machineId", machineId))
|
||||
.order("desc")
|
||||
|
||||
if (filters.statusFilter === "open") {
|
||||
working = working.filter((q) =>
|
||||
q.or(
|
||||
q.eq(q.field("status"), "PENDING"),
|
||||
q.eq(q.field("status"), "AWAITING_ATTENDANCE"),
|
||||
q.eq(q.field("status"), "PAUSED")
|
||||
)
|
||||
)
|
||||
} else if (filters.statusFilter === "resolved") {
|
||||
working = working.filter((q) => q.eq(q.field("status"), "RESOLVED"))
|
||||
}
|
||||
|
||||
if (filters.priorityFilter) {
|
||||
working = working.filter((q) => q.eq(q.field("priority"), filters.priorityFilter))
|
||||
}
|
||||
|
||||
if (typeof filters.from === "number") {
|
||||
working = working.filter((q) => q.gte(q.field("updatedAt"), filters.from!))
|
||||
}
|
||||
|
||||
if (typeof filters.to === "number") {
|
||||
working = working.filter((q) => q.lte(q.field("updatedAt"), filters.to!))
|
||||
}
|
||||
|
||||
return working
|
||||
}
|
||||
|
||||
function matchesTicketSearch(ticket: Doc<"tickets">, searchTerm: string): boolean {
|
||||
const normalized = searchTerm.trim().toLowerCase()
|
||||
if (!normalized) return true
|
||||
|
||||
const subject = ticket.subject.toLowerCase()
|
||||
if (subject.includes(normalized)) return true
|
||||
|
||||
const summary = typeof ticket.summary === "string" ? ticket.summary.toLowerCase() : ""
|
||||
if (summary.includes(normalized)) return true
|
||||
|
||||
const reference = `#${ticket.reference}`.toLowerCase()
|
||||
if (reference.includes(normalized)) return true
|
||||
|
||||
const requesterSnapshot = ticket.requesterSnapshot as { name?: string; email?: string } | undefined
|
||||
if (requesterSnapshot) {
|
||||
if (requesterSnapshot.name?.toLowerCase().includes(normalized)) return true
|
||||
if (requesterSnapshot.email?.toLowerCase().includes(normalized)) return true
|
||||
}
|
||||
|
||||
const assigneeSnapshot = ticket.assigneeSnapshot as { name?: string; email?: string } | undefined
|
||||
if (assigneeSnapshot) {
|
||||
if (assigneeSnapshot.name?.toLowerCase().includes(normalized)) return true
|
||||
if (assigneeSnapshot.email?.toLowerCase().includes(normalized)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function listTicketsHistoryHandler(ctx: QueryCtx, args: ListTicketsHistoryArgs) {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine) {
|
||||
return {
|
||||
page: [],
|
||||
isDone: true,
|
||||
continueCursor: args.paginationOpts.cursor ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedStatusFilter = args.status ?? "all"
|
||||
const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null
|
||||
const searchTerm = args.search?.trim().toLowerCase() ?? null
|
||||
const from = typeof args.from === "number" ? args.from : null
|
||||
const to = typeof args.to === "number" ? args.to : null
|
||||
const filters: MachineTicketsHistoryFilter = {
|
||||
statusFilter: normalizedStatusFilter,
|
||||
priorityFilter: normalizedPriorityFilter,
|
||||
from,
|
||||
to,
|
||||
}
|
||||
|
||||
const pageResult = await createMachineTicketsQuery(ctx, machine, args.machineId, filters).paginate(args.paginationOpts)
|
||||
|
||||
const page = searchTerm ? pageResult.page.filter((ticket) => matchesTicketSearch(ticket, searchTerm)) : pageResult.page
|
||||
const queueCache = new Map<string, Doc<"queues"> | null>()
|
||||
const items = await Promise.all(
|
||||
page.map(async (ticket) => {
|
||||
let queueName: string | null = null
|
||||
if (ticket.queueId) {
|
||||
const key = String(ticket.queueId)
|
||||
if (!queueCache.has(key)) {
|
||||
queueCache.set(key, (await ctx.db.get(ticket.queueId)) as Doc<"queues"> | null)
|
||||
}
|
||||
queueName = queueCache.get(key)?.name ?? null
|
||||
}
|
||||
const requesterSnapshot = ticket.requesterSnapshot as { name?: string; email?: string } | undefined
|
||||
const assigneeSnapshot = ticket.assigneeSnapshot as { name?: string; email?: string } | undefined
|
||||
return {
|
||||
id: ticket._id,
|
||||
reference: ticket.reference,
|
||||
subject: ticket.subject,
|
||||
status: normalizeStatus(ticket.status),
|
||||
priority: (ticket.priority ?? "MEDIUM").toString().toUpperCase(),
|
||||
updatedAt: ticket.updatedAt ?? ticket.createdAt ?? 0,
|
||||
createdAt: ticket.createdAt ?? 0,
|
||||
queue: queueName,
|
||||
requester: requesterSnapshot
|
||||
? {
|
||||
name: requesterSnapshot.name ?? null,
|
||||
email: requesterSnapshot.email ?? null,
|
||||
}
|
||||
: null,
|
||||
assignee: assigneeSnapshot
|
||||
? {
|
||||
name: assigneeSnapshot.name ?? null,
|
||||
email: assigneeSnapshot.email ?? null,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
page: items,
|
||||
isDone: pageResult.isDone,
|
||||
continueCursor: pageResult.continueCursor,
|
||||
splitCursor: pageResult.splitCursor ?? undefined,
|
||||
pageStatus: pageResult.pageStatus ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const listTicketsHistory = query({
|
||||
args: {
|
||||
machineId: v.id("machines"),
|
||||
persona: v.optional(v.string()),
|
||||
assignedUserId: v.optional(v.id("users")),
|
||||
assignedUserEmail: v.optional(v.string()),
|
||||
assignedUserName: v.optional(v.string()),
|
||||
assignedUserRole: v.optional(v.string()),
|
||||
status: v.optional(v.union(v.literal("all"), v.literal("open"), v.literal("resolved"))),
|
||||
priority: v.optional(v.string()),
|
||||
search: v.optional(v.string()),
|
||||
from: v.optional(v.number()),
|
||||
to: v.optional(v.number()),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
handler: listTicketsHistoryHandler,
|
||||
})
|
||||
|
||||
export async function getTicketsHistoryStatsHandler(
|
||||
ctx: QueryCtx,
|
||||
args: GetTicketsHistoryStatsArgs
|
||||
) {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine) {
|
||||
return { total: 0, openCount: 0, resolvedCount: 0 }
|
||||
}
|
||||
|
||||
const normalizedStatusFilter = args.status ?? "all"
|
||||
const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null
|
||||
const searchTerm = args.search?.trim().toLowerCase() ?? ""
|
||||
const from = typeof args.from === "number" ? args.from : null
|
||||
const to = typeof args.to === "number" ? args.to : null
|
||||
const filters: MachineTicketsHistoryFilter = {
|
||||
statusFilter: normalizedStatusFilter,
|
||||
priorityFilter: normalizedPriorityFilter,
|
||||
from,
|
||||
to,
|
||||
}
|
||||
|
||||
let cursor: string | null = null
|
||||
let total = 0
|
||||
let openCount = 0
|
||||
let done = false
|
||||
|
||||
while (!done) {
|
||||
const pageResult = await createMachineTicketsQuery(ctx, machine, args.machineId, filters).paginate({
|
||||
numItems: MACHINE_TICKETS_STATS_PAGE_SIZE,
|
||||
cursor,
|
||||
})
|
||||
const page = searchTerm ? pageResult.page.filter((ticket) => matchesTicketSearch(ticket, searchTerm)) : pageResult.page
|
||||
total += page.length
|
||||
for (const ticket of page) {
|
||||
if (OPEN_TICKET_STATUSES.has(normalizeStatus(ticket.status))) {
|
||||
openCount += 1
|
||||
}
|
||||
}
|
||||
done = pageResult.isDone
|
||||
cursor = pageResult.continueCursor ?? null
|
||||
if (!cursor) {
|
||||
done = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
openCount,
|
||||
resolvedCount: total - openCount,
|
||||
}
|
||||
}
|
||||
|
||||
export const getTicketsHistoryStats = query({
|
||||
args: {
|
||||
machineId: v.id("machines"),
|
||||
status: v.optional(v.union(v.literal("all"), v.literal("open"), v.literal("resolved"))),
|
||||
priority: v.optional(v.string()),
|
||||
search: v.optional(v.string()),
|
||||
from: v.optional(v.number()),
|
||||
to: v.optional(v.number()),
|
||||
},
|
||||
handler: getTicketsHistoryStatsHandler,
|
||||
})
|
||||
|
||||
export async function updatePersonaHandler(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
machineId: Id<"machines">
|
||||
persona?: string | null
|
||||
assignedUserId?: Id<"users">
|
||||
assignedUserEmail?: string | null
|
||||
assignedUserName?: string | null
|
||||
assignedUserRole?: string | null
|
||||
}
|
||||
) {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
|
|
@ -1084,7 +1339,7 @@ export const updatePersona = mutation({
|
|||
let nextPersona = machine.persona ?? undefined
|
||||
const personaProvided = args.persona !== undefined
|
||||
if (args.persona !== undefined) {
|
||||
const trimmed = args.persona.trim().toLowerCase()
|
||||
const trimmed = (args.persona ?? "").trim().toLowerCase()
|
||||
if (!trimmed) {
|
||||
nextPersona = undefined
|
||||
} else if (!ALLOWED_MACHINE_PERSONAS.has(trimmed)) {
|
||||
|
|
@ -1101,19 +1356,19 @@ export const updatePersona = mutation({
|
|||
|
||||
let nextAssignedEmail = machine.assignedUserEmail ?? undefined
|
||||
if (args.assignedUserEmail !== undefined) {
|
||||
const trimmedEmail = args.assignedUserEmail.trim().toLowerCase()
|
||||
const trimmedEmail = (args.assignedUserEmail ?? "").trim().toLowerCase()
|
||||
nextAssignedEmail = trimmedEmail || undefined
|
||||
}
|
||||
|
||||
let nextAssignedName = machine.assignedUserName ?? undefined
|
||||
if (args.assignedUserName !== undefined) {
|
||||
const trimmedName = args.assignedUserName.trim()
|
||||
const trimmedName = (args.assignedUserName ?? "").trim()
|
||||
nextAssignedName = trimmedName || undefined
|
||||
}
|
||||
|
||||
let nextAssignedRole = machine.assignedUserRole ?? undefined
|
||||
if (args.assignedUserRole !== undefined) {
|
||||
const trimmedRole = args.assignedUserRole.trim().toUpperCase()
|
||||
const trimmedRole = (args.assignedUserRole ?? "").trim().toUpperCase()
|
||||
nextAssignedRole = trimmedRole || undefined
|
||||
}
|
||||
|
||||
|
|
@ -1178,7 +1433,18 @@ export const updatePersona = mutation({
|
|||
|
||||
await ctx.db.patch(machine._id, patch)
|
||||
return { ok: true, persona: nextPersona ?? null }
|
||||
}
|
||||
|
||||
export const updatePersona = mutation({
|
||||
args: {
|
||||
machineId: v.id("machines"),
|
||||
persona: v.optional(v.string()),
|
||||
assignedUserId: v.optional(v.id("users")),
|
||||
assignedUserEmail: v.optional(v.string()),
|
||||
assignedUserName: v.optional(v.string()),
|
||||
assignedUserRole: v.optional(v.string()),
|
||||
},
|
||||
handler: updatePersonaHandler,
|
||||
})
|
||||
|
||||
export const getContext = query({
|
||||
|
|
|
|||
|
|
@ -195,9 +195,10 @@ function formatDateKey(timestamp: number) {
|
|||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export const slaOverview = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
||||
handler: async (ctx, { tenantId, viewerId, range, companyId }) => {
|
||||
export async function slaOverviewHandler(
|
||||
ctx: QueryCtx,
|
||||
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
||||
) {
|
||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||
let tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||
if (companyId) tickets = tickets.filter((t) => t.companyId === companyId)
|
||||
|
|
@ -252,12 +253,17 @@ export const slaOverview = query({
|
|||
queueBreakdown,
|
||||
rangeDays: days,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
export const slaOverview = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
||||
handler: slaOverviewHandler,
|
||||
});
|
||||
|
||||
export const csatOverview = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
||||
handler: async (ctx, { tenantId, viewerId, range, companyId }) => {
|
||||
export async function csatOverviewHandler(
|
||||
ctx: QueryCtx,
|
||||
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
||||
) {
|
||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||
let tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||
if (companyId) tickets = tickets.filter((t) => t.companyId === companyId)
|
||||
|
|
@ -291,12 +297,17 @@ export const csatOverview = query({
|
|||
})),
|
||||
rangeDays: days,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
export const csatOverview = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
||||
handler: csatOverviewHandler,
|
||||
});
|
||||
|
||||
export const openedResolvedByDay = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
||||
handler: async (ctx, { tenantId, viewerId, range, companyId }) => {
|
||||
export async function openedResolvedByDayHandler(
|
||||
ctx: QueryCtx,
|
||||
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
||||
) {
|
||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||
let tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||
if (companyId) tickets = tickets.filter((t) => t.companyId === companyId)
|
||||
|
|
@ -310,7 +321,6 @@ export const openedResolvedByDay = query({
|
|||
const opened: Record<string, number> = {}
|
||||
const resolved: Record<string, number> = {}
|
||||
|
||||
// pre-fill buckets
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(endMs - (i + 1) * ONE_DAY_MS)
|
||||
const key = formatDateKey(d.getTime())
|
||||
|
|
@ -337,12 +347,17 @@ export const openedResolvedByDay = query({
|
|||
}
|
||||
|
||||
return { rangeDays: days, series }
|
||||
},
|
||||
}
|
||||
|
||||
export const openedResolvedByDay = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
||||
handler: openedResolvedByDayHandler,
|
||||
})
|
||||
|
||||
export const backlogOverview = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
||||
handler: async (ctx, { tenantId, viewerId, range, companyId }) => {
|
||||
export async function backlogOverviewHandler(
|
||||
ctx: QueryCtx,
|
||||
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
||||
) {
|
||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||
// Optional range filter (createdAt) for reporting purposes
|
||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||
|
|
@ -392,13 +407,18 @@ export const backlogOverview = query({
|
|||
})),
|
||||
totalOpen: openTickets.length,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
export const backlogOverview = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
||||
handler: backlogOverviewHandler,
|
||||
});
|
||||
|
||||
// Touch to ensure CI convex_deploy runs and that agentProductivity is deployed
|
||||
export const agentProductivity = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
||||
handler: async (ctx, { tenantId, viewerId, range, companyId }) => {
|
||||
export async function agentProductivityHandler(
|
||||
ctx: QueryCtx,
|
||||
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
||||
) {
|
||||
const viewer = await requireStaff(ctx, viewerId, tenantId)
|
||||
let tickets = await fetchScopedTickets(ctx, tenantId, viewer)
|
||||
if (companyId) tickets = tickets.filter((t) => t.companyId === companyId)
|
||||
|
|
@ -442,16 +462,15 @@ export const agentProductivity = query({
|
|||
}
|
||||
const status = normalizeStatus(t.status)
|
||||
if (OPEN_STATUSES.has(status)) acc.open += 1
|
||||
if (status === 'RESOLVED') acc.resolved += 1
|
||||
if (status === "RESOLVED") acc.resolved += 1
|
||||
if (t.firstResponseAt) acc.avgFirstResponseMinValues.push((t.firstResponseAt - t.createdAt) / 60000)
|
||||
if (t.resolvedAt) acc.avgResolutionMinValues.push((t.resolvedAt - t.createdAt) / 60000)
|
||||
}
|
||||
|
||||
// Sum work sessions by agent
|
||||
for (const [agentId, acc] of map) {
|
||||
const sessions = await ctx.db
|
||||
.query('ticketWorkSessions')
|
||||
.withIndex('by_agent', (q) => q.eq('agentId', agentId as Id<'users'>))
|
||||
.query("ticketWorkSessions")
|
||||
.withIndex("by_agent", (q) => q.eq("agentId", agentId as Id<"users">))
|
||||
.collect()
|
||||
let total = 0
|
||||
for (const s of sessions) {
|
||||
|
|
@ -473,15 +492,19 @@ export const agentProductivity = query({
|
|||
avgResolutionMinutes: average(acc.avgResolutionMinValues),
|
||||
workedHours: Math.round((acc.workedMs / 3600000) * 100) / 100,
|
||||
}))
|
||||
// sort by resolved desc
|
||||
items.sort((a, b) => b.resolved - a.resolved)
|
||||
return { rangeDays: days, items }
|
||||
}
|
||||
}
|
||||
|
||||
export const agentProductivity = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
||||
handler: agentProductivityHandler,
|
||||
})
|
||||
|
||||
export const dashboardOverview = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||
handler: async (ctx, { tenantId, viewerId }) => {
|
||||
export async function dashboardOverviewHandler(
|
||||
ctx: QueryCtx,
|
||||
{ tenantId, viewerId }: { tenantId: string; viewerId: Id<"users"> }
|
||||
) {
|
||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||
const now = Date.now();
|
||||
|
|
@ -583,17 +606,17 @@ export const dashboardOverview = query({
|
|||
deltaPercentage: resolutionDelta,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
export const dashboardOverview = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||
handler: dashboardOverviewHandler,
|
||||
});
|
||||
|
||||
export const ticketsByChannel = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
range: v.optional(v.string()),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId, range, companyId }) => {
|
||||
export async function ticketsByChannelHandler(
|
||||
ctx: QueryCtx,
|
||||
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
||||
) {
|
||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||
let tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||
if (companyId) tickets = tickets.filter((t) => t.companyId === companyId)
|
||||
|
|
@ -638,12 +661,22 @@ export const ticketsByChannel = query({
|
|||
channels: sortedChannels,
|
||||
points,
|
||||
};
|
||||
}
|
||||
|
||||
export const ticketsByChannel = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
range: v.optional(v.string()),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
},
|
||||
handler: ticketsByChannelHandler,
|
||||
});
|
||||
|
||||
export const hoursByClient = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) },
|
||||
handler: async (ctx, { tenantId, viewerId, range }) => {
|
||||
export async function hoursByClientHandler(
|
||||
ctx: QueryCtx,
|
||||
{ tenantId, viewerId, range }: { tenantId: string; viewerId: Id<"users">; range?: string }
|
||||
) {
|
||||
const viewer = await requireStaff(ctx, viewerId, tenantId)
|
||||
const tickets = await fetchScopedTickets(ctx, tenantId, viewer)
|
||||
|
||||
|
|
@ -653,7 +686,6 @@ export const hoursByClient = query({
|
|||
const endMs = end.getTime() + ONE_DAY_MS
|
||||
const startMs = endMs - days * ONE_DAY_MS
|
||||
|
||||
// Accumulate by company
|
||||
type Acc = {
|
||||
companyId: Id<"companies">
|
||||
name: string
|
||||
|
|
@ -666,7 +698,6 @@ export const hoursByClient = query({
|
|||
const map = new Map<string, Acc>()
|
||||
|
||||
for (const t of tickets) {
|
||||
// only consider tickets updated in range as a proxy for recent work
|
||||
if (t.updatedAt < startMs || t.updatedAt >= endMs) continue
|
||||
const companyId = t.companyId ?? null
|
||||
if (!companyId) continue
|
||||
|
|
@ -705,13 +736,18 @@ export const hoursByClient = query({
|
|||
contractedHoursPerMonth: i.contractedHoursPerMonth ?? null,
|
||||
})),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const hoursByClient = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) },
|
||||
handler: hoursByClientHandler,
|
||||
})
|
||||
|
||||
// Internal variant used by scheduled jobs: skips viewer scoping and aggregates for the whole tenant
|
||||
export const hoursByClientInternal = query({
|
||||
args: { tenantId: v.string(), range: v.optional(v.string()) },
|
||||
handler: async (ctx, { tenantId, range }) => {
|
||||
export async function hoursByClientInternalHandler(
|
||||
ctx: QueryCtx,
|
||||
{ tenantId, range }: { tenantId: string; range?: string }
|
||||
) {
|
||||
const tickets = await fetchTickets(ctx, tenantId)
|
||||
|
||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90
|
||||
|
|
@ -770,7 +806,11 @@ export const hoursByClientInternal = query({
|
|||
contractedHoursPerMonth: i.contractedHoursPerMonth ?? null,
|
||||
})),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const hoursByClientInternal = query({
|
||||
args: { tenantId: v.string(), range: v.optional(v.string()) },
|
||||
handler: hoursByClientInternalHandler,
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,20 @@ A página Admin > Máquinas agora exibe um inventário detalhado e pesquisável
|
|||
|
||||
## Exportação
|
||||
- Exportar CSV de softwares ou serviços diretamente da seção detalhada (quando disponíveis).
|
||||
- Exportar planilha XLSX completa (`/admin/machines/:id/inventory.xlsx`). A partir de 31/10/2025 a planilha contém:
|
||||
- **Resumo**: data de geração, filtros aplicados, contagem por status e total de acessos remotos/alertas.
|
||||
- **Inventário**: colunas principais exibidas na UI (status, persona, hardware, token, build/licença do SO, domínio, colaborador, Fleet, etc.).
|
||||
- **Vínculos**: usuários associados à máquina.
|
||||
- **Softwares**: lista deduplicada (nome + versão + origem/publisher). A coluna “Softwares instalados” no inventário bate com o total desta aba.
|
||||
- **Partições**: nome/mount/FS/capacidade/livre, convertendo unidades (ex.: 447 GB → bytes).
|
||||
- **Discos físicos**: modelo, tamanho, interface, tipo e serial de cada drive.
|
||||
- **Rede**: interfaces com MAC/IP de todas as fontes (agente, Fleet).
|
||||
- **Acessos remotos**: TeamViewer/AnyDesk/etc. com notas, URL, última verificação e metadados brutos.
|
||||
- **Serviços**: serviços coletados (Windows/Linux) com nome, display name e status.
|
||||
- **Alertas**: postura recente (tipo, mensagem, severidade, criado em).
|
||||
- **Métricas**: CPU/Memória/Disco/GPU com timestamp coletado.
|
||||
- **Labels**: tags aplicadas à máquina.
|
||||
- **Sistema**: visão categorizada (Sistema, Dispositivo, Hardware, Acesso, Token, Fleet) contendo build, licença, domínio, fabricante, serial, colaborador, contagem de acessos, etc.
|
||||
|
||||
## Notas
|
||||
- Os dados vêm de duas fontes:
|
||||
|
|
|
|||
BIN
machine-inventory-plc-est-025 (2).xlsx
Normal file
BIN
machine-inventory-plc-est-025 (2).xlsx
Normal file
Binary file not shown.
|
|
@ -56,7 +56,7 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "^16.0.0",
|
||||
"next": "^16.0.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdfkit": "^0.17.2",
|
||||
"postcss": "^8.5.6",
|
||||
|
|
@ -88,7 +88,7 @@
|
|||
"better-sqlite3": "^12.4.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^16.0.0",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"jsdom": "^27.0.1",
|
||||
"playwright": "^1.56.1",
|
||||
|
|
|
|||
1939
pnpm-lock.yaml
generated
1939
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
26
src/app/admin/machines/[id]/tickets/page.tsx
Normal file
26
src/app/admin/machines/[id]/tickets/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { AppShell } from "@/components/app-shell"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { MachineBreadcrumbs } from "@/components/admin/machines/machine-breadcrumbs.client"
|
||||
import { MachineTicketsHistoryClient } from "@/components/admin/machines/machine-tickets-history.client"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminMachineTicketsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
|
||||
return (
|
||||
<AppShell header={<SiteHeader title="Tickets da máquina" lead="Histórico completo de chamados vinculados à máquina." />}>
|
||||
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
|
||||
<MachineBreadcrumbs
|
||||
tenantId={DEFAULT_TENANT_ID}
|
||||
machineId={id}
|
||||
machineHref={`/admin/machines/${id}`}
|
||||
extra={[{ label: "Tickets" }]}
|
||||
/>
|
||||
<MachineTicketsHistoryClient tenantId={DEFAULT_TENANT_ID} machineId={id} />
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -102,6 +102,12 @@ type MachineTicketSummary = {
|
|||
assignee: { name: string | null; email: string | null } | null
|
||||
}
|
||||
|
||||
type MachineOpenTicketsSummary = {
|
||||
totalOpen: number
|
||||
hasMore: boolean
|
||||
tickets: MachineTicketSummary[]
|
||||
}
|
||||
|
||||
|
||||
type DetailLineProps = {
|
||||
label: string
|
||||
|
|
@ -1454,9 +1460,14 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
const machineAlertsHistory = alertsHistory ?? []
|
||||
const openTickets = useQuery(
|
||||
machine ? api.machines.listOpenTickets : "skip",
|
||||
machine ? { machineId: machine.id as Id<"machines">, limit: 8 } : ("skip" as const)
|
||||
) as MachineTicketSummary[] | undefined
|
||||
const machineTickets = openTickets ?? []
|
||||
machine ? { machineId: machine.id as Id<"machines">, limit: 6 } : ("skip" as const)
|
||||
) as MachineOpenTicketsSummary | undefined
|
||||
const machineTickets = openTickets?.tickets ?? []
|
||||
const totalOpenTickets = openTickets?.totalOpen ?? machineTickets.length
|
||||
const displayLimit = 3
|
||||
const displayedMachineTickets = machineTickets.slice(0, displayLimit)
|
||||
const hasAdditionalOpenTickets = totalOpenTickets > displayedMachineTickets.length
|
||||
const machineTicketsHref = machine ? `/admin/machines/${machine.id}/tickets` : null
|
||||
const metadata = machine?.inventory ?? null
|
||||
const metrics = machine?.metrics ?? null
|
||||
const metricsCapturedAt = useMemo(() => getMetricsTimestamp(metrics), [metrics])
|
||||
|
|
@ -2356,13 +2367,32 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</div>
|
||||
<div className="rounded-2xl border border-[color:var(--accent)] bg-[color:var(--accent)]/80 px-4 py-4">
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h4 className="text-sm font-semibold text-accent-foreground">Tickets abertos por esta máquina</h4>
|
||||
{machineTickets.length === 0 ? (
|
||||
<p className="text-xs text-[color:var(--accent-foreground)]/80">Nenhum chamado em aberto registrado diretamente por esta máquina.</p>
|
||||
{machineTicketsHref ? (
|
||||
<Link
|
||||
href={machineTicketsHref}
|
||||
className="text-xs font-semibold text-accent-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-foreground)] focus-visible:ring-offset-2"
|
||||
>
|
||||
Ver todos
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
{totalOpenTickets === 0 ? (
|
||||
<p className="text-xs text-[color:var(--accent-foreground)]/80">
|
||||
Nenhum chamado em aberto registrado diretamente por esta máquina.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{hasAdditionalOpenTickets ? (
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--accent-foreground)]/70">
|
||||
Mostrando últimos {Math.min(displayLimit, totalOpenTickets)} de {totalOpenTickets} chamados
|
||||
em aberto
|
||||
</p>
|
||||
) : null}
|
||||
<ul className="space-y-2">
|
||||
{machineTickets.map((ticket) => {
|
||||
{displayedMachineTickets.map((ticket) => {
|
||||
const priorityMeta = getTicketPriorityMeta(ticket.priority)
|
||||
return (
|
||||
<li key={ticket.id}>
|
||||
|
|
@ -2389,12 +2419,13 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="self-center justify-self-end">
|
||||
<div className="flex h-12 min-w-[72px] items-center justify-center rounded-2xl border border-[color:var(--accent)] bg-white px-5 shadow-sm sm:min-w-[88px]">
|
||||
<span className="text-2xl font-semibold leading-none text-accent-foreground tabular-nums sm:text-3xl">
|
||||
{machineTickets.length}
|
||||
{totalOpenTickets}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,19 @@ import type { Id } from "@/convex/_generated/dataModel"
|
|||
import { api } from "@/convex/_generated/api"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
||||
export function MachineBreadcrumbs({ tenantId: _tenantId, machineId }: { tenantId: string; machineId: string }) {
|
||||
type BreadcrumbSegment = {
|
||||
label: string
|
||||
href?: string | null
|
||||
}
|
||||
|
||||
type MachineBreadcrumbsProps = {
|
||||
tenantId: string
|
||||
machineId: string
|
||||
machineHref?: string | null
|
||||
extra?: BreadcrumbSegment[]
|
||||
}
|
||||
|
||||
export function MachineBreadcrumbs({ tenantId: _tenantId, machineId, machineHref, extra }: MachineBreadcrumbsProps) {
|
||||
const { convexUserId } = useAuth()
|
||||
const queryArgs = machineId && convexUserId
|
||||
? ({ id: machineId as Id<"machines">, includeMetadata: false } as const)
|
||||
|
|
@ -15,15 +27,36 @@ export function MachineBreadcrumbs({ tenantId: _tenantId, machineId }: { tenantI
|
|||
|
||||
const item = useQuery(api.machines.getById, queryArgs)
|
||||
const hostname = useMemo(() => item?.hostname ?? "Detalhe", [item])
|
||||
const segments = useMemo(() => {
|
||||
const trail: BreadcrumbSegment[] = [
|
||||
{ label: "Máquinas", href: "/admin/machines" },
|
||||
{ label: hostname, href: machineHref ?? undefined },
|
||||
]
|
||||
if (Array.isArray(extra) && extra.length > 0) {
|
||||
trail.push(...extra.filter((segment): segment is BreadcrumbSegment => Boolean(segment?.label)))
|
||||
}
|
||||
return trail
|
||||
}, [hostname, machineHref, extra])
|
||||
|
||||
return (
|
||||
<nav className="mb-4 text-sm text-neutral-600">
|
||||
<ol className="flex items-center gap-2">
|
||||
<li>
|
||||
<Link href="/admin/machines" className="underline-offset-4 hover:underline">Máquinas</Link>
|
||||
{segments.map((segment, index) => {
|
||||
const isLast = index === segments.length - 1
|
||||
const content = segment.href && !isLast ? (
|
||||
<Link href={segment.href} className="underline-offset-4 hover:underline">
|
||||
{segment.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={isLast ? "text-neutral-800" : "text-neutral-600"}>{segment.label}</span>
|
||||
)
|
||||
return (
|
||||
<li key={`${segment.label}-${index}`} className="flex items-center gap-2">
|
||||
{content}
|
||||
{!isLast ? <span className="text-neutral-400">/</span> : null}
|
||||
</li>
|
||||
<li className="text-neutral-400">/</li>
|
||||
<li className="text-neutral-800">{hostname}</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
|
|
|
|||
439
src/components/admin/machines/machine-tickets-history.client.tsx
Normal file
439
src/components/admin/machines/machine-tickets-history.client.tsx
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePaginatedQuery, useQuery } from "convex/react"
|
||||
import { format, formatDistanceToNowStrict } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"
|
||||
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
||||
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
|
||||
|
||||
type MachineTicketHistoryItem = {
|
||||
id: string
|
||||
reference: number
|
||||
subject: string
|
||||
status: TicketStatus
|
||||
priority: TicketPriority | string
|
||||
updatedAt: number
|
||||
createdAt: number
|
||||
queue: string | null
|
||||
requester: { name: string | null; email: string | null } | null
|
||||
assignee: { name: string | null; email: string | null } | null
|
||||
}
|
||||
|
||||
type MachineTicketsHistoryArgs = {
|
||||
machineId: Id<"machines">
|
||||
status?: "open" | "resolved"
|
||||
priority?: string
|
||||
search?: string
|
||||
from?: number
|
||||
to?: number
|
||||
}
|
||||
|
||||
type MachineTicketsHistoryStats = {
|
||||
total: number
|
||||
openCount: number
|
||||
resolvedCount: number
|
||||
}
|
||||
|
||||
type PeriodPreset = "7d" | "30d" | "90d" | "year" | "all" | "custom"
|
||||
|
||||
function startOfDayMs(date: Date) {
|
||||
const copy = new Date(date)
|
||||
copy.setHours(0, 0, 0, 0)
|
||||
return copy.getTime()
|
||||
}
|
||||
|
||||
function endOfDayMs(date: Date) {
|
||||
const copy = new Date(date)
|
||||
copy.setHours(23, 59, 59, 999)
|
||||
return copy.getTime()
|
||||
}
|
||||
|
||||
function parseDateInput(value: string) {
|
||||
if (!value) return null
|
||||
const parsed = new Date(`${value}T00:00:00`)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function computeRange(preset: PeriodPreset, customFrom: string, customTo: string) {
|
||||
if (preset === "all") {
|
||||
return { from: null, to: null }
|
||||
}
|
||||
if (preset === "custom") {
|
||||
const fromDate = parseDateInput(customFrom)
|
||||
const toDate = parseDateInput(customTo)
|
||||
return {
|
||||
from: fromDate ? startOfDayMs(fromDate) : null,
|
||||
to: toDate ? endOfDayMs(toDate) : null,
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const end = endOfDayMs(now)
|
||||
const start = new Date(now)
|
||||
|
||||
switch (preset) {
|
||||
case "7d":
|
||||
start.setDate(start.getDate() - 6)
|
||||
break
|
||||
case "30d":
|
||||
start.setDate(start.getDate() - 29)
|
||||
break
|
||||
case "90d":
|
||||
start.setDate(start.getDate() - 89)
|
||||
break
|
||||
case "year":
|
||||
start.setMonth(0, 1)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return { from: startOfDayMs(start), to: end }
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp?: number | null) {
|
||||
if (!timestamp || timestamp <= 0) return "—"
|
||||
return formatDistanceToNowStrict(timestamp, { addSuffix: true, locale: ptBR })
|
||||
}
|
||||
|
||||
function formatAbsoluteTime(timestamp?: number | null) {
|
||||
if (!timestamp || timestamp <= 0) return "—"
|
||||
return format(timestamp, "dd/MM/yyyy HH:mm", { locale: ptBR })
|
||||
}
|
||||
|
||||
function getPriorityMeta(priority: TicketPriority | string | null | undefined) {
|
||||
const normalized = (priority ?? "MEDIUM").toString().toUpperCase()
|
||||
switch (normalized) {
|
||||
case "LOW":
|
||||
return { label: "Baixa", badgeClass: "bg-emerald-100 text-emerald-700 border border-emerald-200" }
|
||||
case "MEDIUM":
|
||||
return { label: "Média", badgeClass: "bg-sky-100 text-sky-700 border border-sky-200" }
|
||||
case "HIGH":
|
||||
return { label: "Alta", badgeClass: "bg-amber-100 text-amber-700 border border-amber-200" }
|
||||
case "URGENT":
|
||||
return { label: "Urgente", badgeClass: "bg-rose-100 text-rose-700 border border-rose-200" }
|
||||
default:
|
||||
return { label: normalized, badgeClass: "bg-slate-100 text-slate-700 border border-slate-200" }
|
||||
}
|
||||
}
|
||||
|
||||
export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }: { tenantId: string; machineId: string }) {
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "open" | "resolved">("all")
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>("ALL")
|
||||
const [periodPreset, setPeriodPreset] = useState<PeriodPreset>("90d")
|
||||
const [customFrom, setCustomFrom] = useState<string>("")
|
||||
const [customTo, setCustomTo] = useState<string>("")
|
||||
const [searchValue, setSearchValue] = useState<string>("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState<string>("")
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(searchValue.trim())
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (periodPreset !== "custom") {
|
||||
setCustomFrom("")
|
||||
setCustomTo("")
|
||||
}
|
||||
}, [periodPreset])
|
||||
|
||||
const range = useMemo(() => computeRange(periodPreset, customFrom, customTo), [periodPreset, customFrom, customTo])
|
||||
|
||||
const queryArgs = useMemo(() => {
|
||||
const args: MachineTicketsHistoryArgs = {
|
||||
machineId: machineId as Id<"machines">,
|
||||
}
|
||||
if (statusFilter !== "all") {
|
||||
args.status = statusFilter
|
||||
}
|
||||
if (priorityFilter !== "ALL") {
|
||||
args.priority = priorityFilter
|
||||
}
|
||||
if (debouncedSearch) {
|
||||
args.search = debouncedSearch
|
||||
}
|
||||
if (range.from !== null) {
|
||||
args.from = range.from
|
||||
}
|
||||
if (range.to !== null) {
|
||||
args.to = range.to
|
||||
}
|
||||
return args
|
||||
}, [debouncedSearch, machineId, priorityFilter, range.from, range.to, statusFilter])
|
||||
|
||||
const { results: tickets, status: paginationStatus, loadMore } = usePaginatedQuery(
|
||||
api.machines.listTicketsHistory,
|
||||
queryArgs,
|
||||
{ initialNumItems: 25 }
|
||||
)
|
||||
|
||||
const stats = useQuery(api.machines.getTicketsHistoryStats, queryArgs) as MachineTicketsHistoryStats | undefined
|
||||
const totalTickets = stats?.total ?? 0
|
||||
const openTickets = stats?.openCount ?? 0
|
||||
const resolvedTickets = stats?.resolvedCount ?? 0
|
||||
|
||||
const isLoadingFirstPage = paginationStatus === "LoadingFirstPage"
|
||||
const isLoadingMore = paginationStatus === "LoadingMore"
|
||||
const canLoadMore = paginationStatus === "CanLoadMore"
|
||||
|
||||
const resetFilters = () => {
|
||||
setStatusFilter("all")
|
||||
setPriorityFilter("ALL")
|
||||
setPeriodPreset("90d")
|
||||
setCustomFrom("")
|
||||
setCustomTo("")
|
||||
setSearchValue("")
|
||||
setDebouncedSearch("")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-4 shadow-sm">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">Chamados no período</p>
|
||||
<p className="pt-1 text-2xl font-semibold text-neutral-900">
|
||||
{stats ? (
|
||||
totalTickets
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2 text-sm text-neutral-500">
|
||||
<Spinner className="size-4 text-neutral-400" /> Atualizando...
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-4 shadow-sm">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-emerald-700">Em aberto</p>
|
||||
<p className="pt-1 text-2xl font-semibold text-emerald-900">{stats ? openTickets : "—"}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 shadow-sm">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-600">Resolvidos</p>
|
||||
<p className="pt-1 text-2xl font-semibold text-neutral-900">{stats ? resolvedTickets : "—"}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm lg:p-6">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
placeholder="Buscar por assunto, #ID, solicitante ou responsável"
|
||||
className="sm:max-w-sm"
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={(value) => setStatusFilter(value as typeof statusFilter)}>
|
||||
<SelectTrigger className="sm:w-[160px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os status</SelectItem>
|
||||
<SelectItem value="open">Em aberto</SelectItem>
|
||||
<SelectItem value="resolved">Resolvidos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={priorityFilter} onValueChange={(value) => setPriorityFilter(value)}>
|
||||
<SelectTrigger className="sm:w-[160px]">
|
||||
<SelectValue placeholder="Prioridade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">Todas as prioridades</SelectItem>
|
||||
<SelectItem value="URGENT">Urgente</SelectItem>
|
||||
<SelectItem value="HIGH">Alta</SelectItem>
|
||||
<SelectItem value="MEDIUM">Média</SelectItem>
|
||||
<SelectItem value="LOW">Baixa</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Select value={periodPreset} onValueChange={(value) => setPeriodPreset(value as PeriodPreset)}>
|
||||
<SelectTrigger className="sm:w-[200px]">
|
||||
<SelectValue placeholder="Período" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7d">Últimos 7 dias</SelectItem>
|
||||
<SelectItem value="30d">Últimos 30 dias</SelectItem>
|
||||
<SelectItem value="90d">Últimos 90 dias</SelectItem>
|
||||
<SelectItem value="year">Este ano</SelectItem>
|
||||
<SelectItem value="all">Desde sempre</SelectItem>
|
||||
<SelectItem value="custom">Personalizado</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{periodPreset === "custom" ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
type="date"
|
||||
value={customFrom}
|
||||
onChange={(event) => setCustomFrom(event.target.value)}
|
||||
className="sm:w-[160px]"
|
||||
placeholder="Início"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={customTo}
|
||||
onChange={(event) => setCustomTo(event.target.value)}
|
||||
className="sm:w-[160px]"
|
||||
placeholder="Fim"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" onClick={resetFilters}>
|
||||
Limpar filtros
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-0 shadow-sm">
|
||||
{isLoadingFirstPage ? (
|
||||
<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 chamados...</span>
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<Empty className="m-4 border-dashed">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Nenhum chamado encontrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Ajuste os filtros ou expanda o período para visualizar o histórico de chamados desta máquina.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button variant="outline" size="sm" onClick={resetFilters}>
|
||||
Limpar filtros
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b border-slate-200 bg-slate-50/60">
|
||||
<TableHead className="min-w-[260px] text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Ticket
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px] text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Status
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px] text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Prioridade
|
||||
</TableHead>
|
||||
<TableHead className="w-[160px] text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Última atualização
|
||||
</TableHead>
|
||||
<TableHead className="w-[200px] text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Responsável
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tickets.map((ticket) => {
|
||||
const priorityMeta = getPriorityMeta(ticket.priority)
|
||||
const requesterLabel = ticket.requester?.name ?? ticket.requester?.email ?? "Solicitante não informado"
|
||||
const assigneeLabel = ticket.assignee?.name ?? ticket.assignee?.email ?? "Sem responsável"
|
||||
const updatedLabel = formatRelativeTime(ticket.updatedAt)
|
||||
const updatedAbsolute = formatAbsoluteTime(ticket.updatedAt)
|
||||
return (
|
||||
<TableRow key={ticket.id} className="border-b border-slate-100 hover:bg-slate-50/70">
|
||||
<TableCell className="align-top">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
href={`/tickets/${ticket.id}`}
|
||||
className="text-sm font-semibold text-neutral-900 underline-offset-4 hover:underline"
|
||||
>
|
||||
#{ticket.reference} · {ticket.subject}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-500">
|
||||
<span>{requesterLabel}</span>
|
||||
{ticket.queue ? (
|
||||
<span className="inline-flex items-center rounded-full bg-slate-200/70 px-2 py-0.5 text-[11px] font-medium text-neutral-600">
|
||||
{ticket.queue}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-neutral-400">Aberto em {formatAbsoluteTime(ticket.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<TicketStatusBadge status={ticket.status} className="h-7 px-3 text-xs font-semibold" />
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityMeta.badgeClass)}>
|
||||
{priorityMeta.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="flex flex-col text-xs text-neutral-600">
|
||||
<span className="font-medium text-neutral-800">{updatedLabel}</span>
|
||||
<span className="text-[11px] text-neutral-400">{updatedAbsolute}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="flex flex-col text-sm text-neutral-700">
|
||||
<span>{assigneeLabel}</span>
|
||||
{ticket.assignee?.email ? (
|
||||
<span className="text-xs text-neutral-400">{ticket.assignee.email}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="border-t border-slate-200 px-4 py-4 text-center">
|
||||
{canLoadMore || isLoadingMore ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => loadMore(25)}
|
||||
disabled={isLoadingMore}
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Spinner className="size-4 text-neutral-500" />
|
||||
Carregando...
|
||||
</>
|
||||
) : (
|
||||
"Carregar mais"
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-neutral-500">
|
||||
Todos os chamados filtrados foram exibidos.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -303,7 +303,7 @@ export function CloseTicketDialog({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Encerrar ticket</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export function SearchableCombobox({
|
|||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-full border border-input bg-background px-3 text-sm font-medium text-foreground shadow-sm transition focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"flex min-h-[42px] w-full items-center justify-between rounded-full border border-input bg-background px-3 py-2 text-sm font-medium text-foreground shadow-sm transition focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
@ -171,4 +171,3 @@ export function SearchableCombobox({
|
|||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it, expect, vi } from "vitest"
|
||||
|
||||
import type { Doc, Id } from "../convex/_generated/dataModel"
|
||||
import { getById } from "../convex/machines"
|
||||
import { getByIdHandler } from "../convex/machines"
|
||||
|
||||
const FIXED_NOW = 1_706_071_200_000
|
||||
|
||||
|
|
@ -65,8 +65,8 @@ describe("convex.machines.getById", () => {
|
|||
})),
|
||||
}
|
||||
|
||||
const ctx = { db } as unknown as Parameters<typeof getById>[0]
|
||||
const result = await getById(ctx, { id: machine._id, includeMetadata: true })
|
||||
const ctx = { db } as unknown as Parameters<typeof getByIdHandler>[0]
|
||||
const result = await getByIdHandler(ctx, { id: machine._id, includeMetadata: true })
|
||||
|
||||
expect(result).toBeTruthy()
|
||||
expect(result?.metrics).toBeTruthy()
|
||||
|
|
@ -75,4 +75,3 @@ describe("convex.machines.getById", () => {
|
|||
expect(result?.token).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
255
tests/machines.listTicketsHistory.test.ts
Normal file
255
tests/machines.listTicketsHistory.test.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import type { Doc, Id } from "../convex/_generated/dataModel"
|
||||
import { getTicketsHistoryStatsHandler, listTicketsHistoryHandler } from "../convex/machines"
|
||||
|
||||
const MACHINE_ID = "machine_1" as Id<"machines">
|
||||
const TENANT_ID = "tenant-1"
|
||||
|
||||
function buildMachine(overrides: Partial<Doc<"machines">> = {}): Doc<"machines"> {
|
||||
const machine: Record<string, unknown> = {
|
||||
_id: MACHINE_ID,
|
||||
tenantId: TENANT_ID,
|
||||
hostname: "desktop-01",
|
||||
macAddresses: [],
|
||||
serialNumbers: [],
|
||||
fingerprint: "fp",
|
||||
isActive: true,
|
||||
lastHeartbeatAt: Date.now(),
|
||||
createdAt: Date.now() - 10_000,
|
||||
updatedAt: Date.now() - 5_000,
|
||||
linkedUserIds: [],
|
||||
remoteAccess: null,
|
||||
}
|
||||
return { ...(machine as Doc<"machines">), ...overrides }
|
||||
}
|
||||
|
||||
function buildTicket(overrides: Partial<Doc<"tickets">> = {}): Doc<"tickets"> {
|
||||
const base: Record<string, unknown> = {
|
||||
_id: "ticket_base" as Id<"tickets">,
|
||||
tenantId: TENANT_ID,
|
||||
reference: 42600,
|
||||
subject: "Generic ticket",
|
||||
summary: "",
|
||||
status: "PENDING",
|
||||
priority: "MEDIUM",
|
||||
channel: "EMAIL",
|
||||
queueId: undefined,
|
||||
requesterId: "user_req" as Id<"users">,
|
||||
requesterSnapshot: { name: "Alice", email: "alice@example.com", avatarUrl: undefined, teams: [] },
|
||||
assigneeId: "user_assignee" as Id<"users">,
|
||||
assigneeSnapshot: { name: "Bob", email: "bob@example.com", avatarUrl: undefined, teams: [] },
|
||||
companyId: undefined,
|
||||
companySnapshot: undefined,
|
||||
machineId: MACHINE_ID,
|
||||
machineSnapshot: undefined,
|
||||
working: false,
|
||||
dueAt: undefined,
|
||||
firstResponseAt: undefined,
|
||||
resolvedAt: undefined,
|
||||
closedAt: undefined,
|
||||
updatedAt: Date.now(),
|
||||
createdAt: Date.now() - 2000,
|
||||
tags: [],
|
||||
customFields: [],
|
||||
totalWorkedMs: 0,
|
||||
internalWorkedMs: 0,
|
||||
externalWorkedMs: 0,
|
||||
activeSessionId: undefined,
|
||||
}
|
||||
return { ...(base as Doc<"tickets">), ...overrides }
|
||||
}
|
||||
|
||||
type PaginateHandler = (options: { cursor: string | null; numItems: number }) => Promise<{
|
||||
page: Doc<"tickets">[]
|
||||
isDone: boolean
|
||||
continueCursor: string
|
||||
}>
|
||||
|
||||
function createCtx({
|
||||
machine = buildMachine(),
|
||||
queues = new Map<string, Doc<"queues">>(),
|
||||
paginate,
|
||||
}: {
|
||||
machine?: Doc<"machines">
|
||||
queues?: Map<string, Doc<"queues">>
|
||||
paginate: PaginateHandler
|
||||
}) {
|
||||
const createFilterBuilder = () => {
|
||||
const builder: Record<string, (..._args: unknown[]) => typeof builder> = {}
|
||||
builder.eq = () => builder
|
||||
builder.gte = () => builder
|
||||
builder.lte = () => builder
|
||||
builder.or = () => builder
|
||||
return builder
|
||||
}
|
||||
|
||||
return {
|
||||
db: {
|
||||
get: vi.fn(async (id: Id<"machines"> | Id<"queues">) => {
|
||||
if (machine && id === machine._id) return machine
|
||||
const queue = queues.get(String(id))
|
||||
if (queue) return queue
|
||||
return null
|
||||
}),
|
||||
query: vi.fn(() => {
|
||||
const chain = {
|
||||
filter: vi.fn((cb?: (builder: ReturnType<typeof createFilterBuilder>) => unknown) => {
|
||||
cb?.(createFilterBuilder())
|
||||
return chain
|
||||
}),
|
||||
paginate: vi.fn((options: { cursor: string | null; numItems: number }) => paginate(options)),
|
||||
}
|
||||
|
||||
return {
|
||||
withIndex: vi.fn((_indexName: string, cb?: (builder: ReturnType<typeof createFilterBuilder>) => unknown) => {
|
||||
cb?.(createFilterBuilder())
|
||||
return {
|
||||
order: vi.fn(() => chain),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
},
|
||||
} as unknown as Parameters<typeof listTicketsHistoryHandler>[0]
|
||||
}
|
||||
|
||||
describe("convex.machines.listTicketsHistory", () => {
|
||||
it("maps tickets metadata and resolves queue names", async () => {
|
||||
const machine = buildMachine()
|
||||
const ticket = buildTicket({
|
||||
_id: "ticket_1" as Id<"tickets">,
|
||||
subject: "Printer offline",
|
||||
priority: "HIGH",
|
||||
status: "PENDING",
|
||||
queueId: "queue_1" as Id<"queues">,
|
||||
updatedAt: 170000,
|
||||
createdAt: 160000,
|
||||
})
|
||||
|
||||
const paginate = vi.fn(async () => ({
|
||||
page: [ticket],
|
||||
isDone: false,
|
||||
continueCursor: "cursor-next",
|
||||
}))
|
||||
|
||||
const queues = new Map<string, Doc<"queues">>([
|
||||
["queue_1", { _id: "queue_1" as Id<"queues">, name: "Atendimento", tenantId: TENANT_ID } as Doc<"queues">],
|
||||
])
|
||||
|
||||
const ctx = createCtx({ machine, queues, paginate })
|
||||
|
||||
const result = await listTicketsHistoryHandler(ctx, {
|
||||
machineId: machine._id,
|
||||
paginationOpts: { numItems: 25, cursor: null },
|
||||
})
|
||||
|
||||
expect(paginate).toHaveBeenCalledWith({ numItems: 25, cursor: null })
|
||||
expect(result.page).toHaveLength(1)
|
||||
expect(result.page[0]).toMatchObject({
|
||||
id: "ticket_1",
|
||||
subject: "Printer offline",
|
||||
priority: "HIGH",
|
||||
status: "PENDING",
|
||||
queue: "Atendimento",
|
||||
})
|
||||
expect(result.continueCursor).toBe("cursor-next")
|
||||
})
|
||||
|
||||
it("applies search filtering over paginated results", async () => {
|
||||
const machine = buildMachine()
|
||||
const ticketMatches = buildTicket({
|
||||
_id: "ticket_match" as Id<"tickets">,
|
||||
reference: 44321,
|
||||
subject: "Notebook com tela quebrada",
|
||||
requesterSnapshot: { name: "Carla", email: "carla@example.com", avatarUrl: undefined, teams: [] },
|
||||
})
|
||||
const ticketIgnored = buildTicket({
|
||||
_id: "ticket_other" as Id<"tickets">,
|
||||
subject: "Troca de teclado",
|
||||
requesterSnapshot: { name: "Roberto", email: "roberto@example.com", avatarUrl: undefined, teams: [] },
|
||||
})
|
||||
|
||||
const paginate = vi.fn(async () => ({
|
||||
page: [ticketMatches, ticketIgnored],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
}))
|
||||
|
||||
const ctx = createCtx({ machine, paginate })
|
||||
|
||||
const result = await listTicketsHistoryHandler(ctx, {
|
||||
machineId: machine._id,
|
||||
search: "notebook",
|
||||
paginationOpts: { numItems: 50, cursor: null },
|
||||
})
|
||||
|
||||
expect(result.page).toHaveLength(1)
|
||||
expect(result.page[0].id).toBe("ticket_match")
|
||||
expect(result.isDone).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("convex.machines.getTicketsHistoryStats", () => {
|
||||
it("aggregates totals across multiple pages respecting open status", async () => {
|
||||
const machine = buildMachine()
|
||||
const firstPageTicket = buildTicket({
|
||||
_id: "ticket_open" as Id<"tickets">,
|
||||
status: "AWAITING_ATTENDANCE",
|
||||
})
|
||||
const secondPageTicket = buildTicket({
|
||||
_id: "ticket_resolved" as Id<"tickets">,
|
||||
status: "RESOLVED",
|
||||
})
|
||||
|
||||
const paginate = vi.fn(async ({ cursor }: { cursor: string | null }) => {
|
||||
if (!cursor) {
|
||||
return { page: [firstPageTicket], isDone: false, continueCursor: "cursor-1" }
|
||||
}
|
||||
return { page: [secondPageTicket], isDone: true, continueCursor: "" }
|
||||
})
|
||||
|
||||
const ctx = createCtx({ machine, paginate })
|
||||
|
||||
const stats = await getTicketsHistoryStatsHandler(
|
||||
ctx as unknown as Parameters<typeof getTicketsHistoryStatsHandler>[0],
|
||||
{
|
||||
machineId: machine._id,
|
||||
}
|
||||
)
|
||||
|
||||
expect(stats).toEqual({ total: 2, openCount: 1, resolvedCount: 1 })
|
||||
expect(paginate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("filters results by search term when aggregating", async () => {
|
||||
const machine = buildMachine()
|
||||
const matchingTicket = buildTicket({
|
||||
_id: "ticket_search" as Id<"tickets">,
|
||||
subject: "Notebook com lentidão",
|
||||
})
|
||||
const nonMatchingTicket = buildTicket({
|
||||
_id: "ticket_ignored" as Id<"tickets">,
|
||||
subject: "Impressora parada",
|
||||
})
|
||||
|
||||
const paginate = vi.fn(async ({ cursor }: { cursor: string | null }) => {
|
||||
if (!cursor) {
|
||||
return { page: [matchingTicket, nonMatchingTicket], isDone: true, continueCursor: "" }
|
||||
}
|
||||
return { page: [], isDone: true, continueCursor: "" }
|
||||
})
|
||||
|
||||
const ctx = createCtx({ machine, paginate })
|
||||
|
||||
const stats = await getTicketsHistoryStatsHandler(
|
||||
ctx as unknown as Parameters<typeof getTicketsHistoryStatsHandler>[0],
|
||||
{
|
||||
machineId: machine._id,
|
||||
search: "notebook",
|
||||
}
|
||||
)
|
||||
|
||||
expect(stats).toEqual({ total: 1, openCount: 1, resolvedCount: 0 })
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { updatePersona } from "../convex/machines"
|
||||
import { updatePersonaHandler } from "../convex/machines"
|
||||
import type { Doc, Id } from "../convex/_generated/dataModel"
|
||||
|
||||
const FIXED_NOW = 1_706_071_200_000
|
||||
|
|
@ -63,9 +63,9 @@ describe("convex.machines.updatePersona", () => {
|
|||
return null
|
||||
})
|
||||
|
||||
const ctx = { db: { get, patch } } as unknown as Parameters<typeof updatePersona>[0]
|
||||
const ctx = { db: { get, patch } } as unknown as Parameters<typeof updatePersonaHandler>[0]
|
||||
|
||||
const result = await updatePersona(ctx, { machineId: machine._id, persona: "" })
|
||||
const result = await updatePersonaHandler(ctx, { machineId: machine._id, persona: "" })
|
||||
|
||||
expect(result).toEqual({ ok: true, persona: null })
|
||||
expect(patch).toHaveBeenCalledTimes(1)
|
||||
|
|
@ -92,10 +92,10 @@ describe("convex.machines.updatePersona", () => {
|
|||
return null
|
||||
})
|
||||
|
||||
const ctx = { db: { get, patch } } as unknown as Parameters<typeof updatePersona>[0]
|
||||
const ctx = { db: { get, patch } } as unknown as Parameters<typeof updatePersonaHandler>[0]
|
||||
|
||||
await expect(
|
||||
updatePersona(ctx, {
|
||||
updatePersonaHandler(ctx, {
|
||||
machineId: machine._id,
|
||||
persona: "collaborator",
|
||||
assignedUserId: missingUserId,
|
||||
|
|
|
|||
267
tests/reports.productivity-dashboard.test.ts
Normal file
267
tests/reports.productivity-dashboard.test.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
|
||||
vi.mock("../convex/rbac", () => ({
|
||||
requireStaff: vi.fn(),
|
||||
}))
|
||||
|
||||
import type { Doc, Id } from "../convex/_generated/dataModel"
|
||||
import {
|
||||
agentProductivityHandler,
|
||||
dashboardOverviewHandler,
|
||||
hoursByClientInternalHandler,
|
||||
} from "../convex/reports"
|
||||
import { requireStaff } from "../convex/rbac"
|
||||
import { createReportsCtx } from "./utils/report-test-helpers"
|
||||
|
||||
const TENANT_ID = "tenant-1"
|
||||
const VIEWER_ID = "user-agent" as Id<"users">
|
||||
|
||||
function buildTicket(overrides: Partial<Doc<"tickets">>): Doc<"tickets"> {
|
||||
const base: Record<string, unknown> = {
|
||||
_id: "ticket_base" as Id<"tickets">,
|
||||
tenantId: TENANT_ID,
|
||||
reference: 50000,
|
||||
subject: "Chamado",
|
||||
summary: null,
|
||||
status: "PENDING",
|
||||
priority: "MEDIUM",
|
||||
channel: "EMAIL",
|
||||
queueId: undefined,
|
||||
requesterId: "user_req" as Id<"users">,
|
||||
requesterSnapshot: { name: "Alice", email: "alice@example.com", avatarUrl: undefined, teams: [] },
|
||||
assigneeId: "user_assignee" as Id<"users">,
|
||||
assigneeSnapshot: { name: "Bob", email: "bob@example.com", avatarUrl: undefined, teams: [] },
|
||||
companyId: undefined,
|
||||
companySnapshot: undefined,
|
||||
machineId: undefined,
|
||||
machineSnapshot: undefined,
|
||||
working: false,
|
||||
dueAt: undefined,
|
||||
firstResponseAt: undefined,
|
||||
resolvedAt: undefined,
|
||||
closedAt: undefined,
|
||||
updatedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
tags: [],
|
||||
customFields: [],
|
||||
totalWorkedMs: 0,
|
||||
internalWorkedMs: 0,
|
||||
externalWorkedMs: 0,
|
||||
activeSessionId: undefined,
|
||||
}
|
||||
return { ...(base as Doc<"tickets">), ...overrides }
|
||||
}
|
||||
|
||||
function buildCompany(overrides: Partial<Doc<"companies">>): Doc<"companies"> {
|
||||
const base: Record<string, unknown> = {
|
||||
_id: "company_base" as Id<"companies">,
|
||||
tenantId: TENANT_ID,
|
||||
name: "Empresa",
|
||||
slug: "empresa",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
isAvulso: false,
|
||||
contractedHoursPerMonth: 40,
|
||||
}
|
||||
return { ...(base as Doc<"companies">), ...overrides }
|
||||
}
|
||||
|
||||
describe("convex.reports.agentProductivity", () => {
|
||||
const requireStaffMock = vi.mocked(requireStaff)
|
||||
const FIXED_NOW = Date.UTC(2024, 6, 10, 12, 0, 0)
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(FIXED_NOW)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("aggregates per-agent metrics including work sessions", async () => {
|
||||
requireStaffMock.mockResolvedValue({
|
||||
role: "ADMIN",
|
||||
user: { companyId: undefined },
|
||||
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
|
||||
|
||||
const agentA = { _id: "agent_a" as Id<"users">, name: "Ana", email: "ana@example.com" } as Doc<"users">
|
||||
const agentB = { _id: "agent_b" as Id<"users">, name: "Bruno", email: "bruno@example.com" } as Doc<"users">
|
||||
|
||||
const tickets = [
|
||||
buildTicket({
|
||||
_id: "ticket_open" as Id<"tickets">,
|
||||
assigneeId: agentA._id,
|
||||
createdAt: Date.UTC(2024, 6, 9, 9, 0, 0),
|
||||
status: "PENDING",
|
||||
firstResponseAt: Date.UTC(2024, 6, 9, 9, 30, 0),
|
||||
internalWorkedMs: 45 * 60 * 1000,
|
||||
}),
|
||||
buildTicket({
|
||||
_id: "ticket_resolved" as Id<"tickets">,
|
||||
assigneeId: agentA._id,
|
||||
createdAt: Date.UTC(2024, 6, 8, 10, 0, 0),
|
||||
firstResponseAt: Date.UTC(2024, 6, 8, 10, 20, 0),
|
||||
resolvedAt: Date.UTC(2024, 6, 8, 12, 0, 0),
|
||||
status: "RESOLVED",
|
||||
}),
|
||||
buildTicket({
|
||||
_id: "ticket_old" as Id<"tickets">,
|
||||
assigneeId: agentB._id,
|
||||
createdAt: Date.UTC(2024, 5, 20, 10, 0, 0),
|
||||
status: "RESOLVED",
|
||||
}),
|
||||
]
|
||||
|
||||
const sessionsMap = new Map<string, Array<{ agentId: Id<"users">; startedAt: number; stoppedAt?: number; durationMs?: number }>>([
|
||||
[
|
||||
String(agentA._id),
|
||||
[
|
||||
{ agentId: agentA._id, startedAt: Date.UTC(2024, 6, 9, 9, 0, 0), stoppedAt: Date.UTC(2024, 6, 9, 10, 0, 0) },
|
||||
{ agentId: agentA._id, startedAt: Date.UTC(2024, 6, 8, 11, 0, 0), durationMs: 30 * 60 * 1000 },
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
const ctx = createReportsCtx({
|
||||
tickets,
|
||||
users: new Map<string, Doc<"users">>([
|
||||
[String(agentA._id), agentA],
|
||||
[String(agentB._id), agentB],
|
||||
]),
|
||||
ticketWorkSessionsByAgent: sessionsMap,
|
||||
}) as Parameters<typeof agentProductivityHandler>[0]
|
||||
|
||||
const result = await agentProductivityHandler(ctx, {
|
||||
tenantId: TENANT_ID,
|
||||
viewerId: VIEWER_ID,
|
||||
range: "7d",
|
||||
})
|
||||
|
||||
expect(result.rangeDays).toBe(7)
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]).toMatchObject({
|
||||
agentId: agentA._id,
|
||||
open: 1,
|
||||
resolved: 1,
|
||||
avgFirstResponseMinutes: 25,
|
||||
})
|
||||
expect(result.items[0]?.workedHours).toBeCloseTo(1.5, 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("convex.reports.dashboardOverview", () => {
|
||||
const requireStaffMock = vi.mocked(requireStaff)
|
||||
const FIXED_NOW = Date.UTC(2024, 6, 15, 12, 0, 0)
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(FIXED_NOW)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("returns trend metrics for new, in-progress and resolution data", async () => {
|
||||
requireStaffMock.mockResolvedValue({
|
||||
role: "ADMIN",
|
||||
user: { companyId: undefined },
|
||||
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
|
||||
|
||||
const tickets = [
|
||||
buildTicket({
|
||||
_id: "ticket_new" as Id<"tickets">,
|
||||
createdAt: Date.UTC(2024, 6, 15, 8, 0, 0),
|
||||
firstResponseAt: Date.UTC(2024, 6, 15, 8, 30, 0),
|
||||
status: "PENDING",
|
||||
}),
|
||||
buildTicket({
|
||||
_id: "ticket_resolved_recent" as Id<"tickets">,
|
||||
createdAt: Date.UTC(2024, 6, 8, 9, 0, 0),
|
||||
firstResponseAt: Date.UTC(2024, 6, 8, 9, 15, 0),
|
||||
resolvedAt: Date.UTC(2024, 6, 13, 12, 0, 0),
|
||||
status: "RESOLVED",
|
||||
}),
|
||||
buildTicket({
|
||||
_id: "ticket_prev" as Id<"tickets">,
|
||||
createdAt: Date.UTC(2024, 6, 13, 9, 0, 0),
|
||||
firstResponseAt: Date.UTC(2024, 6, 13, 9, 45, 0),
|
||||
status: "PAUSED",
|
||||
dueAt: Date.UTC(2024, 6, 14, 9, 0, 0),
|
||||
}),
|
||||
buildTicket({
|
||||
_id: "ticket_prev_resolved" as Id<"tickets">,
|
||||
createdAt: Date.UTC(2024, 6, 5, 9, 0, 0),
|
||||
firstResponseAt: Date.UTC(2024, 6, 5, 9, 10, 0),
|
||||
resolvedAt: Date.UTC(2024, 6, 7, 10, 0, 0),
|
||||
status: "RESOLVED",
|
||||
}),
|
||||
]
|
||||
|
||||
const ctx = createReportsCtx({ tickets }) as Parameters<typeof dashboardOverviewHandler>[0]
|
||||
|
||||
const result = await dashboardOverviewHandler(ctx, {
|
||||
tenantId: TENANT_ID,
|
||||
viewerId: VIEWER_ID,
|
||||
})
|
||||
|
||||
expect(result.newTickets.last24h).toBe(1)
|
||||
expect(result.inProgress.current).toBe(2)
|
||||
expect(result.awaitingAction.total).toBe(2)
|
||||
expect(result.resolution.resolvedLast7d).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("convex.reports.hoursByClientInternal", () => {
|
||||
const FIXED_NOW = Date.UTC(2024, 7, 1, 12, 0, 0)
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(FIXED_NOW)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("sums internal and external hours per company", async () => {
|
||||
const companyA = buildCompany({ _id: "company_a" as Id<"companies">, name: "Empresa A" })
|
||||
const companyB = buildCompany({ _id: "company_b" as Id<"companies">, name: "Empresa B", isAvulso: true })
|
||||
|
||||
const tickets = [
|
||||
buildTicket({
|
||||
_id: "ticket_a" as Id<"tickets">,
|
||||
companyId: companyA._id,
|
||||
updatedAt: Date.UTC(2024, 6, 30, 10, 0, 0),
|
||||
internalWorkedMs: 2 * 3600000,
|
||||
externalWorkedMs: 3600000,
|
||||
}),
|
||||
buildTicket({
|
||||
_id: "ticket_b" as Id<"tickets">,
|
||||
companyId: companyB._id,
|
||||
updatedAt: Date.UTC(2024, 6, 29, 14, 0, 0),
|
||||
internalWorkedMs: 0,
|
||||
externalWorkedMs: 2 * 3600000,
|
||||
}),
|
||||
]
|
||||
|
||||
const ctx = createReportsCtx({
|
||||
tickets,
|
||||
companies: new Map([
|
||||
[String(companyA._id), companyA],
|
||||
[String(companyB._id), companyB],
|
||||
]),
|
||||
}) as Parameters<typeof hoursByClientInternalHandler>[0]
|
||||
|
||||
const result = await hoursByClientInternalHandler(ctx, { tenantId: TENANT_ID, range: "7d" })
|
||||
|
||||
expect(result.rangeDays).toBe(7)
|
||||
expect(result.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ companyId: companyA._id, internalMs: 2 * 3600000, externalMs: 3600000 }),
|
||||
expect.objectContaining({ companyId: companyB._id, internalMs: 0, externalMs: 2 * 3600000 }),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
199
tests/reports.sla-backlog.test.ts
Normal file
199
tests/reports.sla-backlog.test.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
|
||||
vi.mock("../convex/rbac", () => ({
|
||||
requireStaff: vi.fn(),
|
||||
}))
|
||||
|
||||
import type { Doc, Id } from "../convex/_generated/dataModel"
|
||||
import { backlogOverviewHandler, slaOverviewHandler } from "../convex/reports"
|
||||
import { requireStaff } from "../convex/rbac"
|
||||
import { createReportsCtx } from "./utils/report-test-helpers"
|
||||
|
||||
const TENANT_ID = "tenant-1"
|
||||
const VIEWER_ID = "user-staff" as Id<"users">
|
||||
|
||||
function buildTicket(overrides: Partial<Doc<"tickets">>): Doc<"tickets"> {
|
||||
const base: Record<string, unknown> = {
|
||||
_id: "ticket_base" as Id<"tickets">,
|
||||
tenantId: TENANT_ID,
|
||||
reference: 50000,
|
||||
subject: "Chamado",
|
||||
summary: null,
|
||||
status: "PENDING",
|
||||
priority: "MEDIUM",
|
||||
channel: "EMAIL",
|
||||
queueId: undefined,
|
||||
requesterId: "user_req" as Id<"users">,
|
||||
requesterSnapshot: { name: "Alice", email: "alice@example.com", avatarUrl: undefined, teams: [] },
|
||||
assigneeId: "user_agent" as Id<"users">,
|
||||
assigneeSnapshot: { name: "Bob", email: "bob@example.com", avatarUrl: undefined, teams: [] },
|
||||
companyId: undefined,
|
||||
companySnapshot: undefined,
|
||||
machineId: undefined,
|
||||
machineSnapshot: undefined,
|
||||
working: false,
|
||||
dueAt: undefined,
|
||||
firstResponseAt: undefined,
|
||||
resolvedAt: undefined,
|
||||
closedAt: undefined,
|
||||
updatedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
tags: [],
|
||||
customFields: [],
|
||||
totalWorkedMs: 0,
|
||||
internalWorkedMs: 0,
|
||||
externalWorkedMs: 0,
|
||||
activeSessionId: undefined,
|
||||
}
|
||||
return { ...(base as Doc<"tickets">), ...overrides }
|
||||
}
|
||||
|
||||
function buildQueue(overrides: Partial<Doc<"queues">>): Doc<"queues"> {
|
||||
const base: Record<string, unknown> = {
|
||||
_id: "queue_base" as Id<"queues">,
|
||||
tenantId: TENANT_ID,
|
||||
name: "Suporte",
|
||||
slug: "suporte",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
return { ...(base as Doc<"queues">), ...overrides }
|
||||
}
|
||||
|
||||
describe("convex.reports.slaOverview", () => {
|
||||
const requireStaffMock = vi.mocked(requireStaff)
|
||||
const FIXED_NOW = Date.UTC(2024, 4, 8, 12, 0, 0)
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(FIXED_NOW)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("summarizes totals, averages and queue breakdown", async () => {
|
||||
requireStaffMock.mockResolvedValue({
|
||||
role: "ADMIN",
|
||||
user: { companyId: undefined },
|
||||
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
|
||||
|
||||
const queue = buildQueue({
|
||||
_id: "queue_1" as Id<"queues">,
|
||||
name: "Suporte Nível 1",
|
||||
})
|
||||
|
||||
const tickets = [
|
||||
buildTicket({
|
||||
_id: "ticket_open" as Id<"tickets">,
|
||||
status: "PENDING",
|
||||
queueId: queue._id,
|
||||
createdAt: Date.UTC(2024, 4, 7, 9, 0, 0),
|
||||
dueAt: Date.UTC(2024, 4, 7, 11, 0, 0),
|
||||
}),
|
||||
buildTicket({
|
||||
_id: "ticket_resolved" as Id<"tickets">,
|
||||
status: "RESOLVED",
|
||||
queueId: queue._id,
|
||||
createdAt: Date.UTC(2024, 4, 6, 8, 0, 0),
|
||||
firstResponseAt: Date.UTC(2024, 4, 6, 8, 30, 0),
|
||||
resolvedAt: Date.UTC(2024, 4, 6, 10, 0, 0),
|
||||
}),
|
||||
]
|
||||
|
||||
const ctx = createReportsCtx({ tickets, queues: [queue] }) as Parameters<typeof slaOverviewHandler>[0]
|
||||
|
||||
const result = await slaOverviewHandler(ctx, {
|
||||
tenantId: TENANT_ID,
|
||||
viewerId: VIEWER_ID,
|
||||
range: "7d",
|
||||
})
|
||||
|
||||
expect(result.rangeDays).toBe(7)
|
||||
expect(result.totals).toEqual({ total: 2, open: 1, resolved: 1, overdue: 1 })
|
||||
expect(result.response).toEqual({ averageFirstResponseMinutes: 30, responsesRegistered: 1 })
|
||||
expect(result.resolution).toEqual({ averageResolutionMinutes: 120, resolvedCount: 1 })
|
||||
expect(result.queueBreakdown).toEqual([{ id: queue._id, name: queue.name, open: 1 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("convex.reports.backlogOverview", () => {
|
||||
const requireStaffMock = vi.mocked(requireStaff)
|
||||
const FIXED_NOW = Date.UTC(2024, 6, 1, 12, 0, 0)
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(FIXED_NOW)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("aggregates status, priority and queue counts for open tickets", async () => {
|
||||
requireStaffMock.mockResolvedValue({
|
||||
role: "ADMIN",
|
||||
user: { companyId: undefined },
|
||||
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
|
||||
|
||||
const queueA = buildQueue({ _id: "queue_a" as Id<"queues">, name: "Atendimento" })
|
||||
const queueB = buildQueue({ _id: "queue_b" as Id<"queues">, name: "Infraestrutura" })
|
||||
|
||||
const tickets = [
|
||||
buildTicket({
|
||||
_id: "ticket_pending" as Id<"tickets">,
|
||||
status: "PENDING",
|
||||
priority: "HIGH",
|
||||
queueId: queueA._id,
|
||||
createdAt: Date.UTC(2024, 5, 28, 10, 0, 0),
|
||||
}),
|
||||
buildTicket({
|
||||
_id: "ticket_paused" as Id<"tickets">,
|
||||
status: "PAUSED",
|
||||
priority: "MEDIUM",
|
||||
queueId: queueB._id,
|
||||
createdAt: Date.UTC(2024, 5, 29, 15, 0, 0),
|
||||
}),
|
||||
buildTicket({
|
||||
_id: "ticket_resolved" as Id<"tickets">,
|
||||
status: "RESOLVED",
|
||||
priority: "URGENT",
|
||||
queueId: undefined,
|
||||
createdAt: Date.UTC(2024, 5, 27, 9, 0, 0),
|
||||
}),
|
||||
]
|
||||
|
||||
const ctx = createReportsCtx({
|
||||
tickets,
|
||||
createdRangeTickets: tickets,
|
||||
queues: [queueA, queueB],
|
||||
}) as Parameters<typeof backlogOverviewHandler>[0]
|
||||
|
||||
const result = await backlogOverviewHandler(ctx, {
|
||||
tenantId: TENANT_ID,
|
||||
viewerId: VIEWER_ID,
|
||||
range: "7d",
|
||||
})
|
||||
|
||||
expect(result.rangeDays).toBe(7)
|
||||
expect(result.statusCounts).toEqual({
|
||||
PENDING: 1,
|
||||
PAUSED: 1,
|
||||
RESOLVED: 1,
|
||||
})
|
||||
expect(result.priorityCounts).toEqual({
|
||||
HIGH: 1,
|
||||
MEDIUM: 1,
|
||||
URGENT: 1,
|
||||
})
|
||||
expect(result.totalOpen).toBe(2)
|
||||
expect(result.queueCounts).toHaveLength(2)
|
||||
expect(result.queueCounts).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ id: "queue_a", name: queueA.name, total: 1 },
|
||||
{ id: "queue_b", name: queueB.name, total: 1 },
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
111
tests/reports.ticketsByChannel.test.ts
Normal file
111
tests/reports.ticketsByChannel.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
|
||||
vi.mock("../convex/rbac", () => ({
|
||||
requireStaff: vi.fn(),
|
||||
}))
|
||||
|
||||
import type { Doc, Id } from "../convex/_generated/dataModel"
|
||||
import { ticketsByChannelHandler } from "../convex/reports"
|
||||
import { requireStaff } from "../convex/rbac"
|
||||
import { createReportsCtx } from "./utils/report-test-helpers"
|
||||
|
||||
const TENANT_ID = "tenant-1"
|
||||
const VIEWER_ID = "user-viewer" as Id<"users">
|
||||
|
||||
function buildTicket(overrides: Partial<Doc<"tickets">>): Doc<"tickets"> {
|
||||
const base: Record<string, unknown> = {
|
||||
_id: "ticket_base" as Id<"tickets">,
|
||||
tenantId: TENANT_ID,
|
||||
reference: 50000,
|
||||
subject: "Chamado",
|
||||
summary: null,
|
||||
status: "PENDING",
|
||||
priority: "MEDIUM",
|
||||
channel: "EMAIL",
|
||||
queueId: undefined,
|
||||
requesterId: "user_req" as Id<"users">,
|
||||
requesterSnapshot: { name: "Alice", email: "alice@example.com", avatarUrl: undefined, teams: [] },
|
||||
assigneeId: "user_assignee" as Id<"users">,
|
||||
assigneeSnapshot: { name: "Bob", email: "bob@example.com", avatarUrl: undefined, teams: [] },
|
||||
companyId: undefined,
|
||||
companySnapshot: undefined,
|
||||
machineId: undefined,
|
||||
machineSnapshot: undefined,
|
||||
working: false,
|
||||
dueAt: undefined,
|
||||
firstResponseAt: undefined,
|
||||
resolvedAt: undefined,
|
||||
closedAt: undefined,
|
||||
updatedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
tags: [],
|
||||
customFields: [],
|
||||
totalWorkedMs: 0,
|
||||
internalWorkedMs: 0,
|
||||
externalWorkedMs: 0,
|
||||
activeSessionId: undefined,
|
||||
}
|
||||
return { ...(base as Doc<"tickets">), ...overrides }
|
||||
}
|
||||
|
||||
describe("convex.reports.ticketsByChannel", () => {
|
||||
const requireStaffMock = vi.mocked(requireStaff)
|
||||
const FIXED_NOW = Date.UTC(2024, 4, 8, 12, 0, 0) // 8 May 2024 12:00 UTC
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(FIXED_NOW)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("builds timeline grouped by channel within the requested range", async () => {
|
||||
requireStaffMock.mockResolvedValue({
|
||||
role: "ADMIN",
|
||||
user: { companyId: undefined },
|
||||
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
|
||||
|
||||
const tickets = [
|
||||
buildTicket({
|
||||
_id: "ticket_email" as Id<"tickets">,
|
||||
channel: "EMAIL",
|
||||
createdAt: Date.UTC(2024, 4, 7, 10, 0, 0),
|
||||
}),
|
||||
buildTicket({
|
||||
_id: "ticket_chat" as Id<"tickets">,
|
||||
channel: "CHAT",
|
||||
createdAt: Date.UTC(2024, 4, 7, 12, 0, 0),
|
||||
}),
|
||||
buildTicket({
|
||||
_id: "ticket_other" as Id<"tickets">,
|
||||
channel: undefined,
|
||||
createdAt: Date.UTC(2024, 4, 6, 9, 0, 0),
|
||||
}),
|
||||
buildTicket({
|
||||
_id: "ticket_outside" as Id<"tickets">,
|
||||
createdAt: Date.UTC(2024, 3, 25, 12, 0, 0), // outside 7-day window
|
||||
}),
|
||||
]
|
||||
|
||||
const ctx = createReportsCtx({ tickets }) as Parameters<typeof ticketsByChannelHandler>[0]
|
||||
|
||||
const result = await ticketsByChannelHandler(ctx, {
|
||||
tenantId: TENANT_ID,
|
||||
viewerId: VIEWER_ID,
|
||||
range: "7d",
|
||||
})
|
||||
|
||||
expect(result.rangeDays).toBe(7)
|
||||
expect(result.channels).toEqual(["CHAT", "EMAIL", "OUTRO"])
|
||||
|
||||
const may06 = result.points.find((point) => point.date === "2024-05-06")
|
||||
const may07 = result.points.find((point) => point.date === "2024-05-07")
|
||||
|
||||
expect(may06?.values).toEqual({ CHAT: 0, EMAIL: 0, OUTRO: 1 })
|
||||
expect(may07?.values).toEqual({ CHAT: 1, EMAIL: 1, OUTRO: 0 })
|
||||
expect(may06).toBeTruthy()
|
||||
expect(may07).toBeTruthy()
|
||||
})
|
||||
})
|
||||
260
tests/reports.timeline-hours.test.ts
Normal file
260
tests/reports.timeline-hours.test.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
|
||||
vi.mock("../convex/rbac", () => ({
|
||||
requireStaff: vi.fn(),
|
||||
}))
|
||||
|
||||
import type { Doc, Id } from "../convex/_generated/dataModel"
|
||||
import {
|
||||
csatOverviewHandler,
|
||||
hoursByClientHandler,
|
||||
openedResolvedByDayHandler,
|
||||
} from "../convex/reports"
|
||||
import { requireStaff } from "../convex/rbac"
|
||||
import { createReportsCtx } from "./utils/report-test-helpers"
|
||||
|
||||
const TENANT_ID = "tenant-1"
|
||||
const VIEWER_ID = "user-reports" as Id<"users">
|
||||
|
||||
function buildTicket(overrides: Partial<Doc<"tickets">>): Doc<"tickets"> {
|
||||
const base: Record<string, unknown> = {
|
||||
_id: "ticket_base" as Id<"tickets">,
|
||||
tenantId: TENANT_ID,
|
||||
reference: 50000,
|
||||
subject: "Chamado",
|
||||
summary: null,
|
||||
status: "PENDING",
|
||||
priority: "MEDIUM",
|
||||
channel: "EMAIL",
|
||||
queueId: undefined,
|
||||
requesterId: "user_req" as Id<"users">,
|
||||
requesterSnapshot: { name: "Alice", email: "alice@example.com", avatarUrl: undefined, teams: [] },
|
||||
assigneeId: "user_assignee" as Id<"users">,
|
||||
assigneeSnapshot: { name: "Bob", email: "bob@example.com", avatarUrl: undefined, teams: [] },
|
||||
companyId: undefined,
|
||||
companySnapshot: undefined,
|
||||
machineId: undefined,
|
||||
machineSnapshot: undefined,
|
||||
working: false,
|
||||
dueAt: undefined,
|
||||
firstResponseAt: undefined,
|
||||
resolvedAt: undefined,
|
||||
closedAt: undefined,
|
||||
updatedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
tags: [],
|
||||
customFields: [],
|
||||
totalWorkedMs: 0,
|
||||
internalWorkedMs: 0,
|
||||
externalWorkedMs: 0,
|
||||
activeSessionId: undefined,
|
||||
}
|
||||
return { ...(base as Doc<"tickets">), ...overrides }
|
||||
}
|
||||
|
||||
function buildCompany(overrides: Partial<Doc<"companies">>): Doc<"companies"> {
|
||||
const base: Record<string, unknown> = {
|
||||
_id: "company_base" as Id<"companies">,
|
||||
tenantId: TENANT_ID,
|
||||
name: "Empresa",
|
||||
slug: "empresa",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
isAvulso: false,
|
||||
contractedHoursPerMonth: 40,
|
||||
}
|
||||
return { ...(base as Doc<"companies">), ...overrides }
|
||||
}
|
||||
|
||||
describe("convex.reports.openedResolvedByDay", () => {
|
||||
const requireStaffMock = vi.mocked(requireStaff)
|
||||
const FIXED_NOW = Date.UTC(2024, 4, 15, 12, 0, 0)
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(FIXED_NOW)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("counts opened and resolved tickets per day within the range", async () => {
|
||||
requireStaffMock.mockResolvedValue({
|
||||
role: "ADMIN",
|
||||
user: { companyId: undefined },
|
||||
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
|
||||
|
||||
const tickets = [
|
||||
buildTicket({
|
||||
_id: "ticket_open" as Id<"tickets">,
|
||||
createdAt: Date.UTC(2024, 4, 13, 9, 0, 0),
|
||||
status: "PENDING",
|
||||
}),
|
||||
buildTicket({
|
||||
_id: "ticket_resolved" as Id<"tickets">,
|
||||
createdAt: Date.UTC(2024, 4, 12, 10, 0, 0),
|
||||
resolvedAt: Date.UTC(2024, 4, 14, 8, 0, 0),
|
||||
status: "RESOLVED",
|
||||
}),
|
||||
buildTicket({
|
||||
_id: "ticket_old" as Id<"tickets">,
|
||||
createdAt: Date.UTC(2024, 3, 30, 10, 0, 0),
|
||||
status: "PENDING",
|
||||
}),
|
||||
]
|
||||
|
||||
const ctx = createReportsCtx({ tickets }) as Parameters<typeof openedResolvedByDayHandler>[0]
|
||||
|
||||
const result = await openedResolvedByDayHandler(ctx, {
|
||||
tenantId: TENANT_ID,
|
||||
viewerId: VIEWER_ID,
|
||||
range: "7d",
|
||||
})
|
||||
|
||||
const opened13 = result.series.find((point) => point.date === "2024-05-13")
|
||||
const resolved14 = result.series.find((point) => point.date === "2024-05-14")
|
||||
|
||||
expect(result.rangeDays).toBe(7)
|
||||
expect(opened13).toMatchObject({ opened: 1, resolved: 0 })
|
||||
expect(resolved14).toMatchObject({ opened: 0, resolved: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("convex.reports.csatOverview", () => {
|
||||
const requireStaffMock = vi.mocked(requireStaff)
|
||||
const FIXED_NOW = Date.UTC(2024, 4, 20, 12, 0, 0)
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(FIXED_NOW)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("summarizes survey averages and distribution", async () => {
|
||||
requireStaffMock.mockResolvedValue({
|
||||
role: "ADMIN",
|
||||
user: { companyId: undefined },
|
||||
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
|
||||
|
||||
const ticketA = buildTicket({
|
||||
_id: "ticket_a" as Id<"tickets">,
|
||||
createdAt: Date.UTC(2024, 4, 18, 9, 0, 0),
|
||||
})
|
||||
const ticketB = buildTicket({
|
||||
_id: "ticket_b" as Id<"tickets">,
|
||||
createdAt: Date.UTC(2024, 4, 17, 15, 0, 0),
|
||||
})
|
||||
|
||||
const eventsByTicket = new Map<string, Array<{ type: string; payload?: unknown; createdAt: number }>>([
|
||||
["ticket_a", [{ type: "CSAT_RECEIVED", payload: { score: 5 }, createdAt: Date.UTC(2024, 4, 19, 8, 0, 0) }]],
|
||||
[
|
||||
"ticket_b",
|
||||
[
|
||||
{ type: "CSAT_RECEIVED", payload: { score: 3 }, createdAt: Date.UTC(2024, 4, 18, 14, 0, 0) },
|
||||
{ type: "COMMENT", payload: {}, createdAt: Date.UTC(2024, 4, 18, 15, 0, 0) },
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
const ctx = createReportsCtx({ tickets: [ticketA, ticketB], ticketEventsByTicket: eventsByTicket }) as Parameters<typeof csatOverviewHandler>[0]
|
||||
|
||||
const result = await csatOverviewHandler(ctx, {
|
||||
tenantId: TENANT_ID,
|
||||
viewerId: VIEWER_ID,
|
||||
range: "7d",
|
||||
})
|
||||
|
||||
expect(result.rangeDays).toBe(7)
|
||||
expect(result.totalSurveys).toBe(2)
|
||||
expect(result.averageScore).toBe(4)
|
||||
expect(result.distribution.find((entry) => entry.score === 5)?.total).toBe(1)
|
||||
expect(result.distribution.find((entry) => entry.score === 3)?.total).toBe(1)
|
||||
expect(result.recent[0]?.ticketId).toBe("ticket_a")
|
||||
})
|
||||
})
|
||||
|
||||
describe("convex.reports.hoursByClient", () => {
|
||||
const requireStaffMock = vi.mocked(requireStaff)
|
||||
const FIXED_NOW = Date.UTC(2024, 5, 5, 12, 0, 0)
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(FIXED_NOW)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("aggregates worked hours by company", async () => {
|
||||
requireStaffMock.mockResolvedValue({
|
||||
role: "ADMIN",
|
||||
user: { companyId: undefined },
|
||||
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
|
||||
|
||||
const companyA = buildCompany({ _id: "company_a" as Id<"companies">, name: "Empresa A", contractedHoursPerMonth: 60 })
|
||||
const companyB = buildCompany({ _id: "company_b" as Id<"companies">, name: "Empresa B", isAvulso: true })
|
||||
|
||||
const tickets = [
|
||||
buildTicket({
|
||||
_id: "ticket_a" as Id<"tickets">,
|
||||
companyId: companyA._id,
|
||||
updatedAt: Date.UTC(2024, 5, 4, 10, 0, 0),
|
||||
internalWorkedMs: 3 * 3600000,
|
||||
externalWorkedMs: 1 * 3600000,
|
||||
}),
|
||||
buildTicket({
|
||||
_id: "ticket_b" as Id<"tickets">,
|
||||
companyId: companyB._id,
|
||||
updatedAt: Date.UTC(2024, 5, 3, 15, 0, 0),
|
||||
internalWorkedMs: 3600000,
|
||||
externalWorkedMs: 2 * 3600000,
|
||||
}),
|
||||
buildTicket({
|
||||
_id: "ticket_old" as Id<"tickets">,
|
||||
companyId: companyA._id,
|
||||
updatedAt: Date.UTC(2024, 4, 20, 12, 0, 0),
|
||||
internalWorkedMs: 5 * 3600000,
|
||||
externalWorkedMs: 0,
|
||||
}),
|
||||
]
|
||||
|
||||
const companies = new Map<string, Doc<"companies">>([
|
||||
[String(companyA._id), companyA],
|
||||
[String(companyB._id), companyB],
|
||||
])
|
||||
|
||||
const ctx = createReportsCtx({ tickets, companies }) as Parameters<typeof hoursByClientHandler>[0]
|
||||
|
||||
const result = await hoursByClientHandler(ctx, {
|
||||
tenantId: TENANT_ID,
|
||||
viewerId: VIEWER_ID,
|
||||
range: "7d",
|
||||
})
|
||||
|
||||
expect(result.rangeDays).toBe(7)
|
||||
expect(result.items).toHaveLength(2)
|
||||
expect(result.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
companyId: companyA._id,
|
||||
internalMs: 3 * 3600000,
|
||||
externalMs: 3600000,
|
||||
totalMs: 4 * 3600000,
|
||||
contractedHoursPerMonth: 60,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
companyId: companyB._id,
|
||||
internalMs: 3600000,
|
||||
externalMs: 2 * 3600000,
|
||||
totalMs: 3 * 3600000,
|
||||
isAvulso: true,
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
127
tests/utils/report-test-helpers.ts
Normal file
127
tests/utils/report-test-helpers.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { vi } from "vitest"
|
||||
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel"
|
||||
|
||||
type ReportsCtxOptions = {
|
||||
tickets?: Doc<"tickets">[]
|
||||
createdRangeTickets?: Doc<"tickets">[]
|
||||
queues?: Doc<"queues">[]
|
||||
companies?: Map<string, Doc<"companies">>
|
||||
users?: Map<string, Doc<"users">>
|
||||
ticketEventsByTicket?: Map<string, Array<{ type: string; payload?: unknown; createdAt: number }>>
|
||||
ticketWorkSessionsByAgent?: Map<string, Array<{ agentId: Id<"users">; startedAt: number; stoppedAt?: number; durationMs?: number }>>
|
||||
}
|
||||
|
||||
const noopFilterBuilder = {
|
||||
lt: () => noopFilterBuilder,
|
||||
field: () => "createdAt",
|
||||
}
|
||||
|
||||
const noopIndexBuilder = {
|
||||
eq: () => noopIndexBuilder,
|
||||
gte: () => noopIndexBuilder,
|
||||
}
|
||||
|
||||
function ticketsChain(collection: Doc<"tickets">[]) {
|
||||
const chain = {
|
||||
filter: vi.fn((cb?: (builder: typeof noopFilterBuilder) => unknown) => {
|
||||
cb?.(noopFilterBuilder)
|
||||
return chain
|
||||
}),
|
||||
order: vi.fn(() => chain),
|
||||
collect: vi.fn(async () => collection),
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
export function createReportsCtx({
|
||||
tickets = [],
|
||||
createdRangeTickets = tickets,
|
||||
queues = [],
|
||||
companies = new Map<string, Doc<"companies">>(),
|
||||
users = new Map<string, Doc<"users">>(),
|
||||
ticketEventsByTicket = new Map<string, Array<{ type: string; payload?: unknown; createdAt: number }>>(),
|
||||
ticketWorkSessionsByAgent = new Map<string, Array<{ agentId: Id<"users">; startedAt: number; stoppedAt?: number; durationMs?: number }>>(),
|
||||
}: ReportsCtxOptions = {}) {
|
||||
const db = {
|
||||
get: vi.fn(async (id: Id<"companies"> | Id<"users">) => {
|
||||
const company = companies.get(String(id))
|
||||
if (company) return company
|
||||
const user = users.get(String(id))
|
||||
if (user) return user
|
||||
return null
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "tickets") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string, cb?: (builder: typeof noopIndexBuilder) => unknown) => {
|
||||
cb?.(noopIndexBuilder)
|
||||
const collection =
|
||||
indexName.includes("created") || indexName.includes("tenant_company_created")
|
||||
? createdRangeTickets
|
||||
: tickets
|
||||
return ticketsChain(collection)
|
||||
}),
|
||||
collect: vi.fn(async () => tickets),
|
||||
}
|
||||
}
|
||||
|
||||
if (table === "queues") {
|
||||
return {
|
||||
withIndex: vi.fn((_indexName: string, cb?: (builder: typeof noopIndexBuilder) => unknown) => {
|
||||
cb?.(noopIndexBuilder)
|
||||
return {
|
||||
collect: vi.fn(async () => queues),
|
||||
}
|
||||
}),
|
||||
collect: vi.fn(async () => queues),
|
||||
}
|
||||
}
|
||||
|
||||
if (table === "ticketEvents") {
|
||||
return {
|
||||
withIndex: vi.fn((_indexName: string, cb?: (builder: { eq: (field: unknown, value: unknown) => unknown }) => unknown) => {
|
||||
let ticketId: string | null = null
|
||||
const builder = {
|
||||
eq: (_field: unknown, value: unknown) => {
|
||||
ticketId = String(value)
|
||||
return builder
|
||||
},
|
||||
}
|
||||
cb?.(builder as { eq: (field: unknown, value: unknown) => unknown })
|
||||
return {
|
||||
collect: vi.fn(async () => (ticketId ? ticketEventsByTicket.get(ticketId) ?? [] : [])),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (table === "ticketWorkSessions") {
|
||||
return {
|
||||
withIndex: vi.fn((_indexName: string, cb?: (builder: { eq: (field: unknown, value: unknown) => unknown }) => unknown) => {
|
||||
let agentId: string | null = null
|
||||
const builder = {
|
||||
eq: (_field: unknown, value: unknown) => {
|
||||
agentId = String(value)
|
||||
return builder
|
||||
},
|
||||
}
|
||||
cb?.(builder as { eq: (field: unknown, value: unknown) => unknown })
|
||||
return {
|
||||
collect: vi.fn(async () => (agentId ? ticketWorkSessionsByAgent.get(agentId) ?? [] : [])),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
collect: vi.fn(async () => []),
|
||||
})),
|
||||
collect: vi.fn(async () => []),
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
return { db } as unknown
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue