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:
@@ -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");
|
||||
@@ -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 // 1–12
|
||||
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).
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
18
apps/api/src/app/dashboard/dashboard.controller.ts
Normal file
18
apps/api/src/app/dashboard/dashboard.controller.ts
Normal 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') ?? '');
|
||||
}
|
||||
}
|
||||
9
apps/api/src/app/dashboard/dashboard.module.ts
Normal file
9
apps/api/src/app/dashboard/dashboard.module.ts
Normal 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 {}
|
||||
109
apps/api/src/app/dashboard/dashboard.service.ts
Normal file
109
apps/api/src/app/dashboard/dashboard.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user