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

@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "RepTarget" (
"repId" TEXT NOT NULL,
"year" INTEGER NOT NULL,
"month" INTEGER NOT NULL,
"targetAmount" DECIMAL(15,2) NOT NULL,
"commissionRate" DECIMAL(5,2) NOT NULL DEFAULT 3,
"flexRate" DECIMAL(5,2) NOT NULL DEFAULT 1,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RepTarget_pkey" PRIMARY KEY ("repId","year","month")
);
-- CreateIndex
CREATE INDEX "RepTarget_repId_idx" ON "RepTarget"("repId");

View File

@@ -171,6 +171,26 @@ model Product {
@@index([deletedAt])
}
// ─── RepTarget (C7) ──────────────────────────────────────────────────────────
//
// Meta mensal e taxas de comissão por rep. Uma linha por rep/mês.
// commissionRate: % aplicada sobre o total aprovado+faturado do mês.
// flexRate: % bônus adicional quando atingido >= targetAmount.
model RepTarget {
repId String
year Int
month Int // 112
targetAmount Decimal @db.Decimal(15, 2)
commissionRate Decimal @default(3) @db.Decimal(5, 2)
flexRate Decimal @default(1) @db.Decimal(5, 2)
updatedAt DateTime @updatedAt
@@id([repId, year, month])
@@index([repId])
}
// ─── RepDiscountLimit (C4) ───────────────────────────────────────────────────
//
// Alçada de desconto por rep e por linha de produto (OQ-2 resolvida 2026-05-27).

View File

@@ -988,6 +988,35 @@ async function main() {
}
console.log(`${repDiscountLimits.length} alçadas configuradas.`);
// Metas mensais (C7)
const now = new Date();
const repTargets = [
{
repId: DEV_REP_ID,
year: now.getFullYear(),
month: now.getMonth() + 1,
targetAmount: 60000,
commissionRate: 3,
flexRate: 1,
},
{
repId: DEV_REP2_ID,
year: now.getFullYear(),
month: now.getMonth() + 1,
targetAmount: 40000,
commissionRate: 3,
flexRate: 1,
},
];
for (const t of repTargets) {
await prisma.repTarget.upsert({
where: { repId_year_month: { repId: t.repId, year: t.year, month: t.month } },
create: t,
update: { targetAmount: t.targetAmount },
});
}
console.log(`${repTargets.length} metas mensais configuradas.`);
// Upsert clients (sem lastOrderAt/openOrdersCount — calculados depois)
for (const data of clientDefs) {
await prisma.client.upsert({

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(),
};
}
}