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) ────────────────────────────────────
//
// 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")

View File

@@ -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')

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');
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)

View File

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

View File

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

View File

@@ -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);

View File

@@ -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();

View File

@@ -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),
})),

View File

@@ -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 &mdash; {mes}
Disputa dos supervisores &mdash; {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>
);
}

View File

@@ -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>

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")
export function useRepInativos(codVendedor: number | null) {
return useQuery<RepInativosDetail>({

View File

@@ -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({

View File

@@ -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>;

View File

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

View File

@@ -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,