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:
@@ -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;
|
||||
@@ -174,7 +174,8 @@ model Promocao {
|
||||
// ─── RegraDesconto (Políticas Comerciais) ────────────────────────────────────
|
||||
//
|
||||
// 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 {
|
||||
id Int @id @default(autoincrement())
|
||||
@@ -183,6 +184,7 @@ model RegraDesconto {
|
||||
codGrupo Int? @map("cod_grupo")
|
||||
codSubgrupo Int? @map("cod_subgrupo")
|
||||
codVendedor Int? @map("cod_vendedor")
|
||||
idPauta Int? @map("id_pauta")
|
||||
descPct Decimal @db.Decimal(5, 2) @map("desc_pct")
|
||||
dataInicio DateTime @db.Date @map("data_inicio")
|
||||
dataFim DateTime @db.Date @map("data_fim")
|
||||
|
||||
@@ -45,9 +45,13 @@ export class DashboardController {
|
||||
}
|
||||
|
||||
@Get('supervisor')
|
||||
supervisorDashboard(): Promise<SupervisorDashboard> {
|
||||
supervisorDashboard(
|
||||
@Query('codSupervisor') codSupervisor?: string,
|
||||
): Promise<SupervisorDashboard> {
|
||||
this.assertGestor();
|
||||
return this.dashboard.supervisorDashboard();
|
||||
return this.dashboard.supervisorDashboard(
|
||||
codSupervisor ? parseInt(codSupervisor, 10) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('supervisor/inativos')
|
||||
|
||||
@@ -729,18 +729,25 @@ export class DashboardService {
|
||||
});
|
||||
}
|
||||
|
||||
async supervisorDashboard(): Promise<SupervisorDashboard> {
|
||||
async supervisorDashboard(codSupervisor?: number): Promise<SupervisorDashboard> {
|
||||
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 now = new Date();
|
||||
|
||||
// Supervisor vê só a própria equipe (cod_supervisor em vw_representantes);
|
||||
// gerente/admin acessando este painel veem a empresa toda.
|
||||
// Supervisor vê só a própria equipe (cod_supervisor em vw_representantes).
|
||||
// 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 userId = this.cls.get('userId');
|
||||
const team =
|
||||
role === 'supervisor' ? await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0) : null;
|
||||
let teamCode: number | null = 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(',')})` : '');
|
||||
|
||||
// Pedidos do dia — lê do ERP (situa != 5=Cancelado)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
@Controller({ path: 'equipe' })
|
||||
@@ -10,4 +10,9 @@ export class EquipeController {
|
||||
listEquipe(): Promise<EquipeResponse> {
|
||||
return this.equipe.listEquipe();
|
||||
}
|
||||
|
||||
@Get('supervisores')
|
||||
listSupervisores(): Promise<SupervisoresResponse> {
|
||||
return this.equipe.listSupervisores();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
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';
|
||||
|
||||
function matrizEmpresa(idEmpresa: number): number {
|
||||
@@ -110,4 +110,173 @@ export class EquipeService {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,14 +626,16 @@ export class OrdersService {
|
||||
)
|
||||
.reduce((max, p) => Math.max(max, Number(p.descPct)), 0);
|
||||
|
||||
// Regra sem grupo/subgrupo vale para qualquer produto; com grupo e/ou
|
||||
// subgrupo, o produto precisa casar com o que a regra especifica.
|
||||
// Regra sem alvo vale para qualquer produto; com grupo, subgrupo e/ou
|
||||
// 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 regraPct = regras
|
||||
.filter(
|
||||
(r) =>
|
||||
(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);
|
||||
|
||||
|
||||
@@ -11,17 +11,14 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import {
|
||||
UpsertDescontoBodySchema,
|
||||
CreatePromocaoBodySchema,
|
||||
UpdatePromocaoBodySchema,
|
||||
CreateRegraDescontoBodySchema,
|
||||
UpdateRegraDescontoBodySchema,
|
||||
type UpsertDescontoBody,
|
||||
type CreatePromocaoBody,
|
||||
type UpdatePromocaoBody,
|
||||
type CreateRegraDescontoBody,
|
||||
type UpdateRegraDescontoBody,
|
||||
type AlcadaDescontosResponse,
|
||||
type PromocoesResponse,
|
||||
type PromocoesVigentesResponse,
|
||||
type AlcadaRepResponse,
|
||||
@@ -31,7 +28,6 @@ import {
|
||||
} from '@sar/api-interface';
|
||||
import { PoliticasService } from './politicas.service';
|
||||
|
||||
class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {}
|
||||
class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {}
|
||||
class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {}
|
||||
class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {}
|
||||
@@ -41,17 +37,6 @@ class UpdateRegraDescontoDto extends createZodDto(UpdateRegraDescontoBodySchema)
|
||||
export class PoliticasController {
|
||||
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')
|
||||
listPromocoes(): Promise<PromocoesResponse> {
|
||||
return this.politicas.listPromocoes();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type {
|
||||
AlcadaDescontosResponse,
|
||||
UpsertDescontoBody,
|
||||
PromocoesResponse,
|
||||
CreatePromocaoBody,
|
||||
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> {
|
||||
this.assertManager();
|
||||
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> {
|
||||
this.assertManager();
|
||||
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]),
|
||||
);
|
||||
|
||||
const idPautas = [...new Set(rows.map((r) => r.idPauta).filter((p): p is number => p != null))];
|
||||
const pautaNameMap = await this.loadPautaNames(idPautas);
|
||||
|
||||
return {
|
||||
regras: rows.map((r) => ({
|
||||
id: r.id,
|
||||
@@ -260,6 +223,8 @@ export class PoliticasService {
|
||||
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
|
||||
codSubgrupo: r.codSubgrupo,
|
||||
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,
|
||||
nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null,
|
||||
descPct: Number(r.descPct),
|
||||
@@ -272,8 +237,9 @@ export class PoliticasService {
|
||||
};
|
||||
}
|
||||
|
||||
// Cria uma regra POR ALVO: cada grupo selecionado vira regra por grupo e
|
||||
// cada subgrupo vira regra por subgrupo. Sem alvos, regra vale para todos.
|
||||
// Cria uma regra POR ALVO: cada grupo vira regra por grupo, cada subgrupo
|
||||
// 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[] }> {
|
||||
this.assertManager();
|
||||
const prisma = this.cls.get('prisma');
|
||||
@@ -281,11 +247,17 @@ export class PoliticasService {
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const userId = this.cls.get('userId') ?? 'system';
|
||||
|
||||
const alvos: { codGrupo: number | null; codSubgrupo: number | null }[] = [
|
||||
...(body.codGrupos ?? []).map((g) => ({ codGrupo: g, codSubgrupo: null })),
|
||||
...(body.codSubgrupos ?? []).map((s) => ({ codGrupo: null, codSubgrupo: s })),
|
||||
const alvos: { codGrupo: number | null; codSubgrupo: number | null; idPauta: number | null }[] =
|
||||
[
|
||||
...(body.codGrupos ?? []).map((g) => ({ codGrupo: g, codSubgrupo: null, idPauta: null })),
|
||||
...(body.codSubgrupos ?? []).map((s) => ({
|
||||
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 });
|
||||
if (alvos.length === 0) alvos.push({ codGrupo: null, codSubgrupo: null, idPauta: null });
|
||||
|
||||
const ids = await prisma.$transaction(async (tx) => {
|
||||
const created: number[] = [];
|
||||
@@ -296,6 +268,7 @@ export class PoliticasService {
|
||||
descricao: body.descricao,
|
||||
codGrupo: alvo.codGrupo,
|
||||
codSubgrupo: alvo.codSubgrupo,
|
||||
idPauta: alvo.idPauta,
|
||||
codVendedor: body.codVendedor ?? null,
|
||||
descPct: body.descPct,
|
||||
dataInicio: new Date(body.dataInicio),
|
||||
@@ -325,6 +298,7 @@ export class PoliticasService {
|
||||
...(body.descricao !== undefined && { descricao: body.descricao }),
|
||||
...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }),
|
||||
...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }),
|
||||
...(body.idPauta !== undefined && { idPauta: body.idPauta }),
|
||||
...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }),
|
||||
...(body.descPct !== undefined && { descPct: body.descPct }),
|
||||
...(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]),
|
||||
);
|
||||
|
||||
const idPautas = [...new Set(rows.map((r) => r.idPauta).filter((p): p is number => p != null))];
|
||||
const pautaNameMap = await this.loadPautaNames(idPautas);
|
||||
|
||||
return {
|
||||
regras: rows.map((r) => ({
|
||||
id: r.id,
|
||||
@@ -461,6 +438,8 @@ export class PoliticasService {
|
||||
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
|
||||
codSubgrupo: r.codSubgrupo,
|
||||
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),
|
||||
dataFim: r.dataFim.toISOString().slice(0, 10),
|
||||
})),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Card, Flex, Progress, Skeleton, Table, Tag, Typography } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import type { RepStats } from '@sar/api-interface';
|
||||
import { useEquipe } from '../../lib/queries/gerente';
|
||||
import { useState } from 'react';
|
||||
import { Button, Card, Col, Empty, Flex, Progress, Row, Skeleton, Space, Typography } from 'antd';
|
||||
import type { SupervisorCard } from '@sar/api-interface';
|
||||
import { useSupervisores } from '../../lib/queries/gerente';
|
||||
import { SupervisorDetalheModal } from './SupervisorDetalheModal';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -9,86 +10,119 @@ function fmt(v: number): string {
|
||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<RepStats> = [
|
||||
{
|
||||
title: 'Representante',
|
||||
key: 'rep',
|
||||
render: (_: unknown, row: RepStats) => (
|
||||
<Flex vertical gap={0}>
|
||||
<Text strong>{row.nomeVendedor ?? `Cód. ${row.codVendedor}`}</Text>
|
||||
{row.nomeVendedor && (
|
||||
function metaColor(pct: number): string {
|
||||
return pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14';
|
||||
}
|
||||
|
||||
const MEDALHAS = ['🥇', '🥈', '🥉'];
|
||||
|
||||
function posicaoLabel(i: number): string {
|
||||
return MEDALHAS[i] ?? `${i + 1}º`;
|
||||
}
|
||||
|
||||
function SupervisorCardView({
|
||||
s,
|
||||
posicao,
|
||||
onDetalhar,
|
||||
}: {
|
||||
s: SupervisorCard;
|
||||
posicao: number;
|
||||
onDetalhar: () => void;
|
||||
}) {
|
||||
const lider = posicao === 0;
|
||||
return (
|
||||
<Card
|
||||
style={lider ? { borderColor: 'var(--jcs-blue)', borderWidth: 2 } : undefined}
|
||||
styles={{ body: { padding: 'var(--space-lg)' } }}
|
||||
>
|
||||
<Flex vertical gap={12}>
|
||||
{/* Cabeçalho: posição + nome */}
|
||||
<Flex align="center" gap={10}>
|
||||
<span style={{ fontSize: 22, lineHeight: 1, minWidth: 30, textAlign: 'center' }}>
|
||||
{posicaoLabel(posicao)}
|
||||
</span>
|
||||
<Flex vertical gap={0} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text strong ellipsis style={{ fontSize: 'var(--text-lg)' }}>
|
||||
{s.nomeSupervisor ?? `Supervisor cód. ${s.codSupervisor}`}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
cód. {row.codVendedor}
|
||||
cód. {s.codSupervisor} · {s.repsAtivos}/{s.repsTotal} reps ativos
|
||||
</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>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Pedidos',
|
||||
dataIndex: 'pedidosMes',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
sorter: (a, b) => a.pedidosMes - b.pedidosMes,
|
||||
render: (v: number) => (v === 0 ? <Tag>0</Tag> : v),
|
||||
},
|
||||
{
|
||||
title: 'Faturamento',
|
||||
dataIndex: 'faturamentoMes',
|
||||
width: 160,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
sorter: (a, b) => a.faturamentoMes - b.faturamentoMes,
|
||||
defaultSortOrder: 'descend',
|
||||
render: (v: number) => fmt(v),
|
||||
},
|
||||
{
|
||||
title: 'Ticket Medio',
|
||||
dataIndex: 'ticketMedio',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
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}%
|
||||
|
||||
{/* 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>
|
||||
|
||||
export function EquipePage() {
|
||||
const { data, isLoading } = useEquipe();
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||
<Skeleton active paragraph={{ rows: 1 }} />
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
{/* 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() {
|
||||
const { data, isLoading } = useSupervisores();
|
||||
const [detalhe, setDetalhe] = useState<SupervisorCard | null>(null);
|
||||
|
||||
const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
||||
|
||||
return (
|
||||
@@ -98,25 +132,41 @@ export function EquipePage() {
|
||||
Equipe
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
Performance dos representantes — {mes}
|
||||
Disputa dos supervisores — {mes}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{isLoading || !data ? (
|
||||
<Row gutter={[24, 24]}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Col key={i} xs={24} md={12} lg={8}>
|
||||
<Card>
|
||||
<Table<RepStats>
|
||||
rowKey="codVendedor"
|
||||
columns={columns}
|
||||
dataSource={data.reps}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: 'Nenhum representante encontrado.' }}
|
||||
scroll={{ x: 700 }}
|
||||
/>
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
) : 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)' }}>
|
||||
Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SupervisorDetalheModal supervisor={detalhe} onClose={() => setDetalhe(null)} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,17 +24,9 @@ import type { TableColumnsType } from 'antd';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import type {
|
||||
AlcadaDescontoItem,
|
||||
Promocao,
|
||||
RegraDesconto,
|
||||
RepStats,
|
||||
GrupoProdutoItem,
|
||||
} from '@sar/api-interface';
|
||||
import type { Promocao, RegraDesconto, RepStats, GrupoProdutoItem } from '@sar/api-interface';
|
||||
import {
|
||||
usePoliticasDescontos,
|
||||
usePromocoes,
|
||||
useUpsertDesconto,
|
||||
useCreatePromocao,
|
||||
useUpdatePromocao,
|
||||
useDeletePromocao,
|
||||
@@ -45,7 +37,7 @@ import {
|
||||
useUpdateRegraDesconto,
|
||||
useDeleteRegraDesconto,
|
||||
} 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 ─────────────────────
|
||||
|
||||
@@ -104,125 +96,6 @@ function ProdutoSelect({
|
||||
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
interface PromocaoForm {
|
||||
@@ -534,8 +407,10 @@ interface RegraForm {
|
||||
codVendedor?: number;
|
||||
codGrupos?: number[];
|
||||
codSubgrupos?: number[];
|
||||
idPautas?: number[];
|
||||
codGrupo?: number;
|
||||
codSubgrupo?: number;
|
||||
idPauta?: number;
|
||||
descPct: number;
|
||||
periodo: [dayjs.Dayjs, dayjs.Dayjs];
|
||||
ativa?: boolean;
|
||||
@@ -545,6 +420,7 @@ function TabRegras() {
|
||||
const { data, isLoading } = useRegrasDesconto();
|
||||
const { data: equipe } = useEquipe();
|
||||
const { data: gruposData } = useGruposProduto();
|
||||
const { data: pautas } = usePautas();
|
||||
const createMutation = useCreateRegraDesconto();
|
||||
const updateMutation = useUpdateRegraDesconto();
|
||||
const deleteMutation = useDeleteRegraDesconto();
|
||||
@@ -560,6 +436,12 @@ function TabRegras() {
|
||||
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.
|
||||
// Subgrupos filtram pelos grupos escolhidos (single na edição, multi na criação).
|
||||
const filtroGrupos = new Set<number>([
|
||||
@@ -603,6 +485,7 @@ function TabRegras() {
|
||||
codVendedor: r.codVendedor ?? undefined,
|
||||
codGrupo: r.codGrupo ?? undefined,
|
||||
codSubgrupo: r.codSubgrupo ?? undefined,
|
||||
idPauta: r.idPauta ?? undefined,
|
||||
descPct: r.descPct,
|
||||
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
|
||||
ativa: r.ativa,
|
||||
@@ -630,6 +513,7 @@ function TabRegras() {
|
||||
codVendedor: values.codVendedor ?? null,
|
||||
codGrupo: values.codGrupo ?? null,
|
||||
codSubgrupo: values.codSubgrupo ?? null,
|
||||
idPauta: values.idPauta ?? null,
|
||||
descPct: values.descPct,
|
||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||
dataFim: fim.format('YYYY-MM-DD'),
|
||||
@@ -643,6 +527,7 @@ function TabRegras() {
|
||||
codVendedor: values.codVendedor ?? null,
|
||||
codGrupos: values.codGrupos?.length ? values.codGrupos : undefined,
|
||||
codSubgrupos: values.codSubgrupos?.length ? values.codSubgrupos : undefined,
|
||||
idPautas: values.idPautas?.length ? values.idPautas : undefined,
|
||||
descPct: values.descPct,
|
||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||
dataFim: fim.format('YYYY-MM-DD'),
|
||||
@@ -683,7 +568,7 @@ function TabRegras() {
|
||||
},
|
||||
{
|
||||
title: 'Grupo / Subgrupo',
|
||||
width: 220,
|
||||
width: 200,
|
||||
render: (_: unknown, row: RegraDesconto) => {
|
||||
if (row.codGrupo == null && row.codSubgrupo == null) return <Tag>Todos</Tag>;
|
||||
const partes = [
|
||||
@@ -693,6 +578,16 @@ function TabRegras() {
|
||||
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',
|
||||
dataIndex: 'descPct',
|
||||
@@ -782,8 +677,8 @@ function TabRegras() {
|
||||
locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }}
|
||||
/>
|
||||
<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
|
||||
o desconto automaticamente nos pedidos durante a vigencia.
|
||||
A regra vale para o representante, grupo, subgrupo e pauta informados (em branco = todos) e
|
||||
aplica o desconto automaticamente nos pedidos durante a vigencia.
|
||||
</Text>
|
||||
|
||||
<Modal
|
||||
@@ -817,6 +712,7 @@ function TabRegras() {
|
||||
</Form.Item>
|
||||
|
||||
{editTarget ? (
|
||||
<>
|
||||
<Flex gap={16}>
|
||||
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
|
||||
<Select
|
||||
@@ -841,6 +737,16 @@ function TabRegras() {
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
<Form.Item name="idPauta" label="Pauta de precos">
|
||||
<Select
|
||||
options={pautaOptions}
|
||||
placeholder="Todas as pautas"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
@@ -871,6 +777,20 @@ function TabRegras() {
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</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() {
|
||||
const items = [
|
||||
{
|
||||
key: 'descontos',
|
||||
label: 'Desconto por Representante',
|
||||
children: <TabDescontos />,
|
||||
},
|
||||
{
|
||||
key: 'promocoes',
|
||||
label: 'Promocoes',
|
||||
@@ -927,7 +842,7 @@ export function PoliticasPage() {
|
||||
Politicas Comerciais
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
Limites de desconto e promocoes com vigencia
|
||||
Promocoes e regras de desconto com vigencia
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
|
||||
164
apps/web/src/cockpits/ger/SupervisorDetalheModal.tsx
Normal file
164
apps/web/src/cockpits/ger/SupervisorDetalheModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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")
|
||||
export function useRepInativos(codVendedor: number | null) {
|
||||
return useQuery<RepInativosDetail>({
|
||||
|
||||
@@ -2,15 +2,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ManagerDashboardSchema,
|
||||
EquipeResponseSchema,
|
||||
AlcadaDescontosResponseSchema,
|
||||
SupervisoresResponseSchema,
|
||||
PromocoesResponseSchema,
|
||||
RegrasDescontoResponseSchema,
|
||||
GruposProdutoResponseSchema,
|
||||
type ManagerDashboard,
|
||||
type EquipeResponse,
|
||||
type AlcadaDescontosResponse,
|
||||
type SupervisoresResponse,
|
||||
type PromocoesResponse,
|
||||
type UpsertDescontoBody,
|
||||
type CreatePromocaoBody,
|
||||
type UpdatePromocaoBody,
|
||||
type RegrasDescontoResponse,
|
||||
@@ -47,14 +46,14 @@ export function useEquipe() {
|
||||
});
|
||||
}
|
||||
|
||||
export function usePoliticasDescontos() {
|
||||
return useQuery<AlcadaDescontosResponse>({
|
||||
queryKey: ['politicas', 'descontos'],
|
||||
export function useSupervisores() {
|
||||
return useQuery<SupervisoresResponse>({
|
||||
queryKey: ['equipe', 'supervisores'],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/politicas/descontos');
|
||||
return AlcadaDescontosResponseSchema.parse(raw);
|
||||
const raw = await apiFetch('/equipe/supervisores');
|
||||
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() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -16,3 +16,30 @@ export const EquipeResponseSchema = z.object({
|
||||
syncedAt: z.iso.datetime(),
|
||||
});
|
||||
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>;
|
||||
|
||||
@@ -1,25 +1,5 @@
|
||||
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({
|
||||
id: z.number().int(),
|
||||
descricao: z.string(),
|
||||
@@ -71,7 +51,7 @@ export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>;
|
||||
|
||||
// ─── Regras de Desconto ───────────────────────────────────────────────────────
|
||||
// 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({
|
||||
id: z.number().int(),
|
||||
@@ -80,6 +60,8 @@ export const RegraDescontoSchema = z.object({
|
||||
grupo: z.string().nullable(),
|
||||
codSubgrupo: z.number().int().nullable(),
|
||||
subgrupo: z.string().nullable(),
|
||||
idPauta: z.number().int().nullable(),
|
||||
pauta: z.string().nullable(),
|
||||
codVendedor: z.number().int().nullable(),
|
||||
nomeVendedor: z.string().nullable(),
|
||||
descPct: z.number(),
|
||||
@@ -96,12 +78,14 @@ export const RegrasDescontoResponseSchema = z.object({
|
||||
});
|
||||
export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>;
|
||||
|
||||
// Criação aceita VÁRIOS grupos e/ou subgrupos — uma regra por alvo (grupos
|
||||
// selecionados viram regras por grupo; subgrupos viram regras por subgrupo).
|
||||
// Criação aceita VÁRIOS grupos, subgrupos e/ou pautas — uma regra por alvo
|
||||
// (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({
|
||||
descricao: z.string().min(1).max(200),
|
||||
codGrupos: 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(),
|
||||
descPct: z.number().min(0).max(100),
|
||||
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(),
|
||||
codGrupo: z.number().int().nullable().optional(),
|
||||
codSubgrupo: z.number().int().nullable().optional(),
|
||||
idPauta: z.number().int().nullable().optional(),
|
||||
codVendedor: z.number().int().nullable().optional(),
|
||||
descPct: z.number().min(0).max(100).optional(),
|
||||
dataInicio: z
|
||||
@@ -166,6 +151,8 @@ export const RegraVigenteSchema = z.object({
|
||||
grupo: z.string().nullable(),
|
||||
codSubgrupo: z.number().int().nullable(),
|
||||
subgrupo: z.string().nullable(),
|
||||
idPauta: z.number().int().nullable(),
|
||||
pauta: z.string().nullable(),
|
||||
descPct: z.number(),
|
||||
dataFim: z.string(),
|
||||
});
|
||||
|
||||
@@ -720,8 +720,9 @@ CREATE TABLE IF NOT EXISTS sar.promocoes (
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Regras de Desconto (Politicas Comerciais - criadas pelo Gerente/Admin)
|
||||
-- Desconto liberado por grupo/subgrupo de produto e/ou representante, com
|
||||
-- vigencia. Campos nulos = "todos" (ex: cod_vendedor NULL vale para todo rep).
|
||||
-- Desconto liberado por grupo/subgrupo de produto, pauta de precos e/ou
|
||||
-- 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 (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -730,6 +731,7 @@ CREATE TABLE IF NOT EXISTS sar.regras_desconto (
|
||||
cod_grupo INTEGER,
|
||||
cod_subgrupo INTEGER,
|
||||
cod_vendedor INTEGER,
|
||||
id_pauta INTEGER,
|
||||
desc_pct NUMERIC(5,2) NOT NULL,
|
||||
data_inicio DATE NOT NULL,
|
||||
data_fim DATE NOT NULL,
|
||||
|
||||
Reference in New Issue
Block a user