reports(SLA): aplica filtro de período (7d/30d/90d) no Convex e inclui período no filename do CSV; admin(alerts): filtros no servidor; alerts: batch de últimos alertas por slugs; filtros persistentes de empresa (localStorage) em relatórios; prisma: Company.contractedHoursPerMonth; smtp: suporte a múltiplos destinatários e timeout opcional

This commit is contained in:
Esdras Renan 2025-10-07 16:46:52 -03:00
parent a23b429e4d
commit 384d4411b6
13 changed files with 133 additions and 38 deletions

View file

@ -150,6 +150,36 @@ export const lastForCompanyBySlug = query({
},
})
export const lastForCompaniesBySlugs = query({
args: { tenantId: v.string(), slugs: v.array(v.string()) },
handler: async (ctx, { tenantId, slugs }) => {
const result: Record<string, { createdAt: number; usagePct: number; threshold: number } | null> = {}
// Fetch all alerts once for the tenant
const alerts = await ctx.db
.query("alerts")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
for (const slug of slugs) {
const company = await ctx.db
.query("companies")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
.first()
if (!company) {
result[slug] = null
continue
}
const matches = alerts.filter((a) => a.companyId === company._id)
if (matches.length === 0) {
result[slug] = null
continue
}
const last = matches.sort((a, b) => b.createdAt - a.createdAt)[0]
result[slug] = { createdAt: last.createdAt, usagePct: last.usagePct, threshold: last.threshold }
}
return result
},
})
export const tenantIds = query({
args: {},
handler: async (ctx) => {

View file

@ -129,21 +129,28 @@ function formatDateKey(timestamp: number) {
export const slaOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
handler: async (ctx, { tenantId, viewerId, companyId }) => {
handler: async (ctx, { tenantId, viewerId, range, companyId }) => {
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 = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)));
const resolvedTickets = tickets.filter((ticket) => {
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 = tickets
const firstResponseTimes = inRange
.filter((ticket) => ticket.firstResponseAt)
.map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000);
const resolutionTimes = resolvedTickets
@ -161,7 +168,7 @@ export const slaOverview = query({
return {
totals: {
total: tickets.length,
total: inRange.length,
open: openTickets.length,
resolved: resolvedTickets.length,
overdue: overdueTickets.length,
@ -175,6 +182,7 @@ export const slaOverview = query({
resolvedCount: resolutionTimes.length,
},
queueBreakdown,
rangeDays: days,
};
},
});