feat(dashboard): painel Rafael — meta, comissão, inativos, pedidos recentes (C7)

GET /dashboard/rep retorna meta mensal, comissão (fixa + FLEX), clientes
inativos >30 dias e pedidos dos últimos 7 dias. RepTarget model com migration.
RafaelPainel conectado à API real via useRepDashboard().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 00:29:31 +00:00
parent 356c8e3c2c
commit 6028bf1ba9
12 changed files with 432 additions and 105 deletions

View File

@@ -11,6 +11,7 @@ import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { ClientsModule } from './clients/clients.module';
import { OrdersModule } from './orders/orders.module';
import { CatalogModule } from './catalog/catalog.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({
@@ -25,6 +26,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
ClientsModule,
OrdersModule,
CatalogModule,
DashboardModule,
],
providers: [
{ provide: APP_PIPE, useClass: ZodValidationPipe },

View File

@@ -15,6 +15,7 @@ import {
type ProductDetail,
type ProductListQuery,
type ProductListResponse,
type ProductSyncRequest,
type ProductSyncResponse,
} from '@sar/api-interface';
import { CatalogService } from './catalog.service';
@@ -41,6 +42,7 @@ export class CatalogController {
@Post('sync')
sync(@Body() body: ProductSyncRequestDto): Promise<ProductSyncResponse> {
return this.catalog.sync(body);
const parsed = ProductSyncRequestSchema.parse(body) as ProductSyncRequest;
return this.catalog.sync(parsed);
}
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { RepDashboard } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { DashboardService } from './dashboard.service';
@Controller({ path: 'dashboard' })
export class DashboardController {
constructor(
private readonly dashboard: DashboardService,
private readonly cls: ClsService<WorkspaceClsStore>,
) {}
@Get('rep')
repDashboard(): Promise<RepDashboard> {
return this.dashboard.repDashboard(this.cls.get('userId') ?? '');
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}

View File

@@ -0,0 +1,109 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { OrderStatus } from '@prisma/client';
import type { RepDashboard } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
@Injectable()
export class DashboardService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
async repDashboard(userId: string): Promise<RepDashboard> {
const prisma = this.cls.get('prisma');
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 0, 23, 59, 59, 999);
// Meta e taxas do mês
const target = await prisma.repTarget.findUnique({
where: { repId_year_month: { repId: userId, year, month } },
});
const targetAmount = target ? Number(target.targetAmount) : 0;
const commissionRate = target ? Number(target.commissionRate) : 3;
const flexRate = target ? Number(target.flexRate) : 1;
// Pedidos aprovados/faturados do mês (base do cálculo de meta e comissão)
const approvedThisMonth = await prisma.order.findMany({
where: {
repId: userId,
deletedAt: null,
status: { in: [OrderStatus.approved, OrderStatus.invoiced] },
issuedAt: { gte: monthStart, lte: monthEnd },
},
include: { client: { select: { name: true } } },
});
const atingido = approvedThisMonth.reduce((s, o) => s + Number(o.total), 0);
const pct = targetAmount > 0 ? Math.round((atingido / targetAmount) * 100) : 0;
const falta = Math.max(0, targetAmount - atingido);
const fixa = Math.round(atingido * commissionRate) / 100;
const flex =
targetAmount > 0 && atingido >= targetAmount ? Math.round(atingido * flexRate) / 100 : 0;
// Contagem total de pedidos no mês (todos status exceto cancelado)
const pedidosMes = await prisma.order.count({
where: {
repId: userId,
deletedAt: null,
status: { not: OrderStatus.cancelled },
issuedAt: { gte: monthStart, lte: monthEnd },
},
});
// Pedidos recentes — últimos 7 dias
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const recentOrders = await prisma.order.findMany({
where: {
repId: userId,
deletedAt: null,
status: { not: OrderStatus.cancelled },
issuedAt: { gte: sevenDaysAgo },
},
include: { client: { select: { name: true } } },
orderBy: { issuedAt: 'desc' },
take: 10,
});
// Clientes inativos — sem compra há > 30 dias (ou nunca compraram)
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const inactiveClients = await prisma.client.findMany({
where: {
repId: userId,
deletedAt: null,
OR: [{ lastOrderAt: null }, { lastOrderAt: { lt: thirtyDaysAgo } }],
},
orderBy: { lastOrderAt: { sort: 'asc', nulls: 'first' } },
take: 10,
});
return {
meta: { atingido, total: targetAmount, pct, falta },
comissao: { fixa, flex, total: fixa + flex },
pedidosMes,
pedidosRecentes: recentOrders.map((o) => ({
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(),
})),
clientesInativos: inactiveClients.map((c) => ({
id: c.id,
name: c.name,
diasSemCompra: c.lastOrderAt
? Math.floor((now.getTime() - c.lastOrderAt.getTime()) / 86_400_000)
: 999,
ultimaCompraValor: c.lastOrderValue !== null ? String(c.lastOrderValue) : null,
})),
syncedAt: now.toISOString(),
};
}
}