feat(api,web): regras de desconto por pauta e cards de supervisor na equipe

Regras de Desconto (Politicas Comerciais):
- Nova dimensao de escopo por pauta de precos: cria 1 regra por pauta
  selecionada (idPautas[]), aplicada a todos os produtos da pauta.
- Coluna id_pauta em sar.regras_desconto (migration idempotente + schema
  canonico); motor de alcada (orders) casa regra<->pedido por idPauta.
- Remove a aba/endpoints "Desconto por Representante" (gestao de
  alcada_desconto); o teto por rep continua no motor (default 5%) e no
  carrinho (/politicas/alcada), agora liberado via Regra de Desconto.

Aba Equipe do gerente:
- GET /equipe/supervisores: cards de disputa por supervisor (faturamento,
  meta, positivacao, variacao mensal, reps ativos).
- SupervisorDetalheModal: detalhe do time (ranking + positivacao) reusando
  /dashboard/supervisor escopado por codSupervisor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 21:12:00 +00:00
parent 1ce8d96fea
commit 9b855f63b2
17 changed files with 694 additions and 386 deletions

View File

@@ -0,0 +1,6 @@
-- AlterTable: sar.regras_desconto -- adiciona escopo por pauta de precos.
-- Regra pode ser restrita a uma pauta especifica; id_pauta NULL = qualquer pauta.
-- provision-client.ts roda scripts/sar-erp-schema.sql (idempotente) antes de
-- `prisma migrate deploy`, entao este ALTER precisa ser idempotente tambem.
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
ALTER TABLE sar.regras_desconto ADD COLUMN IF NOT EXISTS id_pauta INTEGER;

View File

@@ -174,7 +174,8 @@ model Promocao {
// ─── RegraDesconto (Políticas Comerciais) ──────────────────────────────────── // ─── RegraDesconto (Políticas Comerciais) ────────────────────────────────────
// //
// Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou // Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou
// subgrupo de produto e/ou representante, com vigência. Campos nulos = "todos". // subgrupo de produto, pauta de preços e/ou representante, com vigência.
// Campos nulos = "todos" (id_pauta nulo = qualquer pauta).
model RegraDesconto { model RegraDesconto {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
@@ -183,6 +184,7 @@ model RegraDesconto {
codGrupo Int? @map("cod_grupo") codGrupo Int? @map("cod_grupo")
codSubgrupo Int? @map("cod_subgrupo") codSubgrupo Int? @map("cod_subgrupo")
codVendedor Int? @map("cod_vendedor") codVendedor Int? @map("cod_vendedor")
idPauta Int? @map("id_pauta")
descPct Decimal @db.Decimal(5, 2) @map("desc_pct") descPct Decimal @db.Decimal(5, 2) @map("desc_pct")
dataInicio DateTime @db.Date @map("data_inicio") dataInicio DateTime @db.Date @map("data_inicio")
dataFim DateTime @db.Date @map("data_fim") dataFim DateTime @db.Date @map("data_fim")

View File

@@ -45,9 +45,13 @@ export class DashboardController {
} }
@Get('supervisor') @Get('supervisor')
supervisorDashboard(): Promise<SupervisorDashboard> { supervisorDashboard(
@Query('codSupervisor') codSupervisor?: string,
): Promise<SupervisorDashboard> {
this.assertGestor(); this.assertGestor();
return this.dashboard.supervisorDashboard(); return this.dashboard.supervisorDashboard(
codSupervisor ? parseInt(codSupervisor, 10) : undefined,
);
} }
@Get('supervisor/inativos') @Get('supervisor/inativos')

View File

@@ -729,18 +729,25 @@ export class DashboardService {
}); });
} }
async supervisorDashboard(): Promise<SupervisorDashboard> { async supervisorDashboard(codSupervisor?: number): Promise<SupervisorDashboard> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
const now = new Date(); const now = new Date();
// Supervisor vê só a própria equipe (cod_supervisor em vw_representantes); // Supervisor vê só a própria equipe (cod_supervisor em vw_representantes).
// gerente/admin acessando este painel veem a empresa toda. // Gerente/admin: sem codSupervisor veem a empresa toda; com codSupervisor
// (Detalhar da aba Equipe) veem o time daquele supervisor. codSupervisor é
// ignorado para role 'supervisor' — não pode espiar outra equipe.
const role = this.cls.get('role'); const role = this.cls.get('role');
const userId = this.cls.get('userId'); const userId = this.cls.get('userId');
const team = let teamCode: number | null = null;
role === 'supervisor' ? await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0) : null; if (role === 'supervisor') {
teamCode = userId ? parseInt(userId, 10) : 0;
} else if (codSupervisor && codSupervisor > 0) {
teamCode = codSupervisor;
}
const team = teamCode ? await getTeamCodes(prisma, teamCode) : null;
const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : ''); const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : '');
// Pedidos do dia — lê do ERP (situa != 5=Cancelado) // Pedidos do dia — lê do ERP (situa != 5=Cancelado)

View File

@@ -1,5 +1,5 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import type { EquipeResponse } from '@sar/api-interface'; import type { EquipeResponse, SupervisoresResponse } from '@sar/api-interface';
import { EquipeService } from './equipe.service'; import { EquipeService } from './equipe.service';
@Controller({ path: 'equipe' }) @Controller({ path: 'equipe' })
@@ -10,4 +10,9 @@ export class EquipeController {
listEquipe(): Promise<EquipeResponse> { listEquipe(): Promise<EquipeResponse> {
return this.equipe.listEquipe(); return this.equipe.listEquipe();
} }
@Get('supervisores')
listSupervisores(): Promise<SupervisoresResponse> {
return this.equipe.listSupervisores();
}
} }

View File

