feat(reports): add date range filters and extend machine reports

This commit is contained in:
Esdras Renan 2025-11-14 00:59:11 -03:00
parent 82875a2252
commit 5b22065609
11 changed files with 742 additions and 290 deletions

View file

@ -109,6 +109,53 @@ function withUpperBound<T>(range: T, field: string, value: number): T {
return range;
}
function parseIsoDateToMs(value?: string | null): number | null {
if (!value) return null;
const [yearStr, monthStr, dayStr] = value.split("-");
const year = Number(yearStr);
const month = Number(monthStr);
const day = Number(dayStr);
if (!year || !month || !day) return null;
const date = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0));
return date.getTime();
}
function resolveRangeWindow(
range: string | undefined,
dateFrom: string | undefined,
dateTo: string | undefined,
defaultDays: number,
): { startMs: number; endMs: number; days: number } {
const fromMs = parseIsoDateToMs(dateFrom);
const toMs = parseIsoDateToMs(dateTo);
if (fromMs !== null || toMs !== null) {
const startMs = fromMs ?? toMs ?? Date.now();
const endMsBase = toMs ?? fromMs ?? startMs;
const endMs = endMsBase + ONE_DAY_MS;
const days = Math.max(1, Math.round((endMs - startMs) / ONE_DAY_MS));
return { startMs, endMs, days };
}
const normalizedRange = range ?? "90d";
let days = defaultDays;
if (normalizedRange === "7d") days = 7;
else if (normalizedRange === "30d") days = 30;
else if (normalizedRange === "365d" || normalizedRange === "12m") days = 365;
else if (normalizedRange === "all") {
const now = new Date();
now.setUTCHours(0, 0, 0, 0);
const endMs = now.getTime() + ONE_DAY_MS;
return { startMs: 0, endMs, days: 0 };
}
const end = new Date();
end.setUTCHours(0, 0, 0, 0);
const endMs = end.getTime() + ONE_DAY_MS;
const startMs = endMs - days * ONE_DAY_MS;
return { startMs, endMs, days };
}
function resolveScopedCompanyId(
viewer: Awaited<ReturnType<typeof requireStaff>>,
companyId?: Id<"companies">,
@ -469,15 +516,24 @@ function formatDateKey(timestamp: number) {
export async function slaOverviewHandler(
ctx: QueryCtx,
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
{
tenantId,
viewerId,
range,
companyId,
dateFrom,
dateTo,
}: {
tenantId: string
viewerId: Id<"users">
range?: string
companyId?: Id<"companies">
dateFrom?: string
dateTo?: string
}
) {
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 { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90);
const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
const queues = await fetchQueues(ctx, tenantId);
const categoriesMap = await fetchCategoryMap(ctx, tenantId);
@ -616,14 +672,17 @@ export const triggerScheduledExports = action({
export async function csatOverviewHandler(
ctx: QueryCtx,
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
{
tenantId,
viewerId,
range,
companyId,
dateFrom,
dateTo,
}: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies">; dateFrom?: string; dateTo?: string }
) {
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 { startMs, endMs } = resolveRangeWindow(range, dateFrom, dateTo, 90);
const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
const surveys = (await collectCsatSurveys(ctx, tickets)).filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs);
@ -700,27 +759,37 @@ export async function csatOverviewHandler(
assigneeId: item.assigneeId,
assigneeName: item.assigneeName,
})),
rangeDays: days,
rangeDays: Math.max(1, Math.round((endMs - startMs) / ONE_DAY_MS)),
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")) },
args: {
tenantId: v.string(),
viewerId: v.id("users"),
range: v.optional(v.string()),
companyId: v.optional(v.id("companies")),
dateFrom: v.optional(v.string()),
dateTo: v.optional(v.string()),
},
handler: csatOverviewHandler,
});
export async function openedResolvedByDayHandler(
ctx: QueryCtx,
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
{
tenantId,
viewerId,
range,
companyId,
dateFrom,
dateTo,
}: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies">; dateFrom?: string; dateTo?: string }
) {
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 { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90);
const openedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
const resolvedTickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
@ -761,21 +830,30 @@ export async function openedResolvedByDayHandler(
}
export const openedResolvedByDay = query({
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
args: {
tenantId: v.string(),
viewerId: v.id("users"),
range: v.optional(v.string()),
companyId: v.optional(v.id("companies")),
dateFrom: v.optional(v.string()),
dateTo: v.optional(v.string()),
},
handler: openedResolvedByDayHandler,
})
export async function backlogOverviewHandler(
ctx: QueryCtx,
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
{
tenantId,
viewerId,
range,
companyId,
dateFrom,
dateTo,
}: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies">; dateFrom?: string; dateTo?: string }
) {
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 { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90);
const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
const statusCounts = inRange.reduce<Record<TicketStatusNormalized, number>>((acc, ticket) => {
@ -821,7 +899,14 @@ export async function backlogOverviewHandler(
}
export const backlogOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
args: {
tenantId: v.string(),
viewerId: v.id("users"),
range: v.optional(v.string()),
companyId: v.optional(v.id("companies")),
dateFrom: v.optional(v.string()),
dateTo: v.optional(v.string()),
},
handler: backlogOverviewHandler,
});
@ -1023,14 +1108,12 @@ export async function ticketCategoryInsightsHandler(
viewerId,
range,
companyId,
}: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
dateFrom,
dateTo,
}: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies">; dateFrom?: string; dateTo?: string }
) {
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 { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90)
const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId)
const categories = await ctx.db
@ -1141,6 +1224,8 @@ export const categoryInsights = query({
viewerId: v.id("users"),
range: v.optional(v.string()),
companyId: v.optional(v.id("companies")),
dateFrom: v.optional(v.string()),
dateTo: v.optional(v.string()),
},
handler: ticketCategoryInsightsHandler,
})
@ -1347,14 +1432,17 @@ export async function ticketsByMachineAndCategoryHandler(
}
) {
const viewer = await requireStaff(ctx, viewerId, tenantId)
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90
const { startMs, endMs, days } = resolveRangeWindow(range, undefined, undefined, 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 tickets =
days === 0
? await fetchScopedTickets(ctx, tenantId, viewer).then((all) =>
all.filter((ticket) => {
if (companyId && ticket.companyId && ticket.companyId !== companyId) return false
return true
}),
)
: await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId)
const categoriesMap = await fetchCategoryMap(ctx, tenantId)
const companyIds = new Set<Id<"companies">>()
@ -1458,7 +1546,7 @@ export async function ticketsByMachineAndCategoryHandler(
})
return {
rangeDays: days,
rangeDays: days === 0 ? -1 : days,
items,
}
}
@ -1471,6 +1559,8 @@ export const ticketsByMachineAndCategory = query({
companyId: v.optional(v.id("companies")),
machineId: v.optional(v.id("machines")),
userId: v.optional(v.id("users")),
dateFrom: v.optional(v.string()),
dateTo: v.optional(v.string()),
},
handler: ticketsByMachineAndCategoryHandler,
})
@ -1506,11 +1596,7 @@ export async function hoursByMachineHandler(
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 { startMs, endMs, days } = resolveRangeWindow(range, undefined, undefined, 90)
const machinesById = new Map<string, Doc<"machines"> | null>()
const companiesById = new Map<string, Doc<"companies"> | null>()
@ -1518,7 +1604,7 @@ export async function hoursByMachineHandler(
const map = new Map<string, MachineHoursEntry>()
for (const t of tickets) {
if (t.updatedAt < startMs || t.updatedAt >= endMs) continue
if (days !== 0 && (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
@ -1588,7 +1674,7 @@ export async function hoursByMachineHandler(
})
return {
rangeDays: days,
rangeDays: days === 0 ? -1 : days,
items,
}
}
@ -1601,22 +1687,20 @@ export const hoursByMachine = query({
companyId: v.optional(v.id("companies")),
machineId: v.optional(v.id("machines")),
userId: v.optional(v.id("users")),
dateFrom: v.optional(v.string()),
dateTo: v.optional(v.string()),
},
handler: hoursByMachineHandler,
})
export async function hoursByClientHandler(
ctx: QueryCtx,
{ tenantId, viewerId, range }: { tenantId: string; viewerId: Id<"users">; range?: string }
{ tenantId, viewerId, range, dateFrom, dateTo }: { tenantId: string; viewerId: Id<"users">; range?: string; dateFrom?: string; dateTo?: 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
const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90)
type Acc = {
companyId: Id<"companies">
@ -1671,22 +1755,24 @@ export async function hoursByClientHandler(
}
export const hoursByClient = query({
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) },
args: {
tenantId: v.string(),
viewerId: v.id("users"),
range: v.optional(v.string()),
dateFrom: v.optional(v.string()),
dateTo: 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 }
{ tenantId, range, dateFrom, dateTo }: { tenantId: string; range?: string; dateFrom?: string; dateTo?: 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
const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90)
type Acc = {
companyId: Id<"companies">
@ -1741,7 +1827,12 @@ export async function hoursByClientInternalHandler(
}
export const hoursByClientInternal = query({
args: { tenantId: v.string(), range: v.optional(v.string()) },
args: {
tenantId: v.string(),
range: v.optional(v.string()),
dateFrom: v.optional(v.string()),
dateTo: v.optional(v.string()),
},
handler: hoursByClientInternalHandler,
})