feat: dispositivos e ajustes de csat e relatórios
This commit is contained in:
parent
25d2a9b062
commit
e0ef66555d
86 changed files with 5811 additions and 992 deletions
|
|
@ -51,6 +51,39 @@ function extractScore(payload: unknown): number | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function extractMaxScore(payload: unknown): number | null {
|
||||
if (payload && typeof payload === "object" && "maxScore" in payload) {
|
||||
const value = (payload as { maxScore: unknown }).maxScore;
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractComment(payload: unknown): string | null {
|
||||
if (payload && typeof payload === "object" && "comment" in payload) {
|
||||
const value = (payload as { comment: unknown }).comment;
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractAssignee(payload: unknown): { id: string | null; name: string | null } {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return { id: null, name: null }
|
||||
}
|
||||
const record = payload as Record<string, unknown>
|
||||
const rawId = record["assigneeId"]
|
||||
const rawName = record["assigneeName"]
|
||||
const id = typeof rawId === "string" && rawId.trim().length > 0 ? rawId.trim() : null
|
||||
const name = typeof rawName === "string" && rawName.trim().length > 0 ? rawName.trim() : null
|
||||
return { id, name }
|
||||
}
|
||||
|
||||
function isNotNull<T>(value: T | null): value is T {
|
||||
return value !== null;
|
||||
}
|
||||
|
|
@ -119,6 +152,44 @@ async function fetchScopedTicketsByCreatedRange(
|
|||
.collect();
|
||||
}
|
||||
|
||||
async function fetchScopedTicketsByResolvedRange(
|
||||
ctx: QueryCtx,
|
||||
tenantId: string,
|
||||
viewer: Awaited<ReturnType<typeof requireStaff>>,
|
||||
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_resolved", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!).gte("resolvedAt", startMs),
|
||||
)
|
||||
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
|
||||
.collect();
|
||||
}
|
||||
|
||||
if (companyId) {
|
||||
return ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_company_resolved", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("companyId", companyId).gte("resolvedAt", startMs),
|
||||
)
|
||||
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
|
||||
.collect();
|
||||
}
|
||||
|
||||
return ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_resolved", (q) => q.eq("tenantId", tenantId).gte("resolvedAt", startMs))
|
||||
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
|
||||
.collect();
|
||||
}
|
||||
|
||||
async function fetchQueues(ctx: QueryCtx, tenantId: string) {
|
||||
return ctx.db
|
||||
.query("queues")
|
||||
|
|
@ -159,12 +230,47 @@ type CsatSurvey = {
|
|||
ticketId: Id<"tickets">;
|
||||
reference: number;
|
||||
score: number;
|
||||
maxScore: number;
|
||||
comment: string | null;
|
||||
receivedAt: number;
|
||||
assigneeId: string | null;
|
||||
assigneeName: string | null;
|
||||
};
|
||||
|
||||
async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Promise<CsatSurvey[]> {
|
||||
const perTicket = await Promise.all(
|
||||
tickets.map(async (ticket) => {
|
||||
if (typeof ticket.csatScore === "number") {
|
||||
const snapshot = (ticket.csatAssigneeSnapshot ?? null) as {
|
||||
name?: string
|
||||
email?: string
|
||||
} | null
|
||||
const assigneeId =
|
||||
ticket.csatAssigneeId && typeof ticket.csatAssigneeId === "string"
|
||||
? ticket.csatAssigneeId
|
||||
: ticket.csatAssigneeId
|
||||
? String(ticket.csatAssigneeId)
|
||||
: null
|
||||
const assigneeName =
|
||||
snapshot && typeof snapshot.name === "string" && snapshot.name.trim().length > 0
|
||||
? snapshot.name.trim()
|
||||
: null
|
||||
return [
|
||||
{
|
||||
ticketId: ticket._id,
|
||||
reference: ticket.reference,
|
||||
score: ticket.csatScore,
|
||||
maxScore: ticket.csatMaxScore && Number.isFinite(ticket.csatMaxScore) ? (ticket.csatMaxScore as number) : 5,
|
||||
comment:
|
||||
typeof ticket.csatComment === "string" && ticket.csatComment.trim().length > 0
|
||||
? ticket.csatComment.trim()
|
||||
: null,
|
||||
receivedAt: ticket.csatRatedAt ?? ticket.updatedAt ?? ticket.createdAt,
|
||||
assigneeId,
|
||||
assigneeName,
|
||||
} satisfies CsatSurvey,
|
||||
];
|
||||
}
|
||||
const events = await ctx.db
|
||||
.query("ticketEvents")
|
||||
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
|
||||
|
|
@ -174,11 +280,16 @@ async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Pro
|
|||
.map((event) => {
|
||||
const score = extractScore(event.payload);
|
||||
if (score === null) return null;
|
||||
const assignee = extractAssignee(event.payload)
|
||||
return {
|
||||
ticketId: ticket._id,
|
||||
reference: ticket.reference,
|
||||
score,
|
||||
maxScore: extractMaxScore(event.payload) ?? 5,
|
||||
comment: extractComment(event.payload),
|
||||
receivedAt: event.createdAt,
|
||||
assigneeId: assignee.id,
|
||||
assigneeName: assignee.name,
|
||||
} as CsatSurvey;
|
||||
})
|
||||
.filter(isNotNull);
|
||||
|
|
@ -275,12 +386,61 @@ export async function csatOverviewHandler(
|
|||
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 normalizeToFive = (value: CsatSurvey) => {
|
||||
if (!value.maxScore || value.maxScore <= 0) return value.score;
|
||||
return Math.min(5, Math.max(1, (value.score / value.maxScore) * 5));
|
||||
};
|
||||
|
||||
const averageScore = average(surveys.map((item) => normalizeToFive(item)));
|
||||
const distribution = [1, 2, 3, 4, 5].map((score) => ({
|
||||
score,
|
||||
total: surveys.filter((item) => item.score === score).length,
|
||||
total: surveys.filter((item) => Math.round(normalizeToFive(item)) === score).length,
|
||||
}));
|
||||
|
||||
const positiveThreshold = 4;
|
||||
const positiveCount = surveys.filter((item) => normalizeToFive(item) >= positiveThreshold).length;
|
||||
const positiveRate = surveys.length > 0 ? positiveCount / surveys.length : null;
|
||||
|
||||
const agentStats = new Map<
|
||||
string,
|
||||
{ id: string; name: string; total: number; sum: number; positive: number }
|
||||
>();
|
||||
|
||||
for (const survey of surveys) {
|
||||
const normalizedScore = normalizeToFive(survey);
|
||||
const key = survey.assigneeId ?? "unassigned";
|
||||
const existing = agentStats.get(key) ?? {
|
||||
id: key,
|
||||
name: survey.assigneeName ?? "Sem responsável",
|
||||
total: 0,
|
||||
sum: 0,
|
||||
positive: 0,
|
||||
};
|
||||
existing.total += 1;
|
||||
existing.sum += normalizedScore;
|
||||
if (normalizedScore >= positiveThreshold) {
|
||||
existing.positive += 1;
|
||||
}
|
||||
if (survey.assigneeName && survey.assigneeName.trim().length > 0) {
|
||||
existing.name = survey.assigneeName.trim();
|
||||
}
|
||||
agentStats.set(key, existing);
|
||||
}
|
||||
|
||||
const byAgent = Array.from(agentStats.values())
|
||||
.map((entry) => ({
|
||||
agentId: entry.id === "unassigned" ? null : entry.id,
|
||||
agentName: entry.id === "unassigned" ? "Sem responsável" : entry.name,
|
||||
totalResponses: entry.total,
|
||||
averageScore: entry.total > 0 ? entry.sum / entry.total : null,
|
||||
positiveRate: entry.total > 0 ? entry.positive / entry.total : null,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const diff = (b.averageScore ?? 0) - (a.averageScore ?? 0);
|
||||
if (Math.abs(diff) > 0.0001) return diff;
|
||||
return (b.totalResponses ?? 0) - (a.totalResponses ?? 0);
|
||||
});
|
||||
|
||||
return {
|
||||
totalSurveys: surveys.length,
|
||||
averageScore,
|
||||
|
|
@ -293,9 +453,15 @@ export async function csatOverviewHandler(
|
|||
ticketId: item.ticketId,
|
||||
reference: item.reference,
|
||||
score: item.score,
|
||||
maxScore: item.maxScore,
|
||||
comment: item.comment,
|
||||
receivedAt: item.receivedAt,
|
||||
assigneeId: item.assigneeId,
|
||||
assigneeName: item.assigneeName,
|
||||
})),
|
||||
rangeDays: days,
|
||||
positiveRate,
|
||||
byAgent,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -309,15 +475,15 @@ export async function openedResolvedByDayHandler(
|
|||
{ 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 openedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
||||
const resolvedTickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
||||
|
||||
const opened: Record<string, number> = {}
|
||||
const resolved: Record<string, number> = {}
|
||||
|
||||
|
|
@ -328,15 +494,19 @@ export async function openedResolvedByDayHandler(
|
|||
resolved[key] = 0
|
||||
}
|
||||
|
||||
for (const t of tickets) {
|
||||
if (t.createdAt >= startMs && t.createdAt < endMs) {
|
||||
const key = formatDateKey(t.createdAt)
|
||||
for (const ticket of openedTickets) {
|
||||
if (ticket.createdAt >= startMs && ticket.createdAt < endMs) {
|
||||
const key = formatDateKey(ticket.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
|
||||
}
|
||||
|
||||
for (const ticket of resolvedTickets) {
|
||||
if (typeof ticket.resolvedAt !== "number") {
|
||||
continue
|
||||
}
|
||||
const key = formatDateKey(ticket.resolvedAt)
|
||||
resolved[key] = (resolved[key] ?? 0) + 1
|
||||
}
|
||||
|
||||
const series: Array<{ date: string; opened: number; resolved: number }> = []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue