feat: export reports as xlsx and add machine inventory

This commit is contained in:
Esdras Renan 2025-10-27 18:00:28 -03:00
parent 29b865885c
commit 714b199879
34 changed files with 2304 additions and 245 deletions

View file

@ -4,6 +4,7 @@ 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";
@ -87,6 +88,35 @@ async function fetchQueues(ctx: QueryCtx, tenantId: string) {
.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;
@ -707,3 +737,163 @@ export const hoursByClientInternal = query({
}
},
})
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<string, number>;
const priorityCounts = {} as Record<string, number>;
const channelCounts = {} as Record<string, number>;
const trendMap = new Map<string, { opened: number; resolved: number }>();
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<string, number> = {};
const machineOsCounts: Record<string, number> = {};
const machineLookup = new Map<string, Doc<"machines">>();
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<string, number> = {};
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,
},
};
},
});