@@ -1,6 +1,6 @@
import { ForbiddenException, Injectable } from '@nestjs/common'; import { ForbiddenException, Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import type { EquipeResponse } from '@sar/api-interface'; import type { EquipeResponse, SupervisorCard, SupervisoresResponse } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
function matrizEmpresa(idEmpresa: number): number { function matrizEmpresa(idEmpresa: number): number {
@@ -110,4 +110,173 @@ export class EquipeService {
syncedAt: now.toISOString(), syncedAt: now.toISOString(),
}; };
} }
// Cards de supervisor da aba Equipe: agrega as métricas do mês por equipe.
// Time de um supervisor = reps cujo cod_supervisor aponta para ele + ele
// próprio (mesma regra de getTeamCodes, que o Detalhe reusa via
// /dashboard/supervisor?codSupervisor=). Agregação feita em JS a partir de
// métricas por rep — as mesmas contas do painel, sem escopo por equipe.
async listSupervisores(): Promise<SupervisoresResponse> {
const role = this.cls.get('role') ?? 'rep';
if (role !== 'manager' && role !== 'admin') {
throw new ForbiddenException('Apenas gerentes podem consultar os supervisores');
}
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const monthStartStr = new Date(year, month - 1, 1).toISOString().slice(0, 10);
const monthEndStr = new Date(year, month, 0).toISOString().slice(0, 10);
const prevStartStr = new Date(year, month - 2, 1).toISOString().slice(0, 10);
const prevEndStr = new Date(year, month - 1, 0).toISOString().slice(0, 10);
const [repRows, pedidosRows, prevRows, metaRows, positivacaoRows] = await Promise.all([
prisma.$queryRawUnsafe<
{ cod_vendedor: number; cod_supervisor: number | null; nome: string | null }[]
>(`SELECT codigo AS cod_vendedor, cod_supervisor, nome FROM vw_representantes`),
prisma.$queryRawUnsafe<PedidosRepRow[]>(`
SELECT cod_vendedor,
COUNT(*)::text AS pedidos,
COALESCE(SUM(total), 0)::text AS faturamento
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
AND dt_pedido >= '${monthStartStr}'
AND dt_pedido <= '${monthEndStr}'
GROUP BY cod_vendedor
`),
prisma.$queryRawUnsafe<{ cod_vendedor: number; faturamento: string }[]>(`
SELECT cod_vendedor,
COALESCE(SUM(total), 0)::text AS faturamento
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
AND dt_pedido >= '${prevStartStr}'
AND dt_pedido <= '${prevEndStr}'
GROUP BY cod_vendedor
`),
prisma.$queryRawUnsafe<MetaRepRow[]>(`
SELECT cod_vendedor,
SUM(valor)::text AS meta_valor
FROM sar.vw_metas
WHERE id_empresa = ${idEmpresaMatriz}
AND ano = ${year}
AND mes = ${month}
AND TRIM(tipo) = 'GR'
GROUP BY cod_vendedor
`),
prisma.$queryRawUnsafe<
{ cod_vendedor: number; total_clientes: string; positivados: string }[]
>(`
SELECT c.cod_vendedor,
COUNT(DISTINCT c.id_cliente)::text AS total_clientes,
COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END)::text AS positivados
FROM vw_clientes c
LEFT JOIN (
SELECT DISTINCT id_cliente, cod_vendedor
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
AND dt_pedido >= '${monthStartStr}'
AND dt_pedido <= '${monthEndStr}'
) ped ON ped.id_cliente = c.id_cliente AND ped.cod_vendedor = c.cod_vendedor
WHERE c.cod_vendedor > 0
GROUP BY c.cod_vendedor
`),
]);
const pedidosMap = new Map(
pedidosRows.map((r) => [
Number(r.cod_vendedor),
{ pedidos: Number(r.pedidos), faturamento: Number(r.faturamento) },
]),
);
const prevMap = new Map(prevRows.map((r) => [Number(r.cod_vendedor), Number(r.faturamento)]));
const metaMap = new Map(metaRows.map((r) => [Number(r.cod_vendedor), Number(r.meta_valor)]));
const posMap = new Map(
positivacaoRows.map((r) => [
Number(r.cod_vendedor),
{ total: Number(r.total_clientes), positivados: Number(r.positivados) },
]),
);
// Monta a equipe de cada supervisor: código do supervisor -> códigos dos reps.
// O supervisor entra no próprio time (getTeamCodes inclui o supervisor).
const nomeByCod = new Map<number, string | null>();
const membrosBySup = new Map<number, Set<number>>();
for (const r of repRows) {
const cod = Number(r.cod_vendedor);
if (!nomeByCod.has(cod)) nomeByCod.set(cod, r.nome ?? null);
const sup = r.cod_supervisor != null ? Number(r.cod_supervisor) : 0;
if (sup > 0) {
let membros = membrosBySup.get(sup);
if (!membros) {
membros = new Set();
membrosBySup.set(sup, membros);
}
membros.add(cod);
}
}
const supervisores: SupervisorCard[] = [...membrosBySup.entries()].map(([sup, membros]) => {
const team = new Set(membros);
team.add(sup); // o próprio supervisor faz parte do time
let faturamento = 0;
let faturamentoAnterior = 0;
let pedidos = 0;
let meta = 0;
let totalClientes = 0;
let positivados = 0;
let repsAtivos = 0;
for (const cod of team) {
const p = pedidosMap.get(cod);
if (p) {
faturamento += p.faturamento;
pedidos += p.pedidos;
if (p.faturamento > 0) repsAtivos += 1;
}
faturamentoAnterior += prevMap.get(cod) ?? 0;
meta += metaMap.get(cod) ?? 0;
const pos = posMap.get(cod);
if (pos) {
totalClientes += pos.total;
positivados += pos.positivados;
}
}
return {
codSupervisor: sup,
nomeSupervisor: nomeByCod.get(sup) ?? null,
faturamentoMes: faturamento,
faturamentoMesAnterior: faturamentoAnterior,
variacaoPct:
faturamentoAnterior > 0
? Math.round(((faturamento - faturamentoAnterior) / faturamentoAnterior) * 100)
: null,
pedidosMes: pedidos,
ticketMedio: pedidos > 0 ? faturamento / pedidos : 0,
metaTotal: meta,
pctMeta: meta > 0 ? Math.round((faturamento / meta) * 100) : 0,
totalClientes,
clientesPositivados: positivados,
pctPositivacao: totalClientes > 0 ? Math.round((positivados / totalClientes) * 100) : 0,
repsTotal: team.size,
repsAtivos,
};
});
// Ranking/disputa: maior faturamento primeiro.
supervisores.sort((a, b) => b.faturamentoMes - a.faturamentoMes);
return {
supervisores,
syncedAt: now.toISOString(),
};
}
} }

View File

@@ -626,14 +626,16 @@ export class OrdersService {
) )
.reduce((max, p) => Math.max(max, Number(p.descPct)), 0); .reduce((max, p) => Math.max(max, Number(p.descPct)), 0);
// Regra sem grupo/subgrupo vale para qualquer produto; com grupo e/ou // Regra sem alvo vale para qualquer produto; com grupo, subgrupo e/ou
// subgrupo, o produto precisa casar com o que a regra especifica. // pauta, precisa casar com o que a regra especifica. Regra por pauta só
// vale quando o pedido usa aquela pauta (id_pauta nulo = qualquer pauta).
const codSubgrupo = prod?.cod_subgrupo != null ? Number(prod.cod_subgrupo) : null; const codSubgrupo = prod?.cod_subgrupo != null ? Number(prod.cod_subgrupo) : null;
const regraPct = regras const regraPct = regras
.filter( .filter(
(r) => (r) =>
(r.codGrupo == null || (codGrupo != null && r.codGrupo === codGrupo)) && (r.codGrupo == null || (codGrupo != null && r.codGrupo === codGrupo)) &&
(r.codSubgrupo == null || (codSubgrupo != null && r.codSubgrupo === codSubgrupo)), (r.codSubgrupo == null || (codSubgrupo != null && r.codSubgrupo === codSubgrupo)) &&
(r.idPauta == null || (pedido.idPauta != null && r.idPauta === pedido.idPauta)),
) )
.reduce((max, r) => Math.max(max, Number(r.descPct)), 0); .reduce((max, r) => Math.max(max, Number(r.descPct)), 0);

View File

