import { 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"; type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED"; 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", }; 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; } const OPEN_STATUSES = new Set(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]); const ONE_DAY_MS = 24 * 60 * 60 * 1000; 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 isNotNull(value: T | null): value is T { return value !== null; } async function fetchTickets(ctx: QueryCtx, tenantId: string) { return ctx.db .query("tickets") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); } async function fetchScopedTickets( ctx: QueryCtx, tenantId: string, viewer: Awaited>, ) { if (viewer.role === "MANAGER") { if (!viewer.user.companyId) { throw new ConvexError("Gestor não possui empresa vinculada"); } return ctx.db .query("tickets") .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!) ) .collect(); } return fetchTickets(ctx, tenantId); } async function fetchScopedTicketsByCreatedRange( ctx: QueryCtx, tenantId: string, viewer: Awaited>, startMs: number, endMs: number, companyId?: Id<"companies">, ) { if (viewer.role === "MANAGER") { if (!viewer.user.companyId) { throw new ConvexError("Gestor não possui empresa vinculada"); } return ctx.db .query("tickets") .withIndex("by_tenant_company_created", (q) => q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!).gte("createdAt", startMs), ) .filter((q) => q.lt(q.field("createdAt"), endMs)) .collect(); } if (companyId) { return ctx.db .query("tickets") .withIndex("by_tenant_company_created", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId).gte("createdAt", startMs), ) .filter((q) => q.lt(q.field("createdAt"), endMs)) .collect(); } return ctx.db .query("tickets") .withIndex("by_tenant_created", (q) => q.eq("tenantId", tenantId).gte("createdAt", startMs)) .filter((q) => q.lt(q.field("createdAt"), endMs)) .collect(); } 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; receivedAt: number; }; async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Promise { const perTicket = await Promise.all( tickets.map(async (ticket) => { 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; return { ticketId: ticket._id, reference: ticket.reference, score, receivedAt: event.createdAt, } 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); let tickets = await fetchScopedTickets(ctx, tenantId, viewer); if (companyId) tickets = tickets.filter((t) => t.companyId === companyId) // 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 = tickets.filter((t) => t.createdAt >= startMs && t.createdAt < endMs); const queues = await fetchQueues(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, }; }); 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, 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 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) const surveysAll = await collectCsatSurveys(ctx, tickets); 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 surveys = surveysAll.filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs); const averageScore = average(surveys.map((item) => item.score)); const distribution = [1, 2, 3, 4, 5].map((score) => ({ score, total: surveys.filter((item) => item.score === score).length, })); 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, receivedAt: item.receivedAt, })), 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 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) 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 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 t of tickets) { if (t.createdAt >= startMs && t.createdAt < endMs) { const key = formatDateKey(t.createdAt) opened[key] = (opened[key] ?? 0) + 1 } if (t.resolvedAt && t.resolvedAt >= startMs && t.resolvedAt < endMs) { const key = formatDateKey(t.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, }); // 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) let tickets = await fetchScopedTickets(ctx, tenantId, viewer) if (companyId) tickets = tickets.filter((t) => t.companyId === companyId) 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 = tickets.filter((t) => t.createdAt >= startMs && t.createdAt < endMs) 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, }) 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(); const lastDayStart = now - ONE_DAY_MS; const previousDayStart = now - 2 * ONE_DAY_MS; const newTickets = tickets.filter((ticket) => ticket.createdAt >= lastDayStart); const previousTickets = tickets.filter( (ticket) => ticket.createdAt >= previousDayStart && ticket.createdAt < lastDayStart ); const trend = percentageChange(newTickets.length, previousTickets.length); const inProgressCurrent = tickets.filter((ticket) => { if (!ticket.firstResponseAt) return false; const status = normalizeStatus(ticket.status); if (status === "RESOLVED") return false; return !ticket.resolvedAt; }); const inProgressPrevious = tickets.filter((ticket) => { if (!ticket.firstResponseAt || ticket.firstResponseAt >= lastDayStart) return false; if (ticket.resolvedAt && ticket.resolvedAt < lastDayStart) return false; const status = normalizeStatus(ticket.status); return status !== "RESOLVED" || !ticket.resolvedAt; }); const inProgressTrend = percentageChange(inProgressCurrent.length, inProgressPrevious.length); const lastWindowStart = now - 7 * ONE_DAY_MS; const previousWindowStart = now - 14 * ONE_DAY_MS; const firstResponseWindow = tickets .filter( (ticket) => ticket.createdAt >= lastWindowStart && ticket.createdAt < now && ticket.firstResponseAt ) .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); const firstResponsePrevious = tickets .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 = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); const resolvedLastWindow = tickets.filter( (ticket) => ticket.resolvedAt && ticket.resolvedAt >= lastWindowStart && ticket.resolvedAt < now ); const resolvedPreviousWindow = tickets.filter( (ticket) => ticket.resolvedAt && ticket.resolvedAt >= previousWindowStart && ticket.resolvedAt < lastWindowStart ); const resolutionRate = tickets.length > 0 ? (resolvedLastWindow.length / tickets.length) * 100 : null; const resolutionDelta = resolvedPreviousWindow.length > 0 ? ((resolvedLastWindow.length - resolvedPreviousWindow.length) / resolvedPreviousWindow.length) * 100 : null; 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); let tickets = await fetchScopedTickets(ctx, tenantId, viewer); if (companyId) tickets = tickets.filter((t) => t.companyId === companyId) 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 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, }); 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, }, }; }, });