feat: export reports as xlsx and add machine inventory
This commit is contained in:
parent
29b865885c
commit
714b199879
34 changed files with 2304 additions and 245 deletions
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue