import { action, query } from "./_generated/server"; import type { QueryCtx } from "./_generated/server"; import { ConvexError, v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; import { requireStaff } from "./rbac"; import { getOfflineThresholdMs, getStaleThresholdMs } from "./machines"; export type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED"; type QueryFilterBuilder = { lt: (field: unknown, value: number) => unknown; field: (name: string) => unknown }; export const STATUS_NORMALIZE_MAP: Record = { NEW: "PENDING", PENDING: "PENDING", OPEN: "AWAITING_ATTENDANCE", AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE", ON_HOLD: "PAUSED", PAUSED: "PAUSED", RESOLVED: "RESOLVED", CLOSED: "RESOLVED", }; export function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { if (!status) return "PENDING"; const normalized = STATUS_NORMALIZE_MAP[status.toUpperCase()]; return normalized ?? "PENDING"; } function average(values: number[]) { if (values.length === 0) return null; return values.reduce((sum, value) => sum + value, 0) / values.length; } function resolveCategoryName( categoryId: string | null, snapshot: { categoryName?: string } | null, categories: Map> ) { if (categoryId) { const category = categories.get(categoryId) if (category?.name) { return category.name } } if (snapshot?.categoryName && snapshot.categoryName.trim().length > 0) { return snapshot.categoryName.trim() } return "Sem categoria" } export const OPEN_STATUSES = new Set(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]); export const ONE_DAY_MS = 24 * 60 * 60 * 1000; const REPORTS_PAGE_SIZE = 200; type PaginatedResult = { page: T[]; continueCursor?: string | null; done?: boolean; isDone?: boolean; }; async function paginateTickets( buildQuery: () => { paginate: (options: { cursor: string | null; numItems: number }) => Promise>; collect?: () => Promise; }, handler: (doc: T) => void | Promise, pageSize = REPORTS_PAGE_SIZE, ) { const query = buildQuery(); if (typeof (query as { paginate?: unknown }).paginate !== "function") { const collectFn = (query as { collect?: (() => Promise) | undefined }).collect; if (typeof collectFn !== "function") { throw new ConvexError("Query does not support paginate or collect"); } const docs = await collectFn.call(query); for (const doc of docs) { await handler(doc); } return; } let cursor: string | null = null; while (true) { const page = await query.paginate({ cursor, numItems: pageSize }); for (const doc of page.page) { await handler(doc); } const done = page.done ?? page.isDone ?? !page.continueCursor; if (done) { break; } cursor = page.continueCursor ?? null; } } function withLowerBound(range: T, field: string, value: number): T { const candidate = range as { gte?: (field: string, value: number) => unknown }; if (candidate && typeof candidate.gte === "function") { return candidate.gte(field, value) as T; } return range; } function withUpperBound(range: T, field: string, value: number): T { const candidate = range as { lt?: (field: string, value: number) => unknown }; if (candidate && typeof candidate.lt === "function") { return candidate.lt(field, value) as T; } return range; } function resolveScopedCompanyId( viewer: Awaited>, companyId?: Id<"companies">, ): Id<"companies"> | undefined { if (viewer.role === "MANAGER") { if (!viewer.user.companyId) { throw new ConvexError("Gestor não possui empresa vinculada"); } return viewer.user.companyId; } return companyId; } export async function fetchOpenScopedTickets( ctx: QueryCtx, tenantId: string, viewer: Awaited>, companyId?: Id<"companies">, ): Promise[]> { const scopedCompanyId = resolveScopedCompanyId(viewer, companyId); const statuses: TicketStatusNormalized[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]; const results: Doc<"tickets">[] = []; const seen = new Set(); for (const status of statuses) { const snapshot = await ctx.db .query("tickets") .withIndex("by_tenant_status", (q) => q.eq("tenantId", tenantId).eq("status", status)) .collect(); for (const ticket of snapshot) { if (!OPEN_STATUSES.has(normalizeStatus(ticket.status))) continue; if (scopedCompanyId && ticket.companyId !== scopedCompanyId) continue; const key = String(ticket._id); if (seen.has(key)) continue; seen.add(key); results.push(ticket); } } return results; } function percentageChange(current: number, previous: number) { if (previous === 0) { return current === 0 ? 0 : null; } return ((current - previous) / previous) * 100; } function extractScore(payload: unknown): number | null { if (typeof payload === "number") return payload; if (payload && typeof payload === "object" && "score" in payload) { const value = (payload as { score: unknown }).score; if (typeof value === "number") { return value; } } return null; } function extractMaxScore(payload: unknown): number | null { if (payload && typeof payload === "object" && "maxScore" in payload) { const value = (payload as { maxScore: unknown }).maxScore; if (typeof value === "number" && Number.isFinite(value) && value > 0) { return value; } } return null; } function extractComment(payload: unknown): string | null { if (payload && typeof payload === "object" && "comment" in payload) { const value = (payload as { comment: unknown }).comment; if (typeof value === "string") { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } } return null; } function extractAssignee(payload: unknown): { id: string | null; name: string | null } { if (!payload || typeof payload !== "object") { return { id: null, name: null } } const record = payload as Record const rawId = record["assigneeId"] const rawName = record["assigneeName"] const id = typeof rawId === "string" && rawId.trim().length > 0 ? rawId.trim() : null const name = typeof rawName === "string" && rawName.trim().length > 0 ? rawName.trim() : null return { id, name } } function isNotNull(value: T | null): value is T { return value !== null; } export async function fetchTickets(ctx: QueryCtx, tenantId: string) { const results: Doc<"tickets">[] = []; await paginateTickets( () => ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).order("desc"), (ticket) => { results.push(ticket); }, ); return results; } async function fetchCategoryMap(ctx: QueryCtx, tenantId: string) { const categories = await ctx.db .query("ticketCategories") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); const map = new Map>(); for (const category of categories) { map.set(String(category._id), category); } return map; } export async function fetchScopedTickets( ctx: QueryCtx, tenantId: string, viewer: Awaited>, ) { const scopedCompanyId = resolveScopedCompanyId(viewer); const results: Doc<"tickets">[] = []; await paginateTickets( () => { if (scopedCompanyId) { return ctx.db .query("tickets") .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId)) .order("desc"); } return ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).order("desc"); }, (ticket) => { results.push(ticket); }, ); return results; } export async function fetchScopedTicketsByCreatedRange( ctx: QueryCtx, tenantId: string, viewer: Awaited>, startMs: number, endMs: number, companyId?: Id<"companies">, ) { const scopedCompanyId = resolveScopedCompanyId(viewer, companyId); const results: Doc<"tickets">[] = []; const query = scopedCompanyId ? ctx.db .query("tickets") .withIndex("by_tenant_company_created", (q) => { let range = q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId); range = withLowerBound(range, "createdAt", startMs); range = withUpperBound(range, "createdAt", endMs); return range; }) .order("desc") : ctx.db .query("tickets") .withIndex("by_tenant_created", (q) => { let range = q.eq("tenantId", tenantId); range = withLowerBound(range, "createdAt", startMs); range = withUpperBound(range, "createdAt", endMs); return range; }) .order("desc"); const snapshot = await query.collect(); for (const ticket of snapshot) { const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null; if (createdAt === null) continue; if (createdAt < startMs || createdAt >= endMs) continue; results.push(ticket); } return results; } export async function fetchScopedTicketsByResolvedRange( ctx: QueryCtx, tenantId: string, viewer: Awaited>, startMs: number, endMs: number, companyId?: Id<"companies">, ) { const scopedCompanyId = resolveScopedCompanyId(viewer, companyId); const results: Doc<"tickets">[] = []; const query = scopedCompanyId ? ctx.db .query("tickets") .withIndex("by_tenant_company_resolved", (q) => { let range = q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId); range = withLowerBound(range, "resolvedAt", startMs); range = withUpperBound(range, "resolvedAt", endMs); return range; }) .order("desc") : ctx.db .query("tickets") .withIndex("by_tenant_resolved", (q) => { let range = q.eq("tenantId", tenantId); range = withLowerBound(range, "resolvedAt", startMs); range = withUpperBound(range, "resolvedAt", endMs); return range; }) .order("desc"); const snapshot = await query.collect(); for (const ticket of snapshot) { const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null; if (resolvedAt === null) continue; if (resolvedAt < startMs || resolvedAt >= endMs) continue; results.push(ticket); } return results; } async function fetchQueues(ctx: QueryCtx, tenantId: string) { return ctx.db .query("queues") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); } function deriveMachineStatus(machine: Doc<"machines">, now: number) { if (machine.isActive === false) { return "deactivated"; } const manualStatus = (machine.status ?? "").toLowerCase(); if (manualStatus === "maintenance" || manualStatus === "blocked") { return manualStatus; } const offlineMs = getOfflineThresholdMs(); const staleMs = getStaleThresholdMs(offlineMs); if (machine.lastHeartbeatAt) { const age = now - machine.lastHeartbeatAt; if (age <= offlineMs) return "online"; if (age <= staleMs) return "offline"; return "stale"; } return (machine.status ?? "unknown") || "unknown"; } function formatOsLabel(osName?: string | null, osVersion?: string | null) { const name = osName?.trim(); if (!name) return "Desconhecido"; const version = osVersion?.trim(); if (!version) return name; const conciseVersion = version.split(" ")[0]; if (!conciseVersion) return name; return `${name} ${conciseVersion}`.trim(); } type CsatSurvey = { ticketId: Id<"tickets">; reference: number; score: number; maxScore: number; comment: string | null; receivedAt: number; assigneeId: string | null; assigneeName: string | null; }; async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Promise { const perTicket = await Promise.all( tickets.map(async (ticket) => { if (typeof ticket.csatScore === "number") { const snapshot = (ticket.csatAssigneeSnapshot ?? null) as { name?: string | null email?: string | null } | null const assigneeId = ticket.csatAssigneeId && typeof ticket.csatAssigneeId === "string" ? ticket.csatAssigneeId : ticket.csatAssigneeId ? String(ticket.csatAssigneeId) : ticket.assigneeId ? String(ticket.assigneeId) : null const assigneeName = snapshot?.name?.trim?.() || snapshot?.email?.trim?.() || null const maxScore = typeof ticket.csatMaxScore === "number" && Number.isFinite(ticket.csatMaxScore) ? (ticket.csatMaxScore as number) : 5 const receivedAtRaw = typeof ticket.csatRatedAt === "number" ? ticket.csatRatedAt : typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : ticket.updatedAt ?? ticket.createdAt if (typeof receivedAtRaw !== "number") { return []; } return [ { ticketId: ticket._id, reference: ticket.reference, score: ticket.csatScore, maxScore, comment: typeof ticket.csatComment === "string" && ticket.csatComment.trim().length > 0 ? ticket.csatComment.trim() : null, receivedAt: receivedAtRaw, assigneeId, assigneeName, } satisfies CsatSurvey, ]; } const events = await ctx.db .query("ticketEvents") .withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id)) .collect(); return events .filter((event) => event.type === "CSAT_RECEIVED" || event.type === "CSAT_RATED") .map((event) => { const score = extractScore(event.payload); if (score === null) return null; const assignee = extractAssignee(event.payload); return { ticketId: ticket._id, reference: ticket.reference, score, maxScore: extractMaxScore(event.payload) ?? 5, comment: extractComment(event.payload), receivedAt: event.createdAt, assigneeId: assignee.id, assigneeName: assignee.name, } as CsatSurvey; }) .filter(isNotNull); }) ); return perTicket.flat(); } function formatDateKey(timestamp: number) { const date = new Date(timestamp); const year = date.getUTCFullYear(); const month = `${date.getUTCMonth() + 1}`.padStart(2, "0"); const day = `${date.getUTCDate()}`.padStart(2, "0"); return `${year}-${month}-${day}`; } 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); // Optional range filter (createdAt) for reporting purposes, similar ao backlog/csat const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; const end = new Date(); end.setUTCHours(0, 0, 0, 0); const endMs = end.getTime() + ONE_DAY_MS; const startMs = endMs - days * ONE_DAY_MS; const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId); const queues = await fetchQueues(ctx, tenantId); const categoriesMap = await fetchCategoryMap(ctx, tenantId); const now = Date.now(); const openTickets = inRange.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); const resolvedTickets = inRange.filter((ticket) => { const status = normalizeStatus(ticket.status); return status === "RESOLVED"; }); const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); const firstResponseTimes = inRange .filter((ticket) => ticket.firstResponseAt) .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); const resolutionTimes = resolvedTickets .filter((ticket) => ticket.resolvedAt) .map((ticket) => (ticket.resolvedAt! - ticket.createdAt) / 60000); const queueBreakdown = queues.map((queue) => { const count = openTickets.filter((ticket) => ticket.queueId === queue._id).length; return { id: queue._id, name: queue.name, open: count, }; }); const categoryStats = new Map< string, { categoryId: string | null categoryName: string priority: string total: number responseMet: number solutionMet: number } >() for (const ticket of inRange) { const snapshot = (ticket.slaSnapshot ?? null) as { categoryId?: Id<"ticketCategories">; categoryName?: string; priority?: string } | null const rawCategoryId = ticket.categoryId ? String(ticket.categoryId) : snapshot?.categoryId ? String(snapshot.categoryId) : null const categoryName = resolveCategoryName(rawCategoryId, snapshot, categoriesMap) const priority = (snapshot?.priority ?? ticket.priority ?? "MEDIUM").toUpperCase() const key = `${rawCategoryId ?? "uncategorized"}::${priority}` let stat = categoryStats.get(key) if (!stat) { stat = { categoryId: rawCategoryId, categoryName, priority, total: 0, responseMet: 0, solutionMet: 0, } categoryStats.set(key, stat) } stat.total += 1 if (ticket.slaResponseStatus === "met") { stat.responseMet += 1 } if (ticket.slaSolutionStatus === "met") { stat.solutionMet += 1 } } const categoryBreakdown = Array.from(categoryStats.values()) .map((entry) => ({ ...entry, responseRate: entry.total > 0 ? entry.responseMet / entry.total : null, solutionRate: entry.total > 0 ? entry.solutionMet / entry.total : null, })) .sort((a, b) => b.total - a.total) return { totals: { total: inRange.length, open: openTickets.length, resolved: resolvedTickets.length, overdue: overdueTickets.length, }, response: { averageFirstResponseMinutes: average(firstResponseTimes), responsesRegistered: firstResponseTimes.length, }, resolution: { averageResolutionMinutes: average(resolutionTimes), resolvedCount: resolutionTimes.length, }, queueBreakdown, categoryBreakdown, 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 triggerScheduledExports = action({ args: { tenantId: v.optional(v.string()), }, handler: async (_ctx, args) => { const secret = process.env.REPORTS_CRON_SECRET const baseUrl = process.env.REPORTS_CRON_BASE_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? process.env.BETTER_AUTH_URL if (!secret || !baseUrl) { console.warn("[reports] cron skip: missing REPORTS_CRON_SECRET or base URL") return { skipped: true } } const endpoint = `${baseUrl.replace(/\/$/, "")}/api/reports/schedules/run` const response = await fetch(endpoint, { method: "POST", headers: { Authorization: `Bearer ${secret}`, "Content-Type": "application/json", }, body: JSON.stringify({ tenantId: args.tenantId }), }) if (!response.ok) { const detail = await response.text().catch(() => response.statusText) throw new ConvexError(`Falha ao disparar agendamentos: ${response.status} ${detail}`) } return response.json() }, }) 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); const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; const end = new Date(); end.setUTCHours(0, 0, 0, 0); const endMs = end.getTime() + ONE_DAY_MS; const startMs = endMs - days * ONE_DAY_MS; const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId); const surveys = (await collectCsatSurveys(ctx, tickets)).filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs); const normalizeToFive = (value: CsatSurvey) => { if (!value.maxScore || value.maxScore <= 0) return value.score; return Math.min(5, Math.max(1, (value.score / value.maxScore) * 5)); }; const averageScore = average(surveys.map((item) => normalizeToFive(item))); const distribution = [1, 2, 3, 4, 5].map((score) => ({ score, total: surveys.filter((item) => Math.round(normalizeToFive(item)) === score).length, })); const positiveThreshold = 4; const positiveCount = surveys.filter((item) => normalizeToFive(item) >= positiveThreshold).length; const positiveRate = surveys.length > 0 ? positiveCount / surveys.length : null; const agentStats = new Map< string, { id: string; name: string; total: number; sum: number; positive: number } >(); for (const survey of surveys) { const normalizedScore = normalizeToFive(survey); const key = survey.assigneeId ?? "unassigned"; const existing = agentStats.get(key) ?? { id: key, name: survey.assigneeName ?? "Sem responsável", total: 0, sum: 0, positive: 0, }; existing.total += 1; existing.sum += normalizedScore; if (normalizedScore >= positiveThreshold) { existing.positive += 1; } if (survey.assigneeName && survey.assigneeName.trim().length > 0) { existing.name = survey.assigneeName.trim(); } agentStats.set(key, existing); } const byAgent = Array.from(agentStats.values()) .map((entry) => ({ agentId: entry.id === "unassigned" ? null : entry.id, agentName: entry.id === "unassigned" ? "Sem responsável" : entry.name, totalResponses: entry.total, averageScore: entry.total > 0 ? entry.sum / entry.total : null, positiveRate: entry.total > 0 ? entry.positive / entry.total : null, })) .sort((a, b) => { const diff = (b.averageScore ?? 0) - (a.averageScore ?? 0); if (Math.abs(diff) > 0.0001) return diff; return (b.totalResponses ?? 0) - (a.totalResponses ?? 0); }); return { totalSurveys: surveys.length, averageScore, distribution, recent: surveys .slice() .sort((a, b) => b.receivedAt - a.receivedAt) .slice(0, 10) .map((item) => ({ ticketId: item.ticketId, reference: item.reference, score: item.score, maxScore: item.maxScore, comment: item.comment, receivedAt: item.receivedAt, assigneeId: item.assigneeId, assigneeName: item.assigneeName, })), rangeDays: days, positiveRate, byAgent, }; } 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 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); const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; const end = new Date(); end.setUTCHours(0, 0, 0, 0); const endMs = end.getTime() + ONE_DAY_MS; const startMs = endMs - days * ONE_DAY_MS; const openedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId); const resolvedTickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId); const opened: Record = {} const resolved: Record = {} for (let i = days - 1; i >= 0; i--) { const d = new Date(endMs - (i + 1) * ONE_DAY_MS) const key = formatDateKey(d.getTime()) opened[key] = 0 resolved[key] = 0 } for (const ticket of openedTickets) { if (ticket.createdAt >= startMs && ticket.createdAt < endMs) { const key = formatDateKey(ticket.createdAt) opened[key] = (opened[key] ?? 0) + 1 } } for (const ticket of resolvedTickets) { if (typeof ticket.resolvedAt !== "number") { continue } const key = formatDateKey(ticket.resolvedAt) resolved[key] = (resolved[key] ?? 0) + 1 } const series: Array<{ date: string; opened: number; resolved: number }> = [] for (let i = days - 1; i >= 0; i--) { const d = new Date(endMs - (i + 1) * ONE_DAY_MS) const key = formatDateKey(d.getTime()) series.push({ date: key, opened: opened[key] ?? 0, resolved: resolved[key] ?? 0 }) } 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 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; const end = new Date(); end.setUTCHours(0, 0, 0, 0); const endMs = end.getTime() + ONE_DAY_MS; const startMs = endMs - days * ONE_DAY_MS; const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId); const statusCounts = inRange.reduce>((acc, ticket) => { const status = normalizeStatus(ticket.status); acc[status] = (acc[status] ?? 0) + 1; return acc; }, {} as Record); const priorityCounts = inRange.reduce>((acc, ticket) => { acc[ticket.priority] = (acc[ticket.priority] ?? 0) + 1; return acc; }, {}); const openTickets = inRange.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); const queueMap = new Map(); for (const ticket of openTickets) { const queueId = ticket.queueId ? ticket.queueId : "sem-fila"; const current = queueMap.get(queueId) ?? { name: queueId === "sem-fila" ? "Sem fila" : "", count: 0 }; current.count += 1; queueMap.set(queueId, current); } const queues = await fetchQueues(ctx, tenantId); for (const queue of queues) { const entry = queueMap.get(queue._id) ?? { name: queue.name, count: 0 }; entry.name = queue.name; queueMap.set(queue._id, entry); } return { rangeDays: days, statusCounts, priorityCounts, queueCounts: Array.from(queueMap.entries()).map(([id, data]) => ({ id, name: data.name, total: data.count, })), 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, }); type QueueTrendPoint = { date: string; opened: number; resolved: number } type QueueTrendEntry = { id: string name: string openedTotal: number resolvedTotal: number series: Map } export async function queueLoadTrendHandler( ctx: QueryCtx, { tenantId, viewerId, range, limit, }: { tenantId: string; viewerId: Id<"users">; range?: string; limit?: number } ) { const viewer = await requireStaff(ctx, viewerId, tenantId) const days = range === "90d" ? 90 : range === "30d" ? 30 : 14 const end = new Date() end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - days * ONE_DAY_MS const openedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs) const resolvedTickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs) const queues = await fetchQueues(ctx, tenantId) const queueNames = new Map() queues.forEach((queue) => queueNames.set(String(queue._id), queue.name)) queueNames.set("unassigned", "Sem fila") const dayKeys: string[] = [] for (let i = days - 1; i >= 0; i--) { const key = formatDateKey(endMs - (i + 1) * ONE_DAY_MS) dayKeys.push(key) } const stats = new Map() const ensureEntry = (queueId: string) => { if (!stats.has(queueId)) { const series = new Map() dayKeys.forEach((key) => { series.set(key, { date: key, opened: 0, resolved: 0 }) }) stats.set(queueId, { id: queueId, name: queueNames.get(queueId) ?? "Sem fila", openedTotal: 0, resolvedTotal: 0, series, }) } return stats.get(queueId)! } for (const ticket of openedTickets) { const queueId = ticket.queueId ? String(ticket.queueId) : "unassigned" const entry = ensureEntry(queueId) const bucket = entry.series.get(formatDateKey(ticket.createdAt)) if (bucket) { bucket.opened += 1 } entry.openedTotal += 1 } for (const ticket of resolvedTickets) { const queueId = ticket.queueId ? String(ticket.queueId) : "unassigned" const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null if (resolvedAt === null) continue const entry = ensureEntry(queueId) const bucket = entry.series.get(formatDateKey(resolvedAt)) if (bucket) { bucket.resolved += 1 } entry.resolvedTotal += 1 } const maxEntries = Math.max(1, Math.min(limit ?? 3, 6)) const queuesTrend = Array.from(stats.values()) .sort((a, b) => b.openedTotal - a.openedTotal) .slice(0, maxEntries) .map((entry) => ({ id: entry.id, name: entry.name, openedTotal: entry.openedTotal, resolvedTotal: entry.resolvedTotal, series: dayKeys.map((key) => entry.series.get(key)!), })) return { rangeDays: days, queues: queuesTrend } } export const queueLoadTrend = query({ args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), limit: v.optional(v.number()) }, handler: queueLoadTrendHandler, }) // Touch to ensure CI convex_deploy runs and that agentProductivity is deployed 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) const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 const end = new Date() end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - days * ONE_DAY_MS const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) type Acc = { agentId: Id<"users"> name: string | null email: string | null open: number resolved: number avgFirstResponseMinValues: number[] avgResolutionMinValues: number[] workedMs: number } const map = new Map() for (const t of inRange) { const assigneeId = t.assigneeId ?? null if (!assigneeId) continue let acc = map.get(assigneeId) if (!acc) { const user = await ctx.db.get(assigneeId) acc = { agentId: assigneeId, name: user?.name ?? null, email: user?.email ?? null, open: 0, resolved: 0, avgFirstResponseMinValues: [], avgResolutionMinValues: [], workedMs: 0, } map.set(assigneeId, acc) } const status = normalizeStatus(t.status) if (OPEN_STATUSES.has(status)) acc.open += 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) } for (const [agentId, acc] of map) { const sessions = await ctx.db .query("ticketWorkSessions") .withIndex("by_agent", (q) => q.eq("agentId", agentId as Id<"users">)) .collect() let total = 0 for (const s of sessions) { const started = s.startedAt const ended = s.stoppedAt ?? s.startedAt if (ended < startMs || started >= endMs) continue total += s.durationMs ?? Math.max(0, (s.stoppedAt ?? Date.now()) - s.startedAt) } acc.workedMs = total } const items = Array.from(map.values()).map((acc) => ({ agentId: acc.agentId, name: acc.name, email: acc.email, open: acc.open, resolved: acc.resolved, avgFirstResponseMinutes: average(acc.avgFirstResponseMinValues), avgResolutionMinutes: average(acc.avgResolutionMinValues), workedHours: Math.round((acc.workedMs / 3600000) * 100) / 100, })) 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, }) type CategoryAgentAccumulator = { id: Id<"ticketCategories"> | null name: string total: number resolved: number agents: Map | null; name: string | null; total: number }> } export async function ticketCategoryInsightsHandler( ctx: QueryCtx, { tenantId, viewerId, range, companyId, }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } ) { const viewer = await requireStaff(ctx, viewerId, tenantId) const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 const end = new Date() end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - days * ONE_DAY_MS const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) const categories = await ctx.db .query("ticketCategories") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect() const categoriesById = new Map, Doc<"ticketCategories">>() for (const category of categories) { categoriesById.set(category._id, category) } const stats = new Map() for (const ticket of inRange) { const categoryKey = ticket.categoryId ? String(ticket.categoryId) : "uncategorized" let stat = stats.get(categoryKey) if (!stat) { const categoryDoc = ticket.categoryId ? categoriesById.get(ticket.categoryId) : null stat = { id: ticket.categoryId ?? null, name: categoryDoc?.name ?? (ticket.categoryId ? "Categoria removida" : "Sem categoria"), total: 0, resolved: 0, agents: new Map(), } stats.set(categoryKey, stat) } stat.total += 1 if (typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs && ticket.resolvedAt < endMs) { stat.resolved += 1 } const agentKey = ticket.assigneeId ? String(ticket.assigneeId) : "unassigned" let agent = stat.agents.get(agentKey) if (!agent) { const snapshotName = ticket.assigneeSnapshot?.name ?? null const fallbackName = ticket.assigneeId ? null : "Sem responsável" agent = { agentId: ticket.assigneeId ?? null, name: snapshotName ?? fallbackName ?? "Agente", total: 0, } stat.agents.set(agentKey, agent) } agent.total += 1 } const categoriesData = Array.from(stats.values()) .map((stat) => { const agents = Array.from(stat.agents.values()).sort((a, b) => b.total - a.total) const topAgent = agents[0] ?? null return { id: stat.id ? String(stat.id) : null, name: stat.name, total: stat.total, resolved: stat.resolved, topAgent: topAgent ? { id: topAgent.agentId ? String(topAgent.agentId) : null, name: topAgent.name, total: topAgent.total, } : null, agents: agents.slice(0, 5).map((agent) => ({ id: agent.agentId ? String(agent.agentId) : null, name: agent.name, total: agent.total, })), } }) .sort((a, b) => b.total - a.total) const spotlight = categoriesData.reduce< | null | { categoryId: string | null categoryName: string agentId: string | null agentName: string | null tickets: number } >((best, current) => { if (!current.topAgent) return best if (!best || current.topAgent.total > best.tickets) { return { categoryId: current.id, categoryName: current.name, agentId: current.topAgent.id, agentName: current.topAgent.name, tickets: current.topAgent.total, } } return best }, null) return { rangeDays: days, totalTickets: inRange.length, categories: categoriesData, spotlight, } } export const categoryInsights = query({ args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")), }, handler: ticketCategoryInsightsHandler, }) export async function dashboardOverviewHandler( ctx: QueryCtx, { tenantId, viewerId }: { tenantId: string; viewerId: Id<"users"> } ) { const viewer = await requireStaff(ctx, viewerId, tenantId); const now = Date.now(); const lastDayStart = now - ONE_DAY_MS; const previousDayStart = now - 2 * ONE_DAY_MS; const lastWindowStart = now - 7 * ONE_DAY_MS; const previousWindowStart = now - 14 * ONE_DAY_MS; const openTickets = await fetchOpenScopedTickets(ctx, tenantId, viewer); const recentCreatedTickets = await fetchScopedTicketsByCreatedRange( ctx, tenantId, viewer, previousWindowStart, now, ); const recentResolvedTickets = await fetchScopedTicketsByResolvedRange( ctx, tenantId, viewer, previousWindowStart, now, ); const newTickets = recentCreatedTickets.filter((ticket) => ticket.createdAt >= lastDayStart); const previousTickets = recentCreatedTickets.filter( (ticket) => ticket.createdAt >= previousDayStart && ticket.createdAt < lastDayStart, ); const trend = percentageChange(newTickets.length, previousTickets.length); const inProgressCurrent = openTickets.filter((ticket) => Boolean(ticket.firstResponseAt)); const inProgressPrevious = openTickets.filter( (ticket) => Boolean(ticket.firstResponseAt && ticket.firstResponseAt < lastDayStart), ); const inProgressTrend = percentageChange(inProgressCurrent.length, inProgressPrevious.length); const firstResponseWindow = recentCreatedTickets .filter( (ticket) => ticket.createdAt >= lastWindowStart && ticket.createdAt < now && ticket.firstResponseAt, ) .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); const firstResponsePrevious = recentCreatedTickets .filter( (ticket) => ticket.createdAt >= previousWindowStart && ticket.createdAt < lastWindowStart && ticket.firstResponseAt, ) .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); const averageWindow = average(firstResponseWindow); const averagePrevious = average(firstResponsePrevious); const deltaMinutes = averageWindow !== null && averagePrevious !== null ? averageWindow - averagePrevious : null; const awaitingTickets = openTickets; const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); const resolvedLastWindow = recentResolvedTickets.filter( (ticket) => ticket.resolvedAt && ticket.resolvedAt >= lastWindowStart && ticket.resolvedAt < now, ); const resolvedPreviousWindow = recentResolvedTickets.filter( (ticket) => ticket.resolvedAt && ticket.resolvedAt >= previousWindowStart && ticket.resolvedAt < lastWindowStart, ); const resolutionBase = Math.max(recentCreatedTickets.length, awaitingTickets.length); const resolutionRate = resolutionBase > 0 ? (resolvedLastWindow.length / resolutionBase) * 100 : null; const resolutionDelta = percentageChange(resolvedLastWindow.length, resolvedPreviousWindow.length); return { newTickets: { last24h: newTickets.length, previous24h: previousTickets.length, trendPercentage: trend, }, inProgress: { current: inProgressCurrent.length, previousSnapshot: inProgressPrevious.length, trendPercentage: inProgressTrend, }, firstResponse: { averageMinutes: averageWindow, previousAverageMinutes: averagePrevious, deltaMinutes, responsesCount: firstResponseWindow.length, }, awaitingAction: { total: awaitingTickets.length, atRisk: atRiskTickets.length, }, resolution: { resolvedLast7d: resolvedLastWindow.length, previousResolved: resolvedPreviousWindow.length, rate: resolutionRate, deltaPercentage: resolutionDelta, }, }; } export const dashboardOverview = query({ args: { tenantId: v.string(), viewerId: v.id("users") }, handler: dashboardOverviewHandler, }); 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); const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; const end = new Date(); end.setUTCHours(0, 0, 0, 0); const endMs = end.getTime() + ONE_DAY_MS; const startMs = endMs - days * ONE_DAY_MS; const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId); const timeline = new Map>(); for (let ts = startMs; ts < endMs; ts += ONE_DAY_MS) { timeline.set(formatDateKey(ts), new Map()); } const channels = new Set(); for (const ticket of tickets) { if (ticket.createdAt < startMs || ticket.createdAt >= endMs) continue; const dateKey = formatDateKey(ticket.createdAt); const channelKey = ticket.channel ?? "OUTRO"; channels.add(channelKey); const dayMap = timeline.get(dateKey) ?? new Map(); dayMap.set(channelKey, (dayMap.get(channelKey) ?? 0) + 1); timeline.set(dateKey, dayMap); } const sortedChannels = Array.from(channels).sort(); const points = Array.from(timeline.entries()) .sort((a, b) => a[0].localeCompare(b[0])) .map(([date, map]) => { const values: Record = {}; for (const channel of sortedChannels) { values[channel] = map.get(channel) ?? 0; } return { date, values }; }); return { rangeDays: days, 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, }); type MachineCategoryDailyEntry = { date: string machineId: Id<"machines"> | null machineHostname: string | null companyId: Id<"companies"> | null companyName: string | null categoryId: Id<"ticketCategories"> | null categoryName: string total: number } export async function ticketsByMachineAndCategoryHandler( ctx: QueryCtx, { tenantId, viewerId, range, companyId, machineId, userId, }: { tenantId: string viewerId: Id<"users"> range?: string companyId?: Id<"companies"> machineId?: Id<"machines"> userId?: Id<"users"> } ) { const viewer = await requireStaff(ctx, viewerId, tenantId) const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 const end = new Date() end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - days * ONE_DAY_MS const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) const categoriesMap = await fetchCategoryMap(ctx, tenantId) const companyIds = new Set>() for (const ticket of tickets) { if (ticket.companyId) { companyIds.add(ticket.companyId) } } const companiesById = new Map | null>() await Promise.all( Array.from(companyIds).map(async (id) => { const doc = await ctx.db.get(id) companiesById.set(String(id), doc ?? null) }) ) const aggregated = new Map() for (const ticket of tickets) { const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null if (createdAt === null || createdAt < startMs || createdAt >= endMs) continue const hasMachine = Boolean(ticket.machineId) || Boolean(ticket.machineSnapshot) if (!hasMachine) continue if (machineId && ticket.machineId !== machineId) continue if (userId && ticket.requesterId !== userId) continue const date = formatDateKey(createdAt) const machineIdValue = (ticket.machineId ?? null) as Id<"machines"> | null const machineSnapshot = (ticket.machineSnapshot ?? null) as | { hostname?: string | null } | null const rawHostname = typeof machineSnapshot?.hostname === "string" && machineSnapshot.hostname.trim().length > 0 ? machineSnapshot.hostname.trim() : null const machineHostname = rawHostname ?? null const snapshot = (ticket.slaSnapshot ?? null) as { categoryId?: Id<"ticketCategories">; categoryName?: string } | null const rawCategoryId = ticket.categoryId && typeof ticket.categoryId === "string" ? String(ticket.categoryId) : snapshot?.categoryId ? String(snapshot.categoryId) : null const categoryName = resolveCategoryName(rawCategoryId, snapshot, categoriesMap) const companyIdValue = (ticket.companyId ?? null) as Id<"companies"> | null let companyName: string | null = null if (companyIdValue) { const company = companiesById.get(String(companyIdValue)) if (company?.name && company.name.trim().length > 0) { companyName = company.name.trim() } } if (!companyName) { const companySnapshot = (ticket.companySnapshot ?? null) as { name?: string | null } | null if (companySnapshot?.name && companySnapshot.name.trim().length > 0) { companyName = companySnapshot.name.trim() } } if (!companyName) { companyName = "Sem empresa" } const key = [ date, machineIdValue ? String(machineIdValue) : "null", machineHostname ?? "", rawCategoryId ?? "uncategorized", companyIdValue ? String(companyIdValue) : "null", ].join("|") const existing = aggregated.get(key) if (existing) { existing.total += 1 } else { aggregated.set(key, { date, machineId: machineIdValue, machineHostname, companyId: companyIdValue, companyName, categoryId: (rawCategoryId as Id<"ticketCategories"> | null) ?? null, categoryName, total: 1, }) } } const items = Array.from(aggregated.values()).sort((a, b) => { if (a.date !== b.date) return a.date.localeCompare(b.date) const machineA = (a.machineHostname ?? "").toLowerCase() const machineB = (b.machineHostname ?? "").toLowerCase() if (machineA !== machineB) return machineA.localeCompare(machineB) return a.categoryName.localeCompare(b.categoryName, "pt-BR") }) return { rangeDays: days, items, } } export const ticketsByMachineAndCategory = query({ args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")), machineId: v.optional(v.id("machines")), userId: v.optional(v.id("users")), }, handler: ticketsByMachineAndCategoryHandler, }) type MachineHoursEntry = { machineId: Id<"machines"> machineHostname: string | null companyId: Id<"companies"> | null companyName: string | null internalMs: number externalMs: number totalMs: number } export async function hoursByMachineHandler( ctx: QueryCtx, { tenantId, viewerId, range, companyId, machineId, userId, }: { tenantId: string viewerId: Id<"users"> range?: string companyId?: Id<"companies"> machineId?: Id<"machines"> userId?: Id<"users"> } ) { const viewer = await requireStaff(ctx, viewerId, tenantId) const tickets = await fetchScopedTickets(ctx, tenantId, viewer) const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 const end = new Date() end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - days * ONE_DAY_MS const machinesById = new Map | null>() const companiesById = new Map | null>() const map = new Map() for (const t of tickets) { if (t.updatedAt < startMs || t.updatedAt >= endMs) continue if (companyId && t.companyId && t.companyId !== companyId) continue if (machineId && t.machineId !== machineId) continue if (userId && t.requesterId !== userId) continue const machineIdValue = (t.machineId ?? null) as Id<"machines"> | null if (!machineIdValue) continue const key = String(machineIdValue) let acc = map.get(key) if (!acc) { let machineDoc = machinesById.get(key) if (machineDoc === undefined) { machineDoc = (await ctx.db.get(machineIdValue)) as Doc<"machines"> | null machinesById.set(key, machineDoc ?? null) } const snapshot = (t.machineSnapshot ?? null) as { hostname?: string | null } | null const machineHostname = typeof machineDoc?.hostname === "string" && machineDoc.hostname.trim().length > 0 ? machineDoc.hostname.trim() : snapshot?.hostname && snapshot.hostname.trim().length > 0 ? snapshot.hostname.trim() : null const companyIdValue = (t.companyId ?? (machineDoc?.companyId as Id<"companies"> | undefined) ?? null) as Id<"companies"> | null let companyName: string | null = null if (companyIdValue) { const companyKey = String(companyIdValue) let companyDoc = companiesById.get(companyKey) if (companyDoc === undefined) { companyDoc = (await ctx.db.get(companyIdValue)) as Doc<"companies"> | null companiesById.set(companyKey, companyDoc ?? null) } if (companyDoc?.name && companyDoc.name.trim().length > 0) { companyName = companyDoc.name.trim() } } acc = { machineId: machineIdValue, machineHostname, companyId: companyIdValue, companyName, internalMs: 0, externalMs: 0, totalMs: 0, } map.set(key, acc) } const internal = (t.internalWorkedMs ?? 0) as number const external = (t.externalWorkedMs ?? 0) as number acc.internalMs += internal acc.externalMs += external acc.totalMs += internal + external } const items = Array.from(map.values()).sort((a, b) => { if (b.totalMs !== a.totalMs) return b.totalMs - a.totalMs const hostA = (a.machineHostname ?? "").toLowerCase() const hostB = (b.machineHostname ?? "").toLowerCase() return hostA.localeCompare(hostB) }) return { rangeDays: days, items, } } export const hoursByMachine = query({ args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")), machineId: v.optional(v.id("machines")), userId: v.optional(v.id("users")), }, handler: hoursByMachineHandler, }) 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) const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 const end = new Date() end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - days * ONE_DAY_MS type Acc = { companyId: Id<"companies"> name: string isAvulso: boolean internalMs: number externalMs: number totalMs: number contractedHoursPerMonth?: number | null } const map = new Map() for (const t of tickets) { if (t.updatedAt < startMs || t.updatedAt >= endMs) continue const companyId = t.companyId ?? null if (!companyId) continue let acc = map.get(companyId) if (!acc) { const company = await ctx.db.get(companyId) acc = { companyId, name: company?.name ?? "Sem empresa", isAvulso: Boolean(company?.isAvulso ?? false), internalMs: 0, externalMs: 0, totalMs: 0, contractedHoursPerMonth: company?.contractedHoursPerMonth ?? null, } map.set(companyId, acc) } const internal = t.internalWorkedMs ?? 0 const external = t.externalWorkedMs ?? 0 acc.internalMs += internal acc.externalMs += external acc.totalMs += internal + external } const items = Array.from(map.values()).sort((a, b) => b.totalMs - a.totalMs) return { rangeDays: days, items: items.map((i) => ({ companyId: i.companyId, name: i.name, isAvulso: i.isAvulso, internalMs: i.internalMs, externalMs: i.externalMs, totalMs: i.totalMs, 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 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 const end = new Date() end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - days * ONE_DAY_MS type Acc = { companyId: Id<"companies"> name: string isAvulso: boolean internalMs: number externalMs: number totalMs: number contractedHoursPerMonth?: number | null } const map = new Map() for (const t of tickets) { if (t.updatedAt < startMs || t.updatedAt >= endMs) continue const companyId = t.companyId ?? null if (!companyId) continue let acc = map.get(companyId) if (!acc) { const company = await ctx.db.get(companyId) acc = { companyId, name: company?.name ?? "Sem empresa", isAvulso: Boolean(company?.isAvulso ?? false), internalMs: 0, externalMs: 0, totalMs: 0, contractedHoursPerMonth: company?.contractedHoursPerMonth ?? null, } map.set(companyId, acc) } const internal = t.internalWorkedMs ?? 0 const external = t.externalWorkedMs ?? 0 acc.internalMs += internal acc.externalMs += external acc.totalMs += internal + external } const items = Array.from(map.values()).sort((a, b) => b.totalMs - a.totalMs) return { rangeDays: days, items: items.map((i) => ({ companyId: i.companyId, name: i.name, isAvulso: i.isAvulso, internalMs: i.internalMs, externalMs: i.externalMs, totalMs: i.totalMs, contractedHoursPerMonth: i.contractedHoursPerMonth ?? null, })), } } export const hoursByClientInternal = query({ args: { tenantId: v.string(), range: v.optional(v.string()) }, handler: hoursByClientInternalHandler, }) export const companyOverview = query({ args: { tenantId: v.string(), viewerId: v.id("users"), companyId: v.id("companies"), range: v.optional(v.string()), }, handler: async (ctx, { tenantId, viewerId, companyId, range }) => { const viewer = await requireStaff(ctx, viewerId, tenantId); if (viewer.role === "MANAGER" && viewer.user.companyId && viewer.user.companyId !== companyId) { throw new ConvexError("Gestores só podem consultar relatórios da própria empresa"); } const company = await ctx.db.get(companyId); if (!company || company.tenantId !== tenantId) { throw new ConvexError("Empresa não encontrada"); } const normalizedRange = (range ?? "30d").toLowerCase(); const rangeDays = normalizedRange === "90d" ? 90 : normalizedRange === "7d" ? 7 : 30; const now = Date.now(); const startMs = now - rangeDays * ONE_DAY_MS; const tickets = await ctx.db .query("tickets") .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) .collect(); const machines = await ctx.db .query("machines") .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) .collect(); const users = await ctx.db .query("users") .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) .collect(); const statusCounts = {} as Record; const priorityCounts = {} as Record; const channelCounts = {} as Record; const trendMap = new Map(); const openTickets: Doc<"tickets">[] = []; tickets.forEach((ticket) => { const normalizedStatus = normalizeStatus(ticket.status); statusCounts[normalizedStatus] = (statusCounts[normalizedStatus] ?? 0) + 1; const priorityKey = (ticket.priority ?? "MEDIUM").toUpperCase(); priorityCounts[priorityKey] = (priorityCounts[priorityKey] ?? 0) + 1; const channelKey = (ticket.channel ?? "MANUAL").toUpperCase(); channelCounts[channelKey] = (channelCounts[channelKey] ?? 0) + 1; if (normalizedStatus !== "RESOLVED") { openTickets.push(ticket); } if (ticket.createdAt >= startMs) { const key = formatDateKey(ticket.createdAt); if (!trendMap.has(key)) { trendMap.set(key, { opened: 0, resolved: 0 }); } trendMap.get(key)!.opened += 1; } if (typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs) { const key = formatDateKey(ticket.resolvedAt); if (!trendMap.has(key)) { trendMap.set(key, { opened: 0, resolved: 0 }); } trendMap.get(key)!.resolved += 1; } }); const machineStatusCounts: Record = {}; const machineOsCounts: Record = {}; const machineLookup = new Map>(); machines.forEach((machine) => { machineLookup.set(String(machine._id), machine); const status = deriveMachineStatus(machine, now); machineStatusCounts[status] = (machineStatusCounts[status] ?? 0) + 1; const osLabel = formatOsLabel(machine.osName ?? null, machine.osVersion ?? null); machineOsCounts[osLabel] = (machineOsCounts[osLabel] ?? 0) + 1; }); const roleCounts: Record = {}; users.forEach((user) => { const roleKey = (user.role ?? "COLLABORATOR").toUpperCase(); roleCounts[roleKey] = (roleCounts[roleKey] ?? 0) + 1; }); const trend = Array.from(trendMap.entries()) .sort((a, b) => a[0].localeCompare(b[0])) .map(([date, value]) => ({ date, opened: value.opened, resolved: value.resolved })); const machineOsDistribution = Object.entries(machineOsCounts) .map(([label, value]) => ({ label, value })) .sort((a, b) => b.value - a.value); const openTicketSummaries = openTickets .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) .slice(0, 20) .map((ticket) => { const machineSnapshot = ticket.machineSnapshot as { hostname?: string } | undefined; const machine = ticket.machineId ? machineLookup.get(String(ticket.machineId)) : null; return { id: ticket._id, reference: ticket.reference, subject: ticket.subject, status: normalizeStatus(ticket.status), priority: ticket.priority, updatedAt: ticket.updatedAt, createdAt: ticket.createdAt, machine: ticket.machineId ? { id: ticket.machineId, hostname: machine?.hostname ?? machineSnapshot?.hostname ?? null, } : null, assignee: ticket.assigneeSnapshot ? { name: (ticket.assigneeSnapshot as { name?: string })?.name ?? null, email: (ticket.assigneeSnapshot as { email?: string })?.email ?? null, } : null, }; }); return { company: { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false, }, rangeDays, generatedAt: now, tickets: { total: tickets.length, byStatus: statusCounts, byPriority: priorityCounts, byChannel: channelCounts, trend, open: openTicketSummaries, }, machines: { total: machines.length, byStatus: machineStatusCounts, byOs: machineOsDistribution, }, users: { total: users.length, byRole: roleCounts, }, }; }, });