@@ -11,17 +11,14 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { import {
UpsertDescontoBodySchema,
CreatePromocaoBodySchema, CreatePromocaoBodySchema,
UpdatePromocaoBodySchema, UpdatePromocaoBodySchema,
CreateRegraDescontoBodySchema, CreateRegraDescontoBodySchema,
UpdateRegraDescontoBodySchema, UpdateRegraDescontoBodySchema,
type UpsertDescontoBody,
type CreatePromocaoBody, type CreatePromocaoBody,
type UpdatePromocaoBody, type UpdatePromocaoBody,
type CreateRegraDescontoBody, type CreateRegraDescontoBody,
type UpdateRegraDescontoBody, type UpdateRegraDescontoBody,
type AlcadaDescontosResponse,
type PromocoesResponse, type PromocoesResponse,
type PromocoesVigentesResponse, type PromocoesVigentesResponse,
type AlcadaRepResponse, type AlcadaRepResponse,
@@ -31,7 +28,6 @@ import {
} from '@sar/api-interface'; } from '@sar/api-interface';
import { PoliticasService } from './politicas.service'; import { PoliticasService } from './politicas.service';
class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {}
class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {} class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {}
class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {} class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {}
class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {} class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {}
@@ -41,17 +37,6 @@ class UpdateRegraDescontoDto extends createZodDto(UpdateRegraDescontoBodySchema)
export class PoliticasController { export class PoliticasController {
constructor(private readonly politicas: PoliticasService) {} constructor(private readonly politicas: PoliticasService) {}
@Get('descontos')
listDescontos(): Promise<AlcadaDescontosResponse> {
return this.politicas.listDescontos();
}
@Post('descontos')
@HttpCode(200)
upsertDesconto(@Body() body: UpsertDescontoDto): Promise<void> {
return this.politicas.upsertDesconto(body as unknown as UpsertDescontoBody);
}
@Get('promocoes') @Get('promocoes')
listPromocoes(): Promise<PromocoesResponse> { listPromocoes(): Promise<PromocoesResponse> {
return this.politicas.listPromocoes(); return this.politicas.listPromocoes();

View File

@@ -1,8 +1,6 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import type { import type {
AlcadaDescontosResponse,
UpsertDescontoBody,
PromocoesResponse, PromocoesResponse,
CreatePromocaoBody, CreatePromocaoBody,
UpdatePromocaoBody, UpdatePromocaoBody,
@@ -27,61 +25,6 @@ export class PoliticasService {
} }
} }
async listDescontos(): Promise<AlcadaDescontosResponse> {
this.assertManager();
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const rows = await prisma.alcadaDesconto.findMany({
where: { idEmpresa },
orderBy: [{ codVendedor: 'asc' }, { codGrupo: 'asc' }],
});
// Busca nomes dos representantes
const codigos = [...new Set(rows.map((r) => r.codVendedor))];
const repRows =
codigos.length > 0
? await prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${codigos.join(',')})`,
)
: [];
const repNameMap = new Map(repRows.map((r) => [Number(r.codigo), r.nome]));
return {
descontos: rows.map((r) => ({
codVendedor: r.codVendedor,
codGrupo: r.codGrupo,
limitePerc: Number(r.limitePerc),
nomeVendedor: repNameMap.get(r.codVendedor) ?? null,
})),
};
}
async upsertDesconto(body: UpsertDescontoBody): Promise<void> {
this.assertManager();
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
await prisma.alcadaDesconto.upsert({
where: {
codVendedor_idEmpresa_codGrupo: {
codVendedor: body.codVendedor,
idEmpresa,
codGrupo: body.codGrupo ?? 0,
},
},
create: {
codVendedor: body.codVendedor,
idEmpresa,
codGrupo: body.codGrupo ?? 0,
limitePerc: body.limitePerc,
},
update: { limitePerc: body.limitePerc },
});
}
async listPromocoes(): Promise<PromocoesResponse> { async listPromocoes(): Promise<PromocoesResponse> {
this.assertManager(); this.assertManager();
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
@@ -220,6 +163,23 @@ export class PoliticasService {
}; };
} }
// Descrições das pautas informadas — UI sempre mostra o nome, nunca só o id.
private async loadPautaNames(idPautas: number[]): Promise<Map<number, string | null>> {
if (idPautas.length === 0) return new Map();
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
const rows = await prisma.$queryRawUnsafe<{ id_pauta: number; descricao: string | null }[]>(
`SELECT id_pauta, TRIM(descricao) AS descricao
FROM vw_pautas
WHERE id_empresa = ${idMatriz}
AND id_pauta IN (${idPautas.join(',')})`,
);
return new Map(rows.map((r) => [Number(r.id_pauta), r.descricao]));
}
async listRegras(): Promise<RegrasDescontoResponse> { async listRegras(): Promise<RegrasDescontoResponse> {
this.assertManager(); this.assertManager();
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
@@ -252,6 +212,9 @@ export class PoliticasService {
grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]), grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]),
); );
const idPautas = [...new Set(rows.map((r) => r.idPauta).filter((p): p is number => p != null))];
const pautaNameMap = await this.loadPautaNames(idPautas);
return { return {
regras: rows.map((r) => ({ regras: rows.map((r) => ({
id: r.id, id: r.id,
@@ -260,6 +223,8 @@ export class PoliticasService {
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null, grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
codSubgrupo: r.codSubgrupo, codSubgrupo: r.codSubgrupo,
subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null, subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null,
idPauta: r.idPauta,
pauta: r.idPauta != null ? (pautaNameMap.get(r.idPauta) ?? null) : null,
codVendedor: r.codVendedor, codVendedor: r.codVendedor,
nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null, nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null,
descPct: Number(r.descPct), descPct: Number(r.descPct),
@@ -272,8 +237,9 @@ export class PoliticasService {
}; };
} }
// Cria uma regra POR ALVO: cada grupo selecionado vira regra por grupo e // Cria uma regra POR ALVO: cada grupo vira regra por grupo, cada subgrupo
// cada subgrupo vira regra por subgrupo. Sem alvos, regra vale para todos. // vira regra por subgrupo e cada pauta vira regra por pauta. O representante
// escolhido é copiado em todas. Sem alvos, regra vale para todos.
async createRegra(body: CreateRegraDescontoBody): Promise<{ ids: number[] }> { async createRegra(body: CreateRegraDescontoBody): Promise<{ ids: number[] }> {
this.assertManager(); this.assertManager();
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
@@ -281,11 +247,17 @@ export class PoliticasService {
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId') ?? 'system'; const userId = this.cls.get('userId') ?? 'system';
const alvos: { codGrupo: number | null; codSubgrupo: number | null }[] = [ const alvos: { codGrupo: number | null; codSubgrupo: number | null; idPauta: number | null }[] =
...(body.codGrupos ?? []).map((g) => ({ codGrupo: g, codSubgrupo: null })), [
...(body.codSubgrupos ?? []).map((s) => ({ codGrupo: null, codSubgrupo: s })), ...(body.codGrupos ?? []).map((g) => ({ codGrupo: g, codSubgrupo: null, idPauta: null })),
]; ...(body.codSubgrupos ?? []).map((s) => ({
if (alvos.length === 0) alvos.push({ codGrupo: null, codSubgrupo: null }); codGrupo: null,
codSubgrupo: s,
idPauta: null,
})),
...(body.idPautas ?? []).map((p) => ({ codGrupo: null, codSubgrupo: null, idPauta: p })),
];
if (alvos.length === 0) alvos.push({ codGrupo: null, codSubgrupo: null, idPauta: null });
const ids = await prisma.$transaction(async (tx) => { const ids = await prisma.$transaction(async (tx) => {
const created: number[] = []; const created: number[] = [];
@@ -296,6 +268,7 @@ export class PoliticasService {
descricao: body.descricao, descricao: body.descricao,
codGrupo: alvo.codGrupo, codGrupo: alvo.codGrupo,
codSubgrupo: alvo.codSubgrupo, codSubgrupo: alvo.codSubgrupo,
idPauta: alvo.idPauta,
codVendedor: body.codVendedor ?? null, codVendedor: body.codVendedor ?? null,
descPct: body.descPct, descPct: body.descPct,
dataInicio: new Date(body.dataInicio), dataInicio: new Date(body.dataInicio),
@@ -325,6 +298,7 @@ export class PoliticasService {
...(body.descricao !== undefined && { descricao: body.descricao }), ...(body.descricao !== undefined && { descricao: body.descricao }),
...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }), ...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }),
...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }), ...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }),
...(body.idPauta !== undefined && { idPauta: body.idPauta }),
...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }), ...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }),
...(body.descPct !== undefined && { descPct: body.descPct }), ...(body.descPct !== undefined && { descPct: body.descPct }),
...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }), ...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }),
@@ -453,6 +427,9 @@ export class PoliticasService {
grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]), grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]),
); );
const idPautas = [...new Set(rows.map((r) => r.idPauta).filter((p): p is number => p != null))];
const pautaNameMap = await this.loadPautaNames(idPautas);
return { return {
regras: rows.map((r) => ({ regras: rows.map((r) => ({
id: r.id, id: r.id,
@@ -461,6 +438,8 @@ export class PoliticasService {
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null, grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
codSubgrupo: r.codSubgrupo, codSubgrupo: r.codSubgrupo,
subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null, subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null,
idPauta: r.idPauta,
pauta: r.idPauta != null ? (pautaNameMap.get(r.idPauta) ?? null) : null,
descPct: Number(r.descPct), descPct: Number(r.descPct),
dataFim: r.dataFim.toISOString().slice(0, 10), dataFim: r.dataFim.toISOString().slice(0, 10),
})), })),

View File

@@ -1,7 +1,8 @@
import { Card, Flex, Progress, Skeleton, Table, Tag, Typography } from 'antd'; import { useState } from 'react';
import type { TableColumnsType } from 'antd'; import { Button, Card, Col, Empty, Flex, Progress, Row, Skeleton, Space, Typography } from 'antd';
import type { RepStats } from '@sar/api-interface'; import type { SupervisorCard } from '@sar/api-interface';
import { useEquipe } from '../../lib/queries/gerente'; import { useSupervisores } from '../../lib/queries/gerente';
import { SupervisorDetalheModal } from './SupervisorDetalheModal';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -9,85 +10,118 @@ function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
} }
const columns: TableColumnsType<RepStats> = [ function metaColor(pct: number): string {
{ return pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14';
title: 'Representante', }
key: 'rep',
render: (_: unknown, row: RepStats) => ( const MEDALHAS = ['🥇', '🥈', '🥉'];
<Flex vertical gap={0}>
<Text strong>{row.nomeVendedor ?? `Cód. ${row.codVendedor}`}</Text> function posicaoLabel(i: number): string {
{row.nomeVendedor && ( return MEDALHAS[i] ?? `${i + 1}º`;
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}> }
cód. {row.codVendedor}
</Text> function SupervisorCardView({
)} s,
</Flex> posicao,
), onDetalhar,
}, }: {
{ s: SupervisorCard;
title: 'Pedidos', posicao: number;
dataIndex: 'pedidosMes', onDetalhar: () => void;
width: 90, }) {
align: 'right', const lider = posicao === 0;
className: 'tabular-nums', return (
sorter: (a, b) => a.pedidosMes - b.pedidosMes, <Card
render: (v: number) => (v === 0 ? <Tag>0</Tag> : v), style={lider ? { borderColor: 'var(--jcs-blue)', borderWidth: 2 } : undefined}
}, styles={{ body: { padding: 'var(--space-lg)' } }}
{ >
title: 'Faturamento', <Flex vertical gap={12}>
dataIndex: 'faturamentoMes', {/* Cabeçalho: posição + nome */}
width: 160, <Flex align="center" gap={10}>
align: 'right', <span style={{ fontSize: 22, lineHeight: 1, minWidth: 30, textAlign: 'center' }}>
className: 'tabular-nums', {posicaoLabel(posicao)}
sorter: (a, b) => a.faturamentoMes - b.faturamentoMes, </span>
defaultSortOrder: 'descend', <Flex vertical gap={0} style={{ minWidth: 0, flex: 1 }}>
render: (v: number) => fmt(v), <Text strong ellipsis style={{ fontSize: 'var(--text-lg)' }}>
}, {s.nomeSupervisor ?? `Supervisor cód. ${s.codSupervisor}`}
{ </Text>
title: 'Ticket Medio', <Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
dataIndex: 'ticketMedio', cód. {s.codSupervisor} · {s.repsAtivos}/{s.repsTotal} reps ativos
width: 140, </Text>
align: 'right', </Flex>
className: 'tabular-nums',
sorter: (a, b) => a.ticketMedio - b.ticketMedio,
render: (v: number) => (v > 0 ? fmt(v) : <Text type="secondary">--</Text>),
},
{
title: '% Meta',
dataIndex: 'pctMeta',
width: 170,
sorter: (a, b) => a.pctMeta - b.pctMeta,
render: (pct: number) =>
pct === 0 ? (
<Text type="secondary">sem meta</Text>
) : (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex> </Flex>
),
}, {/* Faturamento + variação */}
]; <Flex align="baseline" gap={10} wrap>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(s.faturamentoMes)}
</Title>
{s.variacaoPct != null && (
<Text
className="tabular-nums"
style={{
fontSize: 'var(--text-sm)',
color: s.variacaoPct >= 0 ? 'var(--green)' : '#cf1322',
}}
>
{s.variacaoPct >= 0 ? '▲' : '▼'} {s.variacaoPct >= 0 ? '+' : ''}
{s.variacaoPct}% vs. mês ant.
</Text>
)}
</Flex>
{/* Meta do time */}
<div>
<Flex justify="space-between" align="baseline" style={{ marginBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
Meta do time
</Text>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
{s.metaTotal > 0 ? `${s.pctMeta}%` : 'sem meta'}
</Text>
</Flex>
<Progress
percent={s.metaTotal > 0 ? Math.min(s.pctMeta, 100) : 0}
size="small"
strokeColor={metaColor(s.pctMeta)}
showInfo={false}
/>
</div>
{/* Ticket + positivação */}
<Flex justify="space-between" gap={12}>
<Space orientation="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
TICKET MÉDIO
</Text>
<Text strong className="tabular-nums">
{s.ticketMedio > 0 ? fmt(s.ticketMedio) : '—'}
</Text>
</Space>
<Space orientation="vertical" size={0} style={{ textAlign: 'right' }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
POSITIVAÇÃO
</Text>
<Text strong className="tabular-nums">
{s.pctPositivacao}%{' '}
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
({s.clientesPositivados}/{s.totalClientes})
</Text>
</Text>
</Space>
</Flex>
<Button block onClick={onDetalhar}>
Detalhar
</Button>
</Flex>
</Card>
);
}
export function EquipePage() { export function EquipePage() {
const { data, isLoading } = useEquipe(); const { data, isLoading } = useSupervisores();
const [detalhe, setDetalhe] = useState<SupervisorCard | null>(null);
if (isLoading || !data) {
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
<Skeleton active paragraph={{ rows: 1 }} />
<Skeleton active paragraph={{ rows: 6 }} />
</Flex>
);
}
const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }); const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
@@ -98,25 +132,41 @@ export function EquipePage() {
Equipe Equipe
</Title> </Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
Performance dos representantes &mdash; {mes} Disputa dos supervisores &mdash; {mes}
</Text> </Text>
</Flex> </Flex>
<Card> {isLoading || !data ? (
<Table<RepStats> <Row gutter={[24, 24]}>
rowKey="codVendedor" {[1, 2, 3].map((i) => (
columns={columns} <Col key={i} xs={24} md={12} lg={8}>
dataSource={data.reps} <Card>
pagination={false} <Skeleton active paragraph={{ rows: 4 }} />
size="small" </Card>
locale={{ emptyText: 'Nenhum representante encontrado.' }} </Col>
scroll={{ x: 700 }} ))}
/> </Row>
</Card> ) : data.supervisores.length === 0 ? (
<Card>
<Empty description="Nenhum supervisor com equipe encontrado." />
</Card>
) : (
<>
<Row gutter={[24, 24]}>
{data.supervisores.map((s: SupervisorCard, i: number) => (
<Col key={s.codSupervisor} xs={24} md={12} lg={8}>
<SupervisorCardView s={s} posicao={i} onDetalhar={() => setDetalhe(s)} />
</Col>
))}
</Row>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')} Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')}
</Text> </Text>
</>
)}
<SupervisorDetalheModal supervisor={detalhe} onClose={() => setDetalhe(null)} />
</Flex> </Flex>
); );
} }

View File

@@ -24,17 +24,9 @@ import type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons'; import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { import type { Promocao, RegraDesconto, RepStats, GrupoProdutoItem } from '@sar/api-interface';
AlcadaDescontoItem,
Promocao,
RegraDesconto,
RepStats,
GrupoProdutoItem,
} from '@sar/api-interface';
import { import {
usePoliticasDescontos,
usePromocoes, usePromocoes,
useUpsertDesconto,
useCreatePromocao, useCreatePromocao,
useUpdatePromocao, useUpdatePromocao,
useDeletePromocao, useDeletePromocao,
@@ -45,7 +37,7 @@ import {
useUpdateRegraDesconto, useUpdateRegraDesconto,
useDeleteRegraDesconto, useDeleteRegraDesconto,
} from '../../lib/queries/gerente'; } from '../../lib/queries/gerente';
import { useCatalog } from '../../lib/queries/catalog'; import { useCatalog, usePautas } from '../../lib/queries/catalog';
// ─── ProdutoSelect: busca remota por código ou descrição ───────────────────── // ─── ProdutoSelect: busca remota por código ou descrição ─────────────────────
@@ -104,125 +96,6 @@ function ProdutoSelect({
const { Title, Text } = Typography; const { Title, Text } = Typography;
// ─── Tab Descontos ────────────────────────────────────────────────────────────
function TabDescontos() {
const { data, isLoading } = usePoliticasDescontos();
const upsert = useUpsertDesconto();
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editValue, setEditValue] = useState<number>(0);
const [msg, msgCtx] = message.useMessage();
function startEdit(row: AlcadaDescontoItem) {
setEditingKey(`${row.codVendedor}-${row.codGrupo}`);
setEditValue(row.limitePerc);
}
async function saveEdit(row: AlcadaDescontoItem) {
try {
await upsert.mutateAsync({
codVendedor: row.codVendedor,
codGrupo: row.codGrupo,
limitePerc: editValue,
});
} catch {
return; // erro já notificado pelo handler global do QueryClient
}
setEditingKey(null);
void msg.success('Limite atualizado');
}
const columns: TableColumnsType<AlcadaDescontoItem> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: AlcadaDescontoItem) =>
row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Grupo',
dataIndex: 'codGrupo',
width: 100,
render: (v: number) => (v === 0 ? <Tag>Global</Tag> : <Tag color="blue">Grupo {v}</Tag>),
},
{
title: 'Limite de Desconto',
dataIndex: 'limitePerc',
width: 200,
render: (v: number, row: AlcadaDescontoItem) => {
const key = `${row.codVendedor}-${row.codGrupo}`;
if (editingKey === key) {
return (
<InputNumber
value={editValue}
onChange={(val) => setEditValue(val ?? 0)}
min={0}
max={100}
suffix="%"
size="small"
style={{ width: 100 }}
autoFocus
/>
);
}
return <Text className="tabular-nums">{v}%</Text>;
},
},
{
title: '',
width: 120,
render: (_: unknown, row: AlcadaDescontoItem) => {
const key = `${row.codVendedor}-${row.codGrupo}`;
if (editingKey === key) {
return (
<Space>
<Button
size="small"
type="primary"
loading={upsert.isPending}
onClick={() => void saveEdit(row)}
>
Salvar
</Button>
<Button size="small" onClick={() => setEditingKey(null)}>
Cancelar
</Button>
</Space>
);
}
return (
<Button
size="small"
icon={<FontAwesomeIcon icon={faPen} />}
onClick={() => startEdit(row)}
>
Editar
</Button>
);
},
},
];
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
return (
<>
{msgCtx}
<Table<AlcadaDescontoItem>
rowKey={(r) => `${r.codVendedor}-${r.codGrupo}`}
columns={columns}
dataSource={data.descontos}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhum limite configurado.' }}
/>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
Limite 0 = sem restricao de desconto para este representante.
</Text>
</>
);
}
// ─── Tab Promocoes ──────────────────────────────────────────────────────────── // ─── Tab Promocoes ────────────────────────────────────────────────────────────
interface PromocaoForm { interface PromocaoForm {
@@ -534,8 +407,10 @@ interface RegraForm {
codVendedor?: number; codVendedor?: number;
codGrupos?: number[]; codGrupos?: number[];
codSubgrupos?: number[]; codSubgrupos?: number[];
idPautas?: number[];
codGrupo?: number; codGrupo?: number;
codSubgrupo?: number; codSubgrupo?: number;
idPauta?: number;
descPct: number; descPct: number;
periodo: [dayjs.Dayjs, dayjs.Dayjs]; periodo: [dayjs.Dayjs, dayjs.Dayjs];
ativa?: boolean; ativa?: boolean;
@@ -545,6 +420,7 @@ function TabRegras() {
const { data, isLoading } = useRegrasDesconto(); const { data, isLoading } = useRegrasDesconto();
const { data: equipe } = useEquipe(); const { data: equipe } = useEquipe();
const { data: gruposData } = useGruposProduto(); const { data: gruposData } = useGruposProduto();
const { data: pautas } = usePautas();
const createMutation = useCreateRegraDesconto(); const createMutation = useCreateRegraDesconto();
const updateMutation = useUpdateRegraDesconto(); const updateMutation = useUpdateRegraDesconto();
const deleteMutation = useDeleteRegraDesconto(); const deleteMutation = useDeleteRegraDesconto();
@@ -560,6 +436,12 @@ function TabRegras() {
label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`, label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`,
})); }));
// Pautas de preços — rótulo com código + descrição (busca encontra pelos dois)
const pautaOptions = (pautas ?? []).map((p) => ({
value: p.idPauta,
label: `${p.codigo}${p.descricao}`,
}));
// Rótulos com código + nome — a busca do select encontra pelos dois. // Rótulos com código + nome — a busca do select encontra pelos dois.
// Subgrupos filtram pelos grupos escolhidos (single na edição, multi na criação). // Subgrupos filtram pelos grupos escolhidos (single na edição, multi na criação).
const filtroGrupos = new Set<number>([ const filtroGrupos = new Set<number>([
@@ -603,6 +485,7 @@ function TabRegras() {
codVendedor: r.codVendedor ?? undefined, codVendedor: r.codVendedor ?? undefined,
codGrupo: r.codGrupo ?? undefined, codGrupo: r.codGrupo ?? undefined,
codSubgrupo: r.codSubgrupo ?? undefined, codSubgrupo: r.codSubgrupo ?? undefined,
idPauta: r.idPauta ?? undefined,
descPct: r.descPct, descPct: r.descPct,
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)], periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
ativa: r.ativa, ativa: r.ativa,
@@ -630,6 +513,7 @@ function TabRegras() {
codVendedor: values.codVendedor ?? null, codVendedor: values.codVendedor ?? null,
codGrupo: values.codGrupo ?? null, codGrupo: values.codGrupo ?? null,
codSubgrupo: values.codSubgrupo ?? null, codSubgrupo: values.codSubgrupo ?? null,
idPauta: values.idPauta ?? null,
descPct: values.descPct, descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'), dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'), dataFim: fim.format('YYYY-MM-DD'),
@@ -643,6 +527,7 @@ function TabRegras() {
codVendedor: values.codVendedor ?? null, codVendedor: values.codVendedor ?? null,
codGrupos: values.codGrupos?.length ? values.codGrupos : undefined, codGrupos: values.codGrupos?.length ? values.codGrupos : undefined,
codSubgrupos: values.codSubgrupos?.length ? values.codSubgrupos : undefined, codSubgrupos: values.codSubgrupos?.length ? values.codSubgrupos : undefined,
idPautas: values.idPautas?.length ? values.idPautas : undefined,
descPct: values.descPct, descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'), dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'), dataFim: fim.format('YYYY-MM-DD'),
@@ -683,7 +568,7 @@ function TabRegras() {
}, },
{ {
title: 'Grupo / Subgrupo', title: 'Grupo / Subgrupo',
width: 220, width: 200,
render: (_: unknown, row: RegraDesconto) => { render: (_: unknown, row: RegraDesconto) => {
if (row.codGrupo == null && row.codSubgrupo == null) return <Tag>Todos</Tag>; if (row.codGrupo == null && row.codSubgrupo == null) return <Tag>Todos</Tag>;
const partes = [ const partes = [
@@ -693,6 +578,16 @@ function TabRegras() {
return <Text style={{ fontSize: 'var(--text-sm)' }}>{partes.join(' / ')}</Text>; return <Text style={{ fontSize: 'var(--text-sm)' }}>{partes.join(' / ')}</Text>;
}, },
}, },
{
title: 'Pauta',
width: 160,
render: (_: unknown, row: RegraDesconto) =>
row.idPauta == null ? (
<Tag>Todas</Tag>
) : (
<Text style={{ fontSize: 'var(--text-sm)' }}>{row.pauta ?? `Pauta ${row.idPauta}`}</Text>
),
},
{ {
title: 'Desconto', title: 'Desconto',
dataIndex: 'descPct', dataIndex: 'descPct',
@@ -782,8 +677,8 @@ function TabRegras() {
locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }} locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }}
/> />
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}> <Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
A regra vale para o representante, grupo e subgrupo informados (em branco = todos) e aplica A regra vale para o representante, grupo, subgrupo e pauta informados (em branco = todos) e
o desconto automaticamente nos pedidos durante a vigencia. aplica o desconto automaticamente nos pedidos durante a vigencia.
</Text> </Text>
<Modal <Modal
@@ -817,30 +712,41 @@ function TabRegras() {
</Form.Item> </Form.Item>
{editTarget ? ( {editTarget ? (
<Flex gap={16}> <>
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}> <Flex gap={16}>
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
<Select
options={grupoOptions}
placeholder="Todos os grupos"
allowClear
showSearch
optionFilterProp="label"
onChange={() => form.setFieldValue('codSubgrupo', undefined)}
/>
</Form.Item>
<Form.Item name="codSubgrupo" label="Subgrupo" style={{ flex: 1 }}>
<Select
options={subgrupoOptions}
placeholder="Todos os subgrupos"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
<Form.Item name="ativa" label="Ativa" valuePropName="checked">
<Switch />
</Form.Item>
</Flex>
<Form.Item name="idPauta" label="Pauta de precos">
<Select <Select
options={grupoOptions} options={pautaOptions}
placeholder="Todos os grupos" placeholder="Todas as pautas"
allowClear
showSearch
optionFilterProp="label"
onChange={() => form.setFieldValue('codSubgrupo', undefined)}
/>
</Form.Item>
<Form.Item name="codSubgrupo" label="Subgrupo" style={{ flex: 1 }}>
<Select
options={subgrupoOptions}
placeholder="Todos os subgrupos"
allowClear allowClear
showSearch showSearch
optionFilterProp="label" optionFilterProp="label"
/> />
</Form.Item> </Form.Item>
<Form.Item name="ativa" label="Ativa" valuePropName="checked"> </>
<Switch />
</Form.Item>
</Flex>
) : ( ) : (
<> <>
<Form.Item <Form.Item
@@ -871,6 +777,20 @@ function TabRegras() {
optionFilterProp="label" optionFilterProp="label"
/> />
</Form.Item> </Form.Item>
<Form.Item
name="idPautas"
label="Pautas de precos"
extra="Selecione 1 ou mais pautas — uma regra por pauta, aplicada a todos os produtos dela"
>
<Select
mode="multiple"
options={pautaOptions}
placeholder="Todas as pautas"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
</> </>
)} )}
@@ -903,11 +823,6 @@ function TabRegras() {
export function PoliticasPage() { export function PoliticasPage() {
const items = [ const items = [
{
key: 'descontos',
label: 'Desconto por Representante',
children: <TabDescontos />,
},
{ {
key: 'promocoes', key: 'promocoes',
label: 'Promocoes', label: 'Promocoes',
@@ -927,7 +842,7 @@ export function PoliticasPage() {
Politicas Comerciais Politicas Comerciais
</Title> </Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
Limites de desconto e promocoes com vigencia Promocoes e regras de desconto com vigencia
</Text> </Text>
</Flex> </Flex>

View File

@@ -0,0 +1,164 @@
import { Card, Col, Flex, Modal, Progress, Row, Space, Spin, Table, Typography } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faAddressCard, faRankingStar } from '@fortawesome/free-solid-svg-icons';
import type { PositivacaoRep, RankingRep, SupervisorCard } from '@sar/api-interface';
import { useSupervisorDashboardByCod } from '../../lib/queries/dashboard';
import {
positivacaoRepColumns,
rankingRepColumns,
} from '../../components/dashboard/rep-performance-columns';
const { Title, Text } = Typography;
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function metaColor(pct: number): string {
return pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14';
}
// Detalhe/análise do time de um supervisor — aberto pelo gerente na aba Equipe.
// Reusa /dashboard/supervisor escopado por codSupervisor e as mesmas tabelas do
// painel do supervisor (ranking + positivação).
export function SupervisorDetalheModal({
supervisor,
onClose,
}: {
supervisor: SupervisorCard | null;
onClose: () => void;
}) {
const { data, isLoading } = useSupervisorDashboardByCod(supervisor?.codSupervisor ?? null);
const titulo = supervisor
? `${supervisor.nomeSupervisor ?? `Supervisor cód. ${supervisor.codSupervisor}`}`
: '';
return (
<Modal
open={supervisor !== null}
onCancel={onClose}
footer={null}
width={900}
centered
destroyOnHidden
title={
<Space>
<FontAwesomeIcon icon={faRankingStar} style={{ color: 'var(--jcs-blue)' }} />
{titulo}
</Space>
}
>
{isLoading || !data || !supervisor ? (
<Spin style={{ display: 'block', margin: '48px auto' }} />
) : (
<Flex vertical gap={20}>
{/* KPIs do time no mês */}
<Row gutter={[16, 16]}>
<Col xs={24} md={8}>
<Card size="small">
<Space orientation="vertical" size={2}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
FATURAMENTO NO MÊS
</Text>
<Title level={4} style={{ margin: 0 }} className="tabular-nums">
{fmt(data.equipeMes.faturamento)}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{data.equipeMes.pedidos} pedido{data.equipeMes.pedidos !== 1 ? 's' : ''} ·{' '}
{supervisor.repsAtivos}/{supervisor.repsTotal} reps ativos
</Text>
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card size="small">
<Space orientation="vertical" size={2} style={{ width: '100%' }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
META DO TIME
</Text>
{data.equipeMes.metaTotal > 0 ? (
<>
<Flex align="baseline" gap={6}>
<Title level={4} style={{ margin: 0 }} className="tabular-nums">
{data.equipeMes.pctMeta}%
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
de {fmt(data.equipeMes.metaTotal)}
</Text>
</Flex>
<Progress
percent={Math.min(data.equipeMes.pctMeta, 100)}
size="small"
strokeColor={metaColor(data.equipeMes.pctMeta)}
showInfo={false}
/>
</>
) : (
<Text type="secondary">Sem meta cadastrada.</Text>
)}
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card size="small">
<Space orientation="vertical" size={2}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
TICKET MÉDIO
</Text>
<Title level={4} style={{ margin: 0 }} className="tabular-nums">
{fmt(data.equipeMes.ticketMedio)}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
positivação {supervisor.pctPositivacao}% ({supervisor.clientesPositivados}/
{supervisor.totalClientes})
</Text>
</Space>
</Card>
</Col>
</Row>
{/* Ranking dos reps do time */}
<div>
<Text strong style={{ display: 'block', marginBottom: 12 }}>
<FontAwesomeIcon
icon={faRankingStar}
style={{ color: 'var(--jcs-blue)', marginRight: 8 }}
/>
Ranking dos representantes
</Text>
<Table<RankingRep>
rowKey="codVendedor"
columns={rankingRepColumns}
dataSource={data.rankingReps}
pagination={false}
size="small"
scroll={{ x: 640 }}
locale={{ emptyText: 'Nenhum pedido do time no mês.' }}
/>
</div>
{/* Positivação dos reps do time */}
<div>
<Text strong style={{ display: 'block', marginBottom: 12 }}>
<FontAwesomeIcon
icon={faAddressCard}
style={{ color: 'var(--jcs-blue)', marginRight: 8 }}
/>
Positivação de clientes
</Text>
<Table<PositivacaoRep>
rowKey="codVendedor"
columns={positivacaoRepColumns}
dataSource={data.positivacaoReps}
pagination={false}
size="small"
scroll={{ x: 640 }}
locale={{ emptyText: 'Nenhum representante com carteira no time.' }}
/>
</div>
</Flex>
)}
</Modal>
);
}

View File

@@ -32,6 +32,20 @@ export function useSupervisorDashboard() {
}); });
} }
// Painel de um supervisor específico — gerente/admin abrindo o "Detalhar" da
// aba Equipe. Reusa /dashboard/supervisor escopando pelo codSupervisor.
export function useSupervisorDashboardByCod(codSupervisor: number | null) {
return useQuery<SupervisorDashboard>({
queryKey: ['dashboard', 'supervisor', 'detalhe', codSupervisor],
queryFn: async () => {
const raw = await apiFetch(`/dashboard/supervisor?codSupervisor=${codSupervisor}`);
return SupervisorDashboardSchema.parse(raw);
},
enabled: codSupervisor != null,
staleTime: 60 * 1000,
});
}
// Detalhe dos clientes inativos de um rep (modal do card "Inativos por Rep") // Detalhe dos clientes inativos de um rep (modal do card "Inativos por Rep")
export function useRepInativos(codVendedor: number | null) { export function useRepInativos(codVendedor: number | null) {
return useQuery<RepInativosDetail>({ return useQuery<RepInativosDetail>({

View File

@@ -2,15 +2,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
ManagerDashboardSchema, ManagerDashboardSchema,
EquipeResponseSchema, EquipeResponseSchema,
AlcadaDescontosResponseSchema, SupervisoresResponseSchema,
PromocoesResponseSchema, PromocoesResponseSchema,
RegrasDescontoResponseSchema, RegrasDescontoResponseSchema,
GruposProdutoResponseSchema, GruposProdutoResponseSchema,
type ManagerDashboard, type ManagerDashboard,
type EquipeResponse, type EquipeResponse,
type AlcadaDescontosResponse, type SupervisoresResponse,
type PromocoesResponse, type PromocoesResponse,
type UpsertDescontoBody,
type CreatePromocaoBody, type CreatePromocaoBody,
type UpdatePromocaoBody, type UpdatePromocaoBody,
type RegrasDescontoResponse, type RegrasDescontoResponse,
@@ -47,14 +46,14 @@ export function useEquipe() {
}); });
} }
export function usePoliticasDescontos() { export function useSupervisores() {
return useQuery<AlcadaDescontosResponse>({ return useQuery<SupervisoresResponse>({
queryKey: ['politicas', 'descontos'], queryKey: ['equipe', 'supervisores'],
queryFn: async () => { queryFn: async () => {
const raw = await apiFetch('/politicas/descontos'); const raw = await apiFetch('/equipe/supervisores');
return AlcadaDescontosResponseSchema.parse(raw); return SupervisoresResponseSchema.parse(raw);
}, },
staleTime: 10 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
} }
@@ -69,15 +68,6 @@ export function usePromocoes() {
}); });
} }
export function useUpsertDesconto() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: UpsertDescontoBody) =>
apiFetch('/politicas/descontos', { method: 'POST', body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }),
});
}
export function useCreatePromocao() { export function useCreatePromocao() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({

View File

@@ -16,3 +16,30 @@ export const EquipeResponseSchema = z.object({
syncedAt: z.iso.datetime(), syncedAt: z.iso.datetime(),
}); });
export type EquipeResponse = z.infer<typeof EquipeResponseSchema>; export type EquipeResponse = z.infer<typeof EquipeResponseSchema>;
// Card de disputa/análise por supervisor na aba Equipe (visão gerente/admin).
// Time = reps cujo cod_supervisor aponta para este supervisor + o próprio
// supervisor (mesma semântica de getTeamCodes). Métricas agregadas do time no mês.
export const SupervisorCardSchema = z.object({
codSupervisor: z.number().int(),
nomeSupervisor: z.string().nullable(),
faturamentoMes: z.number(),
faturamentoMesAnterior: z.number(),
variacaoPct: z.number().nullable(), // null quando não há base no mês anterior
pedidosMes: z.number().int(),
ticketMedio: z.number(),
metaTotal: z.number(),
pctMeta: z.number(),
totalClientes: z.number().int(),
clientesPositivados: z.number().int(),
pctPositivacao: z.number(),
repsTotal: z.number().int(),
repsAtivos: z.number().int(), // reps do time que faturaram no mês
});
export type SupervisorCard = z.infer<typeof SupervisorCardSchema>;
export const SupervisoresResponseSchema = z.object({
supervisores: z.array(SupervisorCardSchema),
syncedAt: z.iso.datetime(),
});
export type SupervisoresResponse = z.infer<typeof SupervisoresResponseSchema>;

View File

@@ -1,25 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
export const AlcadaDescontoItemSchema = z.object({
codVendedor: z.number().int(),
codGrupo: z.number().int(),
limitePerc: z.number(),
nomeVendedor: z.string().nullable(),
});
export type AlcadaDescontoItem = z.infer<typeof AlcadaDescontoItemSchema>;
export const AlcadaDescontosResponseSchema = z.object({
descontos: z.array(AlcadaDescontoItemSchema),
});
export type AlcadaDescontosResponse = z.infer<typeof AlcadaDescontosResponseSchema>;
export const UpsertDescontoBodySchema = z.object({
codVendedor: z.number().int().positive(),
codGrupo: z.number().int().default(0),
limitePerc: z.number().min(0).max(100),
});
export type UpsertDescontoBody = z.infer<typeof UpsertDescontoBodySchema>;
export const PromocaoSchema = z.object({ export const PromocaoSchema = z.object({
id: z.number().int(), id: z.number().int(),
descricao: z.string(), descricao: z.string(),
@@ -71,7 +51,7 @@ export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>;
// ─── Regras de Desconto ─────────────────────────────────────────────────────── // ─── Regras de Desconto ───────────────────────────────────────────────────────
// Regra criada pelo Gerente: % de desconto liberado por grupo e/ou subgrupo de // Regra criada pelo Gerente: % de desconto liberado por grupo e/ou subgrupo de
// produto e/ou representante, com vigência. Campos nulos = "todos". // produto, pauta de preços e/ou representante, com vigência. Campos nulos = "todos".
export const RegraDescontoSchema = z.object({ export const RegraDescontoSchema = z.object({
id: z.number().int(), id: z.number().int(),
@@ -80,6 +60,8 @@ export const RegraDescontoSchema = z.object({
grupo: z.string().nullable(), grupo: z.string().nullable(),
codSubgrupo: z.number().int().nullable(), codSubgrupo: z.number().int().nullable(),
subgrupo: z.string().nullable(), subgrupo: z.string().nullable(),
idPauta: z.number().int().nullable(),
pauta: z.string().nullable(),
codVendedor: z.number().int().nullable(), codVendedor: z.number().int().nullable(),
nomeVendedor: z.string().nullable(), nomeVendedor: z.string().nullable(),
descPct: z.number(), descPct: z.number(),
@@ -96,12 +78,14 @@ export const RegrasDescontoResponseSchema = z.object({
}); });
export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>; export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>;
// Criação aceita VÁRIOS grupos e/ou subgrupos — uma regra por alvo (grupos // Criação aceita VÁRIOS grupos, subgrupos e/ou pautas — uma regra por alvo
// selecionados viram regras por grupo; subgrupos viram regras por subgrupo). // (cada grupo vira regra por grupo; cada subgrupo, regra por subgrupo; cada
// pauta, regra por pauta). O representante escolhido é copiado em todas.
export const CreateRegraDescontoBodySchema = z.object({ export const CreateRegraDescontoBodySchema = z.object({
descricao: z.string().min(1).max(200), descricao: z.string().min(1).max(200),
codGrupos: z.array(z.number().int()).max(100).optional(), codGrupos: z.array(z.number().int()).max(100).optional(),
codSubgrupos: z.array(z.number().int()).max(100).optional(), codSubgrupos: z.array(z.number().int()).max(100).optional(),
idPautas: z.array(z.number().int()).max(100).optional(),
codVendedor: z.number().int().nullable().optional(), codVendedor: z.number().int().nullable().optional(),
descPct: z.number().min(0).max(100), descPct: z.number().min(0).max(100),
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
@@ -114,6 +98,7 @@ export const UpdateRegraDescontoBodySchema = z.object({
descricao: z.string().min(1).max(200).optional(), descricao: z.string().min(1).max(200).optional(),
codGrupo: z.number().int().nullable().optional(), codGrupo: z.number().int().nullable().optional(),
codSubgrupo: z.number().int().nullable().optional(), codSubgrupo: z.number().int().nullable().optional(),
idPauta: z.number().int().nullable().optional(),
codVendedor: z.number().int().nullable().optional(), codVendedor: z.number().int().nullable().optional(),
descPct: z.number().min(0).max(100).optional(), descPct: z.number().min(0).max(100).optional(),
dataInicio: z dataInicio: z
@@ -166,6 +151,8 @@ export const RegraVigenteSchema = z.object({
grupo: z.string().nullable(), grupo: z.string().nullable(),
codSubgrupo: z.number().int().nullable(), codSubgrupo: z.number().int().nullable(),
subgrupo: z.string().nullable(), subgrupo: z.string().nullable(),
idPauta: z.number().int().nullable(),
pauta: z.string().nullable(),
descPct: z.number(), descPct: z.number(),
dataFim: z.string(), dataFim: z.string(),
}); });

View File

@@ -720,8 +720,9 @@ CREATE TABLE IF NOT EXISTS sar.promocoes (
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- Regras de Desconto (Politicas Comerciais - criadas pelo Gerente/Admin) -- Regras de Desconto (Politicas Comerciais - criadas pelo Gerente/Admin)
-- Desconto liberado por grupo/subgrupo de produto e/ou representante, com -- Desconto liberado por grupo/subgrupo de produto, pauta de precos e/ou
-- vigencia. Campos nulos = "todos" (ex: cod_vendedor NULL vale para todo rep). -- representante, com vigencia. Campos nulos = "todos" (ex: cod_vendedor NULL
-- vale para todo rep; id_pauta NULL vale para qualquer pauta).
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sar.regras_desconto ( CREATE TABLE IF NOT EXISTS sar.regras_desconto (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -730,6 +731,7 @@ CREATE TABLE IF NOT EXISTS sar.regras_desconto (
cod_grupo INTEGER, cod_grupo INTEGER,
cod_subgrupo INTEGER, cod_subgrupo INTEGER,
cod_vendedor INTEGER, cod_vendedor INTEGER,
id_pauta INTEGER,
desc_pct NUMERIC(5,2) NOT NULL, desc_pct NUMERIC(5,2) NOT NULL,
data_inicio DATE NOT NULL, data_inicio DATE NOT NULL,
data_fim DATE NOT NULL, data_fim DATE NOT NULL,