feat(dashboard): painel Sandra — aprovações, pedidos do dia, inativos por rep (C8)

GET /dashboard/supervisor com fila de aprovações, KPIs do dia vs semana
anterior e top 3 reps com mais clientes inativos. SandraPainel com polling
30s. Rota / role-aware: rep → RafaelPainel, supervisor/manager → SandraPainel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 00:40:08 +00:00
parent 6028bf1ba9
commit 36103eaa87
6 changed files with 454 additions and 5 deletions

View File

@@ -1,6 +1,6 @@
import { Controller, Get } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { RepDashboard } from '@sar/api-interface';
import type { RepDashboard, SupervisorDashboard } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { DashboardService } from './dashboard.service';
@@ -15,4 +15,9 @@ export class DashboardController {
repDashboard(): Promise<RepDashboard> {
return this.dashboard.repDashboard(this.cls.get('userId') ?? '');
}
@Get('supervisor')
supervisorDashboard(): Promise<SupervisorDashboard> {
return this.dashboard.supervisorDashboard();
}
}

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { OrderStatus } from '@prisma/client';
import type { RepDashboard } from '@sar/api-interface';
import type { RepDashboard, SupervisorDashboard } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
@Injectable()
@@ -106,4 +106,78 @@ export class DashboardService {
syncedAt: now.toISOString(),
};
}
async supervisorDashboard(): Promise<SupervisorDashboard> {
const prisma = this.cls.get('prisma');
const now = new Date();
// Fila de aprovações — mais antigos primeiro
const approvalQueue = await prisma.order.findMany({
where: { deletedAt: null, status: OrderStatus.pending_approval },
include: { client: { select: { name: true } } },
orderBy: { issuedAt: 'asc' },
take: 50,
});
// Pedidos do dia (hoje, meia-noite até agora)
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayOrders = await prisma.order.findMany({
where: {
deletedAt: null,
status: { not: OrderStatus.cancelled },
issuedAt: { gte: todayStart },
},
});
// Mesmo dia da semana passada (comparativo)
const lastWeekStart = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000);
const lastWeekEnd = new Date(lastWeekStart.getTime() + 24 * 60 * 60 * 1000 - 1);
const lastWeekOrders = await prisma.order.findMany({
where: {
deletedAt: null,
status: { not: OrderStatus.cancelled },
issuedAt: { gte: lastWeekStart, lte: lastWeekEnd },
},
});
// Inativos por rep — top 3 reps com mais clientes inativos (>30 dias)
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const inativosPorRep = await prisma.client.groupBy({
by: ['repId'],
where: {
deletedAt: null,
OR: [{ lastOrderAt: null }, { lastOrderAt: { lt: thirtyDaysAgo } }],
},
_count: { id: true },
orderBy: { _count: { id: 'desc' } },
take: 3,
});
const mapOrder = (o: (typeof approvalQueue)[number]) => ({
id: o.id,
number: o.number,
clientId: o.clientId,
clientName: o.client.name,
repId: o.repId,
status: o.status,
total: String(o.total),
discountPct: String(o.discountPct),
issuedAt: o.issuedAt.toISOString(),
});
return {
approvalQueue: approvalQueue.map(mapOrder),
pedidosDia: {
count: todayOrders.length,
total: todayOrders.reduce((s, o) => s + Number(o.total), 0),
countSemanaAnterior: lastWeekOrders.length,
totalSemanaAnterior: lastWeekOrders.reduce((s, o) => s + Number(o.total), 0),
},
inativosPorRep: inativosPorRep.map((r) => ({
repId: r.repId,
inativosCount: r._count.id,
})),
syncedAt: now.toISOString(),
};
}
}