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) ────────────────────────────────────
|
// ─── RegraDesconto (Políticas Comerciais) ────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou
|
// Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou
|
||||||
// subgrupo de produto e/ou representante, com vigência. Campos nulos = "todos".
|
// subgrupo de produto, pauta de preços e/ou representante, com vigência.
|
||||||
|
// Campos nulos = "todos" (id_pauta nulo = qualquer pauta).
|
||||||
|
|
||||||
model RegraDesconto {
|
model RegraDesconto {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
@@ -183,6 +184,7 @@ model RegraDesconto {
|
|||||||
codGrupo Int? @map("cod_grupo")
|
codGrupo Int? @map("cod_grupo")
|
||||||
codSubgrupo Int? @map("cod_subgrupo")
|
codSubgrupo Int? @map("cod_subgrupo")
|
||||||
codVendedor Int? @map("cod_vendedor")
|
codVendedor Int? @map("cod_vendedor")
|
||||||
|
idPauta Int? @map("id_pauta")
|
||||||
descPct Decimal @db.Decimal(5, 2) @map("desc_pct")
|
descPct Decimal @db.Decimal(5, 2) @map("desc_pct")
|
||||||
dataInicio DateTime @db.Date @map("data_inicio")
|
dataInicio DateTime @db.Date @map("data_inicio")
|
||||||
dataFim DateTime @db.Date @map("data_fim")
|
dataFim DateTime @db.Date @map("data_fim")
|
||||||
|
|||||||
@@ -45,9 +45,13 @@ export class DashboardController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('supervisor')
|
@Get('supervisor')
|
||||||
supervisorDashboard(): Promise<SupervisorDashboard> {
|
supervisorDashboard(
|
||||||
|
@Query('codSupervisor') codSupervisor?: string,
|
||||||
|
): Promise<SupervisorDashboard> {
|
||||||
this.assertGestor();
|
this.assertGestor();
|
||||||
return this.dashboard.supervisorDashboard();
|
return this.dashboard.supervisorDashboard(
|
||||||
|
codSupervisor ? parseInt(codSupervisor, 10) : undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('supervisor/inativos')
|
@Get('supervisor/inativos')
|
||||||
|
|||||||
@@ -729,18 +729,25 @@ export class DashboardService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async supervisorDashboard(): Promise<SupervisorDashboard> {
|
async supervisorDashboard(codSupervisor?: number): Promise<SupervisorDashboard> {
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
const idEmpresa = this.cls.get('idEmpresa');
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Supervisor vê só a própria equipe (cod_supervisor em vw_representantes);
|
// Supervisor vê só a própria equipe (cod_supervisor em vw_representantes).
|
||||||
// gerente/admin acessando este painel veem a empresa toda.
|
// Gerente/admin: sem codSupervisor veem a empresa toda; com codSupervisor
|
||||||
|
// (Detalhar da aba Equipe) veem o time daquele supervisor. codSupervisor é
|
||||||
|
// ignorado para role 'supervisor' — não pode espiar outra equipe.
|
||||||
const role = this.cls.get('role');
|
const role = this.cls.get('role');
|
||||||
const userId = this.cls.get('userId');
|
const userId = this.cls.get('userId');
|
||||||
const team =
|
let teamCode: number | null = null;
|
||||||
role === 'supervisor' ? await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0) : null;
|
if (role === 'supervisor') {
|
||||||
|
teamCode = userId ? parseInt(userId, 10) : 0;
|
||||||
|
} else if (codSupervisor && codSupervisor > 0) {
|
||||||
|
teamCode = codSupervisor;
|
||||||
|
}
|
||||||
|
const team = teamCode ? await getTeamCodes(prisma, teamCode) : null;
|
||||||
const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : '');
|
const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : '');
|
||||||
|
|
||||||
// Pedidos do dia — lê do ERP (situa != 5=Cancelado)
|
// Pedidos do dia — lê do ERP (situa != 5=Cancelado)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import type { EquipeResponse } from '@sar/api-interface';
|
import type { EquipeResponse, SupervisoresResponse } from '@sar/api-interface';
|
||||||
import { EquipeService } from './equipe.service';
|
import { EquipeService } from './equipe.service';
|
||||||
|
|
||||||
@Controller({ path: 'equipe' })
|
@Controller({ path: 'equipe' })
|
||||||
@@ -10,4 +10,9 @@ export class EquipeController {
|
|||||||
listEquipe(): Promise<EquipeResponse> {
|
listEquipe(): Promise<EquipeResponse> {
|
||||||
return this.equipe.listEquipe();
|
return this.equipe.listEquipe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('supervisores')
|
||||||
|
listSupervisores(): Promise<SupervisoresResponse> {
|
||||||
|
return this.equipe.listSupervisores();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import type { EquipeResponse } from '@sar/api-interface';
|
import type { EquipeResponse, SupervisorCard, SupervisoresResponse } from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
|
||||||
function matrizEmpresa(idEmpresa: number): number {
|
function matrizEmpresa(idEmpresa: number): number {
|
||||||
@@ -110,4 +110,173 @@ export class EquipeService {
|
|||||||
syncedAt: now.toISOString(),
|
syncedAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cards de supervisor da aba Equipe: agrega as métricas do mês por equipe.
|
||||||
|
// Time de um supervisor = reps cujo cod_supervisor aponta para ele + ele
|
||||||
|
// próprio (mesma regra de getTeamCodes, que o Detalhe reusa via
|
||||||
|
// /dashboard/supervisor?codSupervisor=). Agregação feita em JS a partir de
|
||||||
|
// métricas por rep — as mesmas contas do painel, sem escopo por equipe.
|
||||||
|
async listSupervisores(): Promise<SupervisoresResponse> {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role !== 'manager' && role !== 'admin') {
|
||||||
|
throw new ForbiddenException('Apenas gerentes podem consultar os supervisores');
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = now.getMonth() + 1;
|
||||||
|
const monthStartStr = new Date(year, month - 1, 1).toISOString().slice(0, 10);
|
||||||
|
const monthEndStr = new Date(year, month, 0).toISOString().slice(0, 10);
|
||||||
|
const prevStartStr = new Date(year, month - 2, 1).toISOString().slice(0, 10);
|
||||||
|
const prevEndStr = new Date(year, month - 1, 0).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const [repRows, pedidosRows, prevRows, metaRows, positivacaoRows] = await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<
|
||||||
|
{ cod_vendedor: number; cod_supervisor: number | null; nome: string | null }[]
|
||||||
|
>(`SELECT codigo AS cod_vendedor, cod_supervisor, nome FROM vw_representantes`),
|
||||||
|
prisma.$queryRawUnsafe<PedidosRepRow[]>(`
|
||||||
|
SELECT cod_vendedor,
|
||||||
|
COUNT(*)::text AS pedidos,
|
||||||
|
COALESCE(SUM(total), 0)::text AS faturamento
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
AND dt_pedido <= '${monthEndStr}'
|
||||||
|
GROUP BY cod_vendedor
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<{ cod_vendedor: number; faturamento: string }[]>(`
|
||||||
|
SELECT cod_vendedor,
|
||||||
|
COALESCE(SUM(total), 0)::text AS faturamento
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
AND dt_pedido >= '${prevStartStr}'
|
||||||
|
AND dt_pedido <= '${prevEndStr}'
|
||||||
|
GROUP BY cod_vendedor
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<MetaRepRow[]>(`
|
||||||
|
SELECT cod_vendedor,
|
||||||
|
SUM(valor)::text AS meta_valor
|
||||||
|
FROM sar.vw_metas
|
||||||
|
WHERE id_empresa = ${idEmpresaMatriz}
|
||||||
|
AND ano = ${year}
|
||||||
|
AND mes = ${month}
|
||||||
|
AND TRIM(tipo) = 'GR'
|
||||||
|
GROUP BY cod_vendedor
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<
|
||||||
|
{ cod_vendedor: number; total_clientes: string; positivados: string }[]
|
||||||
|
>(`
|
||||||
|
SELECT c.cod_vendedor,
|
||||||
|
COUNT(DISTINCT c.id_cliente)::text AS total_clientes,
|
||||||
|
COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END)::text AS positivados
|
||||||
|
FROM vw_clientes c
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DISTINCT id_cliente, cod_vendedor
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
AND dt_pedido <= '${monthEndStr}'
|
||||||
|
) ped ON ped.id_cliente = c.id_cliente AND ped.cod_vendedor = c.cod_vendedor
|
||||||
|
WHERE c.cod_vendedor > 0
|
||||||
|
GROUP BY c.cod_vendedor
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pedidosMap = new Map(
|
||||||
|
pedidosRows.map((r) => [
|
||||||
|
Number(r.cod_vendedor),
|
||||||
|
{ pedidos: Number(r.pedidos), faturamento: Number(r.faturamento) },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const prevMap = new Map(prevRows.map((r) => [Number(r.cod_vendedor), Number(r.faturamento)]));
|
||||||
|
const metaMap = new Map(metaRows.map((r) => [Number(r.cod_vendedor), Number(r.meta_valor)]));
|
||||||
|
const posMap = new Map(
|
||||||
|
positivacaoRows.map((r) => [
|
||||||
|
Number(r.cod_vendedor),
|
||||||
|
{ total: Number(r.total_clientes), positivados: Number(r.positivados) },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Monta a equipe de cada supervisor: código do supervisor -> códigos dos reps.
|
||||||
|
// O supervisor entra no próprio time (getTeamCodes inclui o supervisor).
|
||||||
|
const nomeByCod = new Map<number, string | null>();
|
||||||
|
const membrosBySup = new Map<number, Set<number>>();
|
||||||
|
for (const r of repRows) {
|
||||||
|
const cod = Number(r.cod_vendedor);
|
||||||
|
if (!nomeByCod.has(cod)) nomeByCod.set(cod, r.nome ?? null);
|
||||||
|
const sup = r.cod_supervisor != null ? Number(r.cod_supervisor) : 0;
|
||||||
|
if (sup > 0) {
|
||||||
|
let membros = membrosBySup.get(sup);
|
||||||
|
if (!membros) {
|
||||||
|
membros = new Set();
|
||||||
|
membrosBySup.set(sup, membros);
|
||||||
|
}
|
||||||
|
membros.add(cod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const supervisores: SupervisorCard[] = [...membrosBySup.entries()].map(([sup, membros]) => {
|
||||||
|
const team = new Set(membros);
|
||||||
|
team.add(sup); // o próprio supervisor faz parte do time
|
||||||
|
|
||||||
|
let faturamento = 0;
|
||||||
|
let faturamentoAnterior = 0;
|
||||||
|
let pedidos = 0;
|
||||||
|
let meta = 0;
|
||||||
|
let totalClientes = 0;
|
||||||
|
let positivados = 0;
|
||||||
|
let repsAtivos = 0;
|
||||||
|
for (const cod of team) {
|
||||||
|
const p = pedidosMap.get(cod);
|
||||||
|
if (p) {
|
||||||
|
faturamento += p.faturamento;
|
||||||
|
pedidos += p.pedidos;
|
||||||
|
if (p.faturamento > 0) repsAtivos += 1;
|
||||||
|
}
|
||||||
|
faturamentoAnterior += prevMap.get(cod) ?? 0;
|
||||||
|
meta += metaMap.get(cod) ?? 0;
|
||||||
|
const pos = posMap.get(cod);
|
||||||
|
if (pos) {
|
||||||
|
totalClientes += pos.total;
|
||||||
|
positivados += pos.positivados;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
codSupervisor: sup,
|
||||||
|
nomeSupervisor: nomeByCod.get(sup) ?? null,
|
||||||
|
faturamentoMes: faturamento,
|
||||||
|
faturamentoMesAnterior: faturamentoAnterior,
|
||||||
|
variacaoPct:
|
||||||
|
faturamentoAnterior > 0
|
||||||
|
? Math.round(((faturamento - faturamentoAnterior) / faturamentoAnterior) * 100)
|
||||||
|
: null,
|
||||||
|
pedidosMes: pedidos,
|
||||||
|
ticketMedio: pedidos > 0 ? faturamento / pedidos : 0,
|
||||||
|
metaTotal: meta,
|
||||||
|
pctMeta: meta > 0 ? Math.round((faturamento / meta) * 100) : 0,
|
||||||
|
totalClientes,
|
||||||
|
clientesPositivados: positivados,
|
||||||
|
pctPositivacao: totalClientes > 0 ? Math.round((positivados / totalClientes) * 100) : 0,
|
||||||
|
repsTotal: team.size,
|
||||||
|
repsAtivos,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ranking/disputa: maior faturamento primeiro.
|
||||||
|
supervisores.sort((a, b) => b.faturamentoMes - a.faturamentoMes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
supervisores,
|
||||||
|
syncedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -626,14 +626,16 @@ export class OrdersService {
|
|||||||
)
|
)
|
||||||
.reduce((max, p) => Math.max(max, Number(p.descPct)), 0);
|
.reduce((max, p) => Math.max(max, Number(p.descPct)), 0);
|
||||||
|
|
||||||
// Regra sem grupo/subgrupo vale para qualquer produto; com grupo e/ou
|
// Regra sem alvo vale para qualquer produto; com grupo, subgrupo e/ou
|
||||||
// subgrupo, o produto precisa casar com o que a regra especifica.
|
// pauta, precisa casar com o que a regra especifica. Regra por pauta só
|
||||||
|
// vale quando o pedido usa aquela pauta (id_pauta nulo = qualquer pauta).
|
||||||
const codSubgrupo = prod?.cod_subgrupo != null ? Number(prod.cod_subgrupo) : null;
|
const codSubgrupo = prod?.cod_subgrupo != null ? Number(prod.cod_subgrupo) : null;
|
||||||
const regraPct = regras
|
const regraPct = regras
|
||||||
.filter(
|
.filter(
|
||||||
(r) =>
|
(r) =>
|
||||||
(r.codGrupo == null || (codGrupo != null && r.codGrupo === codGrupo)) &&
|
(r.codGrupo == null || (codGrupo != null && r.codGrupo === codGrupo)) &&
|
||||||
(r.codSubgrupo == null || (codSubgrupo != null && r.codSubgrupo === codSubgrupo)),
|
(r.codSubgrupo == null || (codSubgrupo != null && r.codSubgrupo === codSubgrupo)) &&
|
||||||
|
(r.idPauta == null || (pedido.idPauta != null && r.idPauta === pedido.idPauta)),
|
||||||
)
|
)
|
||||||
.reduce((max, r) => Math.max(max, Number(r.descPct)), 0);
|
.reduce((max, r) => Math.max(max, Number(r.descPct)), 0);
|
||||||
|
|
||||||
|
|||||||
@@ -11,17 +11,14 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import {
|
import {
|
||||||
UpsertDescontoBodySchema,
|
|
||||||
CreatePromocaoBodySchema,
|
CreatePromocaoBodySchema,
|
||||||
UpdatePromocaoBodySchema,
|
UpdatePromocaoBodySchema,
|
||||||
CreateRegraDescontoBodySchema,
|
CreateRegraDescontoBodySchema,
|
||||||
UpdateRegraDescontoBodySchema,
|
UpdateRegraDescontoBodySchema,
|
||||||
type UpsertDescontoBody,
|
|
||||||
type CreatePromocaoBody,
|
type CreatePromocaoBody,
|
||||||
type UpdatePromocaoBody,
|
type UpdatePromocaoBody,
|
||||||
type CreateRegraDescontoBody,
|
type CreateRegraDescontoBody,
|
||||||
type UpdateRegraDescontoBody,
|
type UpdateRegraDescontoBody,
|
||||||
type AlcadaDescontosResponse,
|
|
||||||
type PromocoesResponse,
|
type PromocoesResponse,
|
||||||
type PromocoesVigentesResponse,
|
type PromocoesVigentesResponse,
|
||||||
type AlcadaRepResponse,
|
type AlcadaRepResponse,
|
||||||
@@ -31,7 +28,6 @@ import {
|
|||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import { PoliticasService } from './politicas.service';
|
import { PoliticasService } from './politicas.service';
|
||||||
|
|
||||||
class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {}
|
|
||||||
class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {}
|
class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {}
|
||||||
class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {}
|
class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {}
|
||||||
class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {}
|
class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {}
|
||||||
@@ -41,17 +37,6 @@ class UpdateRegraDescontoDto extends createZodDto(UpdateRegraDescontoBodySchema)
|
|||||||
export class PoliticasController {
|
export class PoliticasController {
|
||||||
constructor(private readonly politicas: PoliticasService) {}
|
constructor(private readonly politicas: PoliticasService) {}
|
||||||
|
|
||||||
@Get('descontos')
|
|
||||||
listDescontos(): Promise<AlcadaDescontosResponse> {
|
|
||||||
return this.politicas.listDescontos();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('descontos')
|
|
||||||
@HttpCode(200)
|
|
||||||
upsertDesconto(@Body() body: UpsertDescontoDto): Promise<void> {
|
|
||||||
return this.politicas.upsertDesconto(body as unknown as UpsertDescontoBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('promocoes')
|
@Get('promocoes')
|
||||||
listPromocoes(): Promise<PromocoesResponse> {
|
listPromocoes(): Promise<PromocoesResponse> {
|
||||||
return this.politicas.listPromocoes();
|
return this.politicas.listPromocoes();
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import type {
|
import type {
|
||||||
AlcadaDescontosResponse,
|
|
||||||
UpsertDescontoBody,
|
|
||||||
PromocoesResponse,
|
PromocoesResponse,
|
||||||
CreatePromocaoBody,
|
CreatePromocaoBody,
|
||||||
UpdatePromocaoBody,
|
UpdatePromocaoBody,
|
||||||
@@ -27,61 +25,6 @@ export class PoliticasService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listDescontos(): Promise<AlcadaDescontosResponse> {
|
|
||||||
this.assertManager();
|
|
||||||
const prisma = this.cls.get('prisma');
|
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
|
||||||
const idEmpresa = this.cls.get('idEmpresa');
|
|
||||||
|
|
||||||
const rows = await prisma.alcadaDesconto.findMany({
|
|
||||||
where: { idEmpresa },
|
|
||||||
orderBy: [{ codVendedor: 'asc' }, { codGrupo: 'asc' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Busca nomes dos representantes
|
|
||||||
const codigos = [...new Set(rows.map((r) => r.codVendedor))];
|
|
||||||
const repRows =
|
|
||||||
codigos.length > 0
|
|
||||||
? await prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
|
|
||||||
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${codigos.join(',')})`,
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const repNameMap = new Map(repRows.map((r) => [Number(r.codigo), r.nome]));
|
|
||||||
|
|
||||||
return {
|
|
||||||
descontos: rows.map((r) => ({
|
|
||||||
codVendedor: r.codVendedor,
|
|
||||||
codGrupo: r.codGrupo,
|
|
||||||
limitePerc: Number(r.limitePerc),
|
|
||||||
nomeVendedor: repNameMap.get(r.codVendedor) ?? null,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async upsertDesconto(body: UpsertDescontoBody): Promise<void> {
|
|
||||||
this.assertManager();
|
|
||||||
const prisma = this.cls.get('prisma');
|
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
|
||||||
const idEmpresa = this.cls.get('idEmpresa');
|
|
||||||
|
|
||||||
await prisma.alcadaDesconto.upsert({
|
|
||||||
where: {
|
|
||||||
codVendedor_idEmpresa_codGrupo: {
|
|
||||||
codVendedor: body.codVendedor,
|
|
||||||
idEmpresa,
|
|
||||||
codGrupo: body.codGrupo ?? 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
codVendedor: body.codVendedor,
|
|
||||||
idEmpresa,
|
|
||||||
codGrupo: body.codGrupo ?? 0,
|
|
||||||
limitePerc: body.limitePerc,
|
|
||||||
},
|
|
||||||
update: { limitePerc: body.limitePerc },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async listPromocoes(): Promise<PromocoesResponse> {
|
async listPromocoes(): Promise<PromocoesResponse> {
|
||||||
this.assertManager();
|
this.assertManager();
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
@@ -220,6 +163,23 @@ export class PoliticasService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Descrições das pautas informadas — UI sempre mostra o nome, nunca só o id.
|
||||||
|
private async loadPautaNames(idPautas: number[]): Promise<Map<number, string | null>> {
|
||||||
|
if (idPautas.length === 0) return new Map();
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<{ id_pauta: number; descricao: string | null }[]>(
|
||||||
|
`SELECT id_pauta, TRIM(descricao) AS descricao
|
||||||
|
FROM vw_pautas
|
||||||
|
WHERE id_empresa = ${idMatriz}
|
||||||
|
AND id_pauta IN (${idPautas.join(',')})`,
|
||||||
|
);
|
||||||
|
return new Map(rows.map((r) => [Number(r.id_pauta), r.descricao]));
|
||||||
|
}
|
||||||
|
|
||||||
async listRegras(): Promise<RegrasDescontoResponse> {
|
async listRegras(): Promise<RegrasDescontoResponse> {
|
||||||
this.assertManager();
|
this.assertManager();
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
@@ -252,6 +212,9 @@ export class PoliticasService {
|
|||||||
grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]),
|
grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const idPautas = [...new Set(rows.map((r) => r.idPauta).filter((p): p is number => p != null))];
|
||||||
|
const pautaNameMap = await this.loadPautaNames(idPautas);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
regras: rows.map((r) => ({
|
regras: rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -260,6 +223,8 @@ export class PoliticasService {
|
|||||||
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
|
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
|
||||||
codSubgrupo: r.codSubgrupo,
|
codSubgrupo: r.codSubgrupo,
|
||||||
subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null,
|
subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null,
|
||||||
|
idPauta: r.idPauta,
|
||||||
|
pauta: r.idPauta != null ? (pautaNameMap.get(r.idPauta) ?? null) : null,
|
||||||
codVendedor: r.codVendedor,
|
codVendedor: r.codVendedor,
|
||||||
nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null,
|
nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null,
|
||||||
descPct: Number(r.descPct),
|
descPct: Number(r.descPct),
|
||||||
@@ -272,8 +237,9 @@ export class PoliticasService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cria uma regra POR ALVO: cada grupo selecionado vira regra por grupo e
|
// Cria uma regra POR ALVO: cada grupo vira regra por grupo, cada subgrupo
|
||||||
// cada subgrupo vira regra por subgrupo. Sem alvos, regra vale para todos.
|
// vira regra por subgrupo e cada pauta vira regra por pauta. O representante
|
||||||
|
// escolhido é copiado em todas. Sem alvos, regra vale para todos.
|
||||||
async createRegra(body: CreateRegraDescontoBody): Promise<{ ids: number[] }> {
|
async createRegra(body: CreateRegraDescontoBody): Promise<{ ids: number[] }> {
|
||||||
this.assertManager();
|
this.assertManager();
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
@@ -281,11 +247,17 @@ export class PoliticasService {
|
|||||||
const idEmpresa = this.cls.get('idEmpresa');
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
const userId = this.cls.get('userId') ?? 'system';
|
const userId = this.cls.get('userId') ?? 'system';
|
||||||
|
|
||||||
const alvos: { codGrupo: number | null; codSubgrupo: number | null }[] = [
|
const alvos: { codGrupo: number | null; codSubgrupo: number | null; idPauta: number | null }[] =
|
||||||
...(body.codGrupos ?? []).map((g) => ({ codGrupo: g, codSubgrupo: null })),
|
[
|
||||||
...(body.codSubgrupos ?? []).map((s) => ({ codGrupo: null, codSubgrupo: s })),
|
...(body.codGrupos ?? []).map((g) => ({ codGrupo: g, codSubgrupo: null, idPauta: null })),
|
||||||
|
...(body.codSubgrupos ?? []).map((s) => ({
|
||||||
|
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 ids = await prisma.$transaction(async (tx) => {
|
||||||
const created: number[] = [];
|
const created: number[] = [];
|
||||||
@@ -296,6 +268,7 @@ export class PoliticasService {
|
|||||||
descricao: body.descricao,
|
descricao: body.descricao,
|
||||||
codGrupo: alvo.codGrupo,
|
codGrupo: alvo.codGrupo,
|
||||||
codSubgrupo: alvo.codSubgrupo,
|
codSubgrupo: alvo.codSubgrupo,
|
||||||
|
idPauta: alvo.idPauta,
|
||||||
codVendedor: body.codVendedor ?? null,
|
codVendedor: body.codVendedor ?? null,
|
||||||
descPct: body.descPct,
|
descPct: body.descPct,
|
||||||
dataInicio: new Date(body.dataInicio),
|
dataInicio: new Date(body.dataInicio),
|
||||||
@@ -325,6 +298,7 @@ export class PoliticasService {
|
|||||||
...(body.descricao !== undefined && { descricao: body.descricao }),
|
...(body.descricao !== undefined && { descricao: body.descricao }),
|
||||||
...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }),
|
...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }),
|
||||||
...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }),
|
...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }),
|
||||||
|
...(body.idPauta !== undefined && { idPauta: body.idPauta }),
|
||||||
...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }),
|
...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }),
|
||||||
...(body.descPct !== undefined && { descPct: body.descPct }),
|
...(body.descPct !== undefined && { descPct: body.descPct }),
|
||||||
...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }),
|
...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }),
|
||||||
@@ -453,6 +427,9 @@ export class PoliticasService {
|
|||||||
grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]),
|
grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const idPautas = [...new Set(rows.map((r) => r.idPauta).filter((p): p is number => p != null))];
|
||||||
|
const pautaNameMap = await this.loadPautaNames(idPautas);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
regras: rows.map((r) => ({
|
regras: rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -461,6 +438,8 @@ export class PoliticasService {
|
|||||||
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
|
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
|
||||||
codSubgrupo: r.codSubgrupo,
|
codSubgrupo: r.codSubgrupo,
|
||||||
subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null,
|
subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null,
|
||||||
|
idPauta: r.idPauta,
|
||||||
|
pauta: r.idPauta != null ? (pautaNameMap.get(r.idPauta) ?? null) : null,
|
||||||
descPct: Number(r.descPct),
|
descPct: Number(r.descPct),
|
||||||
dataFim: r.dataFim.toISOString().slice(0, 10),
|
dataFim: r.dataFim.toISOString().slice(0, 10),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Card, Flex, Progress, Skeleton, Table, Tag, Typography } from 'antd';
|
import { useState } from 'react';
|
||||||
import type { TableColumnsType } from 'antd';
|
import { Button, Card, Col, Empty, Flex, Progress, Row, Skeleton, Space, Typography } from 'antd';
|
||||||
import type { RepStats } from '@sar/api-interface';
|
import type { SupervisorCard } from '@sar/api-interface';
|
||||||
import { useEquipe } from '../../lib/queries/gerente';
|
import { useSupervisores } from '../../lib/queries/gerente';
|
||||||
|
import { SupervisorDetalheModal } from './SupervisorDetalheModal';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
@@ -9,85 +10,118 @@ function fmt(v: number): string {
|
|||||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: TableColumnsType<RepStats> = [
|
function metaColor(pct: number): string {
|
||||||
{
|
return pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14';
|
||||||
title: 'Representante',
|
}
|
||||||
key: 'rep',
|
|
||||||
render: (_: unknown, row: RepStats) => (
|
const MEDALHAS = ['🥇', '🥈', '🥉'];
|
||||||
<Flex vertical gap={0}>
|
|
||||||
<Text strong>{row.nomeVendedor ?? `Cód. ${row.codVendedor}`}</Text>
|
function posicaoLabel(i: number): string {
|
||||||
{row.nomeVendedor && (
|
return MEDALHAS[i] ?? `${i + 1}º`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)' }}>
|
<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>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
),
|
|
||||||
},
|
{/* Meta do time */}
|
||||||
{
|
<div>
|
||||||
title: 'Pedidos',
|
<Flex justify="space-between" align="baseline" style={{ marginBottom: 4 }}>
|
||||||
dataIndex: 'pedidosMes',
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
width: 90,
|
Meta do time
|
||||||
align: 'right',
|
</Text>
|
||||||
className: 'tabular-nums',
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
sorter: (a, b) => a.pedidosMes - b.pedidosMes,
|
{s.metaTotal > 0 ? `${s.pctMeta}%` : 'sem meta'}
|
||||||
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}%
|
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
),
|
<Progress
|
||||||
},
|
percent={s.metaTotal > 0 ? Math.min(s.pctMeta, 100) : 0}
|
||||||
];
|
size="small"
|
||||||
|
strokeColor={metaColor(s.pctMeta)}
|
||||||
|
showInfo={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ticket + positivação */}
|
||||||
|
<Flex justify="space-between" gap={12}>
|
||||||
|
<Space orientation="vertical" size={0}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
TICKET MÉDIO
|
||||||
|
</Text>
|
||||||
|
<Text strong className="tabular-nums">
|
||||||
|
{s.ticketMedio > 0 ? fmt(s.ticketMedio) : '—'}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
<Space orientation="vertical" size={0} style={{ textAlign: 'right' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
POSITIVAÇÃO
|
||||||
|
</Text>
|
||||||
|
<Text strong className="tabular-nums">
|
||||||
|
{s.pctPositivacao}%{' '}
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
({s.clientesPositivados}/{s.totalClientes})
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Button block onClick={onDetalhar}>
|
||||||
|
Detalhar
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EquipePage() {
|
export function EquipePage() {
|
||||||
const { data, isLoading } = useEquipe();
|
const { data, isLoading } = useSupervisores();
|
||||||
|
const [detalhe, setDetalhe] = useState<SupervisorCard | null>(null);
|
||||||
if (isLoading || !data) {
|
|
||||||
return (
|
|
||||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
|
||||||
<Skeleton active paragraph={{ rows: 1 }} />
|
|
||||||
<Skeleton active paragraph={{ rows: 6 }} />
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
@@ -98,25 +132,41 @@ export function EquipePage() {
|
|||||||
Equipe
|
Equipe
|
||||||
</Title>
|
</Title>
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||||
Performance dos representantes — {mes}
|
Disputa dos supervisores — {mes}
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{isLoading || !data ? (
|
||||||
|
<Row gutter={[24, 24]}>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Col key={i} xs={24} md={12} lg={8}>
|
||||||
<Card>
|
<Card>
|
||||||
<Table<RepStats>
|
<Skeleton active paragraph={{ rows: 4 }} />
|
||||||
rowKey="codVendedor"
|
|
||||||
columns={columns}
|
|
||||||
dataSource={data.reps}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
locale={{ emptyText: 'Nenhum representante encontrado.' }}
|
|
||||||
scroll={{ x: 700 }}
|
|
||||||
/>
|
|
||||||
</Card>
|
</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)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')}
|
Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')}
|
||||||
</Text>
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SupervisorDetalheModal supervisor={detalhe} onClose={() => setDetalhe(null)} />
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,17 +24,9 @@ import type { TableColumnsType } from 'antd';
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons';
|
import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type {
|
import type { Promocao, RegraDesconto, RepStats, GrupoProdutoItem } from '@sar/api-interface';
|
||||||
AlcadaDescontoItem,
|
|
||||||
Promocao,
|
|
||||||
RegraDesconto,
|
|
||||||
RepStats,
|
|
||||||
GrupoProdutoItem,
|
|
||||||
} from '@sar/api-interface';
|
|
||||||
import {
|
import {
|
||||||
usePoliticasDescontos,
|
|
||||||
usePromocoes,
|
usePromocoes,
|
||||||
useUpsertDesconto,
|
|
||||||
useCreatePromocao,
|
useCreatePromocao,
|
||||||
useUpdatePromocao,
|
useUpdatePromocao,
|
||||||
useDeletePromocao,
|
useDeletePromocao,
|
||||||
@@ -45,7 +37,7 @@ import {
|
|||||||
useUpdateRegraDesconto,
|
useUpdateRegraDesconto,
|
||||||
useDeleteRegraDesconto,
|
useDeleteRegraDesconto,
|
||||||
} from '../../lib/queries/gerente';
|
} from '../../lib/queries/gerente';
|
||||||
import { useCatalog } from '../../lib/queries/catalog';
|
import { useCatalog, usePautas } from '../../lib/queries/catalog';
|
||||||
|
|
||||||
// ─── ProdutoSelect: busca remota por código ou descrição ─────────────────────
|
// ─── ProdutoSelect: busca remota por código ou descrição ─────────────────────
|
||||||
|
|
||||||
@@ -104,125 +96,6 @@ function ProdutoSelect({
|
|||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
// ─── Tab Descontos ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function TabDescontos() {
|
|
||||||
const { data, isLoading } = usePoliticasDescontos();
|
|
||||||
const upsert = useUpsertDesconto();
|
|
||||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
|
||||||
const [editValue, setEditValue] = useState<number>(0);
|
|
||||||
const [msg, msgCtx] = message.useMessage();
|
|
||||||
|
|
||||||
function startEdit(row: AlcadaDescontoItem) {
|
|
||||||
setEditingKey(`${row.codVendedor}-${row.codGrupo}`);
|
|
||||||
setEditValue(row.limitePerc);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEdit(row: AlcadaDescontoItem) {
|
|
||||||
try {
|
|
||||||
await upsert.mutateAsync({
|
|
||||||
codVendedor: row.codVendedor,
|
|
||||||
codGrupo: row.codGrupo,
|
|
||||||
limitePerc: editValue,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return; // erro já notificado pelo handler global do QueryClient
|
|
||||||
}
|
|
||||||
setEditingKey(null);
|
|
||||||
void msg.success('Limite atualizado');
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: TableColumnsType<AlcadaDescontoItem> = [
|
|
||||||
{
|
|
||||||
title: 'Representante',
|
|
||||||
key: 'rep',
|
|
||||||
render: (_: unknown, row: AlcadaDescontoItem) =>
|
|
||||||
row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Grupo',
|
|
||||||
dataIndex: 'codGrupo',
|
|
||||||
width: 100,
|
|
||||||
render: (v: number) => (v === 0 ? <Tag>Global</Tag> : <Tag color="blue">Grupo {v}</Tag>),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Limite de Desconto',
|
|
||||||
dataIndex: 'limitePerc',
|
|
||||||
width: 200,
|
|
||||||
render: (v: number, row: AlcadaDescontoItem) => {
|
|
||||||
const key = `${row.codVendedor}-${row.codGrupo}`;
|
|
||||||
if (editingKey === key) {
|
|
||||||
return (
|
|
||||||
<InputNumber
|
|
||||||
value={editValue}
|
|
||||||
onChange={(val) => setEditValue(val ?? 0)}
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
suffix="%"
|
|
||||||
size="small"
|
|
||||||
style={{ width: 100 }}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <Text className="tabular-nums">{v}%</Text>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
width: 120,
|
|
||||||
render: (_: unknown, row: AlcadaDescontoItem) => {
|
|
||||||
const key = `${row.codVendedor}-${row.codGrupo}`;
|
|
||||||
if (editingKey === key) {
|
|
||||||
return (
|
|
||||||
<Space>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
loading={upsert.isPending}
|
|
||||||
onClick={() => void saveEdit(row)}
|
|
||||||
>
|
|
||||||
Salvar
|
|
||||||
</Button>
|
|
||||||
<Button size="small" onClick={() => setEditingKey(null)}>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<FontAwesomeIcon icon={faPen} />}
|
|
||||||
onClick={() => startEdit(row)}
|
|
||||||
>
|
|
||||||
Editar
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{msgCtx}
|
|
||||||
<Table<AlcadaDescontoItem>
|
|
||||||
rowKey={(r) => `${r.codVendedor}-${r.codGrupo}`}
|
|
||||||
columns={columns}
|
|
||||||
dataSource={data.descontos}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
locale={{ emptyText: 'Nenhum limite configurado.' }}
|
|
||||||
/>
|
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
|
|
||||||
Limite 0 = sem restricao de desconto para este representante.
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tab Promocoes ────────────────────────────────────────────────────────────
|
// ─── Tab Promocoes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface PromocaoForm {
|
interface PromocaoForm {
|
||||||
@@ -534,8 +407,10 @@ interface RegraForm {
|
|||||||
codVendedor?: number;
|
codVendedor?: number;
|
||||||
codGrupos?: number[];
|
codGrupos?: number[];
|
||||||
codSubgrupos?: number[];
|
codSubgrupos?: number[];
|
||||||
|
idPautas?: number[];
|
||||||
codGrupo?: number;
|
codGrupo?: number;
|
||||||
codSubgrupo?: number;
|
codSubgrupo?: number;
|
||||||
|
idPauta?: number;
|
||||||
descPct: number;
|
descPct: number;
|
||||||
periodo: [dayjs.Dayjs, dayjs.Dayjs];
|
periodo: [dayjs.Dayjs, dayjs.Dayjs];
|
||||||
ativa?: boolean;
|
ativa?: boolean;
|
||||||
@@ -545,6 +420,7 @@ function TabRegras() {
|
|||||||
const { data, isLoading } = useRegrasDesconto();
|
const { data, isLoading } = useRegrasDesconto();
|
||||||
const { data: equipe } = useEquipe();
|
const { data: equipe } = useEquipe();
|
||||||
const { data: gruposData } = useGruposProduto();
|
const { data: gruposData } = useGruposProduto();
|
||||||
|
const { data: pautas } = usePautas();
|
||||||
const createMutation = useCreateRegraDesconto();
|
const createMutation = useCreateRegraDesconto();
|
||||||
const updateMutation = useUpdateRegraDesconto();
|
const updateMutation = useUpdateRegraDesconto();
|
||||||
const deleteMutation = useDeleteRegraDesconto();
|
const deleteMutation = useDeleteRegraDesconto();
|
||||||
@@ -560,6 +436,12 @@ function TabRegras() {
|
|||||||
label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`,
|
label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Pautas de preços — rótulo com código + descrição (busca encontra pelos dois)
|
||||||
|
const pautaOptions = (pautas ?? []).map((p) => ({
|
||||||
|
value: p.idPauta,
|
||||||
|
label: `${p.codigo} — ${p.descricao}`,
|
||||||
|
}));
|
||||||
|
|
||||||
// Rótulos com código + nome — a busca do select encontra pelos dois.
|
// Rótulos com código + nome — a busca do select encontra pelos dois.
|
||||||
// Subgrupos filtram pelos grupos escolhidos (single na edição, multi na criação).
|
// Subgrupos filtram pelos grupos escolhidos (single na edição, multi na criação).
|
||||||
const filtroGrupos = new Set<number>([
|
const filtroGrupos = new Set<number>([
|
||||||
@@ -603,6 +485,7 @@ function TabRegras() {
|
|||||||
codVendedor: r.codVendedor ?? undefined,
|
codVendedor: r.codVendedor ?? undefined,
|
||||||
codGrupo: r.codGrupo ?? undefined,
|
codGrupo: r.codGrupo ?? undefined,
|
||||||
codSubgrupo: r.codSubgrupo ?? undefined,
|
codSubgrupo: r.codSubgrupo ?? undefined,
|
||||||
|
idPauta: r.idPauta ?? undefined,
|
||||||
descPct: r.descPct,
|
descPct: r.descPct,
|
||||||
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
|
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
|
||||||
ativa: r.ativa,
|
ativa: r.ativa,
|
||||||
@@ -630,6 +513,7 @@ function TabRegras() {
|
|||||||
codVendedor: values.codVendedor ?? null,
|
codVendedor: values.codVendedor ?? null,
|
||||||
codGrupo: values.codGrupo ?? null,
|
codGrupo: values.codGrupo ?? null,
|
||||||
codSubgrupo: values.codSubgrupo ?? null,
|
codSubgrupo: values.codSubgrupo ?? null,
|
||||||
|
idPauta: values.idPauta ?? null,
|
||||||
descPct: values.descPct,
|
descPct: values.descPct,
|
||||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||||
dataFim: fim.format('YYYY-MM-DD'),
|
dataFim: fim.format('YYYY-MM-DD'),
|
||||||
@@ -643,6 +527,7 @@ function TabRegras() {
|
|||||||
codVendedor: values.codVendedor ?? null,
|
codVendedor: values.codVendedor ?? null,
|
||||||
codGrupos: values.codGrupos?.length ? values.codGrupos : undefined,
|
codGrupos: values.codGrupos?.length ? values.codGrupos : undefined,
|
||||||
codSubgrupos: values.codSubgrupos?.length ? values.codSubgrupos : undefined,
|
codSubgrupos: values.codSubgrupos?.length ? values.codSubgrupos : undefined,
|
||||||
|
idPautas: values.idPautas?.length ? values.idPautas : undefined,
|
||||||
descPct: values.descPct,
|
descPct: values.descPct,
|
||||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||||
dataFim: fim.format('YYYY-MM-DD'),
|
dataFim: fim.format('YYYY-MM-DD'),
|
||||||
@@ -683,7 +568,7 @@ function TabRegras() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Grupo / Subgrupo',
|
title: 'Grupo / Subgrupo',
|
||||||
width: 220,
|
width: 200,
|
||||||
render: (_: unknown, row: RegraDesconto) => {
|
render: (_: unknown, row: RegraDesconto) => {
|
||||||
if (row.codGrupo == null && row.codSubgrupo == null) return <Tag>Todos</Tag>;
|
if (row.codGrupo == null && row.codSubgrupo == null) return <Tag>Todos</Tag>;
|
||||||
const partes = [
|
const partes = [
|
||||||
@@ -693,6 +578,16 @@ function TabRegras() {
|
|||||||
return <Text style={{ fontSize: 'var(--text-sm)' }}>{partes.join(' / ')}</Text>;
|
return <Text style={{ fontSize: 'var(--text-sm)' }}>{partes.join(' / ')}</Text>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Pauta',
|
||||||
|
width: 160,
|
||||||
|
render: (_: unknown, row: RegraDesconto) =>
|
||||||
|
row.idPauta == null ? (
|
||||||
|
<Tag>Todas</Tag>
|
||||||
|
) : (
|
||||||
|
<Text style={{ fontSize: 'var(--text-sm)' }}>{row.pauta ?? `Pauta ${row.idPauta}`}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Desconto',
|
title: 'Desconto',
|
||||||
dataIndex: 'descPct',
|
dataIndex: 'descPct',
|
||||||
@@ -782,8 +677,8 @@ function TabRegras() {
|
|||||||
locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }}
|
locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }}
|
||||||
/>
|
/>
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
|
||||||
A regra vale para o representante, grupo e subgrupo informados (em branco = todos) e aplica
|
A regra vale para o representante, grupo, subgrupo e pauta informados (em branco = todos) e
|
||||||
o desconto automaticamente nos pedidos durante a vigencia.
|
aplica o desconto automaticamente nos pedidos durante a vigencia.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -817,6 +712,7 @@ function TabRegras() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{editTarget ? (
|
{editTarget ? (
|
||||||
|
<>
|
||||||
<Flex gap={16}>
|
<Flex gap={16}>
|
||||||
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
|
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
|
||||||
<Select
|
<Select
|
||||||
@@ -841,6 +737,16 @@ function TabRegras() {
|
|||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<Form.Item name="idPauta" label="Pauta de precos">
|
||||||
|
<Select
|
||||||
|
options={pautaOptions}
|
||||||
|
placeholder="Todas as pautas"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -871,6 +777,20 @@ function TabRegras() {
|
|||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="idPautas"
|
||||||
|
label="Pautas de precos"
|
||||||
|
extra="Selecione 1 ou mais pautas — uma regra por pauta, aplicada a todos os produtos dela"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
options={pautaOptions}
|
||||||
|
placeholder="Todas as pautas"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -903,11 +823,6 @@ function TabRegras() {
|
|||||||
|
|
||||||
export function PoliticasPage() {
|
export function PoliticasPage() {
|
||||||
const items = [
|
const items = [
|
||||||
{
|
|
||||||
key: 'descontos',
|
|
||||||
label: 'Desconto por Representante',
|
|
||||||
children: <TabDescontos />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'promocoes',
|
key: 'promocoes',
|
||||||
label: 'Promocoes',
|
label: 'Promocoes',
|
||||||
@@ -927,7 +842,7 @@ export function PoliticasPage() {
|
|||||||
Politicas Comerciais
|
Politicas Comerciais
|
||||||
</Title>
|
</Title>
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||||
Limites de desconto e promocoes com vigencia
|
Promocoes e regras de desconto com vigencia
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
|||||||
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")
|
// Detalhe dos clientes inativos de um rep (modal do card "Inativos por Rep")
|
||||||
export function useRepInativos(codVendedor: number | null) {
|
export function useRepInativos(codVendedor: number | null) {
|
||||||
return useQuery<RepInativosDetail>({
|
return useQuery<RepInativosDetail>({
|
||||||
|
|||||||
@@ -2,15 +2,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import {
|
import {
|
||||||
ManagerDashboardSchema,
|
ManagerDashboardSchema,
|
||||||
EquipeResponseSchema,
|
EquipeResponseSchema,
|
||||||
AlcadaDescontosResponseSchema,
|
SupervisoresResponseSchema,
|
||||||
PromocoesResponseSchema,
|
PromocoesResponseSchema,
|
||||||
RegrasDescontoResponseSchema,
|
RegrasDescontoResponseSchema,
|
||||||
GruposProdutoResponseSchema,
|
GruposProdutoResponseSchema,
|
||||||
type ManagerDashboard,
|
type ManagerDashboard,
|
||||||
type EquipeResponse,
|
type EquipeResponse,
|
||||||
type AlcadaDescontosResponse,
|
type SupervisoresResponse,
|
||||||
type PromocoesResponse,
|
type PromocoesResponse,
|
||||||
type UpsertDescontoBody,
|
|
||||||
type CreatePromocaoBody,
|
type CreatePromocaoBody,
|
||||||
type UpdatePromocaoBody,
|
type UpdatePromocaoBody,
|
||||||
type RegrasDescontoResponse,
|
type RegrasDescontoResponse,
|
||||||
@@ -47,14 +46,14 @@ export function useEquipe() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePoliticasDescontos() {
|
export function useSupervisores() {
|
||||||
return useQuery<AlcadaDescontosResponse>({
|
return useQuery<SupervisoresResponse>({
|
||||||
queryKey: ['politicas', 'descontos'],
|
queryKey: ['equipe', 'supervisores'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const raw = await apiFetch('/politicas/descontos');
|
const raw = await apiFetch('/equipe/supervisores');
|
||||||
return AlcadaDescontosResponseSchema.parse(raw);
|
return SupervisoresResponseSchema.parse(raw);
|
||||||
},
|
},
|
||||||
staleTime: 10 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,15 +68,6 @@ export function usePromocoes() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpsertDesconto() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (body: UpsertDescontoBody) =>
|
|
||||||
apiFetch('/politicas/descontos', { method: 'POST', body }),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreatePromocao() {
|
export function useCreatePromocao() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -16,3 +16,30 @@ export const EquipeResponseSchema = z.object({
|
|||||||
syncedAt: z.iso.datetime(),
|
syncedAt: z.iso.datetime(),
|
||||||
});
|
});
|
||||||
export type EquipeResponse = z.infer<typeof EquipeResponseSchema>;
|
export type EquipeResponse = z.infer<typeof EquipeResponseSchema>;
|
||||||
|
|
||||||
|
// Card de disputa/análise por supervisor na aba Equipe (visão gerente/admin).
|
||||||
|
// Time = reps cujo cod_supervisor aponta para este supervisor + o próprio
|
||||||
|
// supervisor (mesma semântica de getTeamCodes). Métricas agregadas do time no mês.
|
||||||
|
export const SupervisorCardSchema = z.object({
|
||||||
|
codSupervisor: z.number().int(),
|
||||||
|
nomeSupervisor: z.string().nullable(),
|
||||||
|
faturamentoMes: z.number(),
|
||||||
|
faturamentoMesAnterior: z.number(),
|
||||||
|
variacaoPct: z.number().nullable(), // null quando não há base no mês anterior
|
||||||
|
pedidosMes: z.number().int(),
|
||||||
|
ticketMedio: z.number(),
|
||||||
|
metaTotal: z.number(),
|
||||||
|
pctMeta: z.number(),
|
||||||
|
totalClientes: z.number().int(),
|
||||||
|
clientesPositivados: z.number().int(),
|
||||||
|
pctPositivacao: z.number(),
|
||||||
|
repsTotal: z.number().int(),
|
||||||
|
repsAtivos: z.number().int(), // reps do time que faturaram no mês
|
||||||
|
});
|
||||||
|
export type SupervisorCard = z.infer<typeof SupervisorCardSchema>;
|
||||||
|
|
||||||
|
export const SupervisoresResponseSchema = z.object({
|
||||||
|
supervisores: z.array(SupervisorCardSchema),
|
||||||
|
syncedAt: z.iso.datetime(),
|
||||||
|
});
|
||||||
|
export type SupervisoresResponse = z.infer<typeof SupervisoresResponseSchema>;
|
||||||
|
|||||||
@@ -1,25 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const AlcadaDescontoItemSchema = z.object({
|
|
||||||
codVendedor: z.number().int(),
|
|
||||||
codGrupo: z.number().int(),
|
|
||||||
limitePerc: z.number(),
|
|
||||||
nomeVendedor: z.string().nullable(),
|
|
||||||
});
|
|
||||||
export type AlcadaDescontoItem = z.infer<typeof AlcadaDescontoItemSchema>;
|
|
||||||
|
|
||||||
export const AlcadaDescontosResponseSchema = z.object({
|
|
||||||
descontos: z.array(AlcadaDescontoItemSchema),
|
|
||||||
});
|
|
||||||
export type AlcadaDescontosResponse = z.infer<typeof AlcadaDescontosResponseSchema>;
|
|
||||||
|
|
||||||
export const UpsertDescontoBodySchema = z.object({
|
|
||||||
codVendedor: z.number().int().positive(),
|
|
||||||
codGrupo: z.number().int().default(0),
|
|
||||||
limitePerc: z.number().min(0).max(100),
|
|
||||||
});
|
|
||||||
export type UpsertDescontoBody = z.infer<typeof UpsertDescontoBodySchema>;
|
|
||||||
|
|
||||||
export const PromocaoSchema = z.object({
|
export const PromocaoSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
descricao: z.string(),
|
descricao: z.string(),
|
||||||
@@ -71,7 +51,7 @@ export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>;
|
|||||||
|
|
||||||
// ─── Regras de Desconto ───────────────────────────────────────────────────────
|
// ─── Regras de Desconto ───────────────────────────────────────────────────────
|
||||||
// Regra criada pelo Gerente: % de desconto liberado por grupo e/ou subgrupo de
|
// Regra criada pelo Gerente: % de desconto liberado por grupo e/ou subgrupo de
|
||||||
// produto e/ou representante, com vigência. Campos nulos = "todos".
|
// produto, pauta de preços e/ou representante, com vigência. Campos nulos = "todos".
|
||||||
|
|
||||||
export const RegraDescontoSchema = z.object({
|
export const RegraDescontoSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
@@ -80,6 +60,8 @@ export const RegraDescontoSchema = z.object({
|
|||||||
grupo: z.string().nullable(),
|
grupo: z.string().nullable(),
|
||||||
codSubgrupo: z.number().int().nullable(),
|
codSubgrupo: z.number().int().nullable(),
|
||||||
subgrupo: z.string().nullable(),
|
subgrupo: z.string().nullable(),
|
||||||
|
idPauta: z.number().int().nullable(),
|
||||||
|
pauta: z.string().nullable(),
|
||||||
codVendedor: z.number().int().nullable(),
|
codVendedor: z.number().int().nullable(),
|
||||||
nomeVendedor: z.string().nullable(),
|
nomeVendedor: z.string().nullable(),
|
||||||
descPct: z.number(),
|
descPct: z.number(),
|
||||||
@@ -96,12 +78,14 @@ export const RegrasDescontoResponseSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>;
|
export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>;
|
||||||
|
|
||||||
// Criação aceita VÁRIOS grupos e/ou subgrupos — uma regra por alvo (grupos
|
// Criação aceita VÁRIOS grupos, subgrupos e/ou pautas — uma regra por alvo
|
||||||
// selecionados viram regras por grupo; subgrupos viram regras por subgrupo).
|
// (cada grupo vira regra por grupo; cada subgrupo, regra por subgrupo; cada
|
||||||
|
// pauta, regra por pauta). O representante escolhido é copiado em todas.
|
||||||
export const CreateRegraDescontoBodySchema = z.object({
|
export const CreateRegraDescontoBodySchema = z.object({
|
||||||
descricao: z.string().min(1).max(200),
|
descricao: z.string().min(1).max(200),
|
||||||
codGrupos: z.array(z.number().int()).max(100).optional(),
|
codGrupos: z.array(z.number().int()).max(100).optional(),
|
||||||
codSubgrupos: z.array(z.number().int()).max(100).optional(),
|
codSubgrupos: z.array(z.number().int()).max(100).optional(),
|
||||||
|
idPautas: z.array(z.number().int()).max(100).optional(),
|
||||||
codVendedor: z.number().int().nullable().optional(),
|
codVendedor: z.number().int().nullable().optional(),
|
||||||
descPct: z.number().min(0).max(100),
|
descPct: z.number().min(0).max(100),
|
||||||
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
@@ -114,6 +98,7 @@ export const UpdateRegraDescontoBodySchema = z.object({
|
|||||||
descricao: z.string().min(1).max(200).optional(),
|
descricao: z.string().min(1).max(200).optional(),
|
||||||
codGrupo: z.number().int().nullable().optional(),
|
codGrupo: z.number().int().nullable().optional(),
|
||||||
codSubgrupo: z.number().int().nullable().optional(),
|
codSubgrupo: z.number().int().nullable().optional(),
|
||||||
|
idPauta: z.number().int().nullable().optional(),
|
||||||
codVendedor: z.number().int().nullable().optional(),
|
codVendedor: z.number().int().nullable().optional(),
|
||||||
descPct: z.number().min(0).max(100).optional(),
|
descPct: z.number().min(0).max(100).optional(),
|
||||||
dataInicio: z
|
dataInicio: z
|
||||||
@@ -166,6 +151,8 @@ export const RegraVigenteSchema = z.object({
|
|||||||
grupo: z.string().nullable(),
|
grupo: z.string().nullable(),
|
||||||
codSubgrupo: z.number().int().nullable(),
|
codSubgrupo: z.number().int().nullable(),
|
||||||
subgrupo: z.string().nullable(),
|
subgrupo: z.string().nullable(),
|
||||||
|
idPauta: z.number().int().nullable(),
|
||||||
|
pauta: z.string().nullable(),
|
||||||
descPct: z.number(),
|
descPct: z.number(),
|
||||||
dataFim: z.string(),
|
dataFim: z.string(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -720,8 +720,9 @@ CREATE TABLE IF NOT EXISTS sar.promocoes (
|
|||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- Regras de Desconto (Politicas Comerciais - criadas pelo Gerente/Admin)
|
-- Regras de Desconto (Politicas Comerciais - criadas pelo Gerente/Admin)
|
||||||
-- Desconto liberado por grupo/subgrupo de produto e/ou representante, com
|
-- Desconto liberado por grupo/subgrupo de produto, pauta de precos e/ou
|
||||||
-- vigencia. Campos nulos = "todos" (ex: cod_vendedor NULL vale para todo rep).
|
-- representante, com vigencia. Campos nulos = "todos" (ex: cod_vendedor NULL
|
||||||
|
-- vale para todo rep; id_pauta NULL vale para qualquer pauta).
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS sar.regras_desconto (
|
CREATE TABLE IF NOT EXISTS sar.regras_desconto (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -730,6 +731,7 @@ CREATE TABLE IF NOT EXISTS sar.regras_desconto (
|
|||||||
cod_grupo INTEGER,
|
cod_grupo INTEGER,
|
||||||
cod_subgrupo INTEGER,
|
cod_subgrupo INTEGER,
|
||||||
cod_vendedor INTEGER,
|
cod_vendedor INTEGER,
|
||||||
|
id_pauta INTEGER,
|
||||||
desc_pct NUMERIC(5,2) NOT NULL,
|
desc_pct NUMERIC(5,2) NOT NULL,
|
||||||
data_inicio DATE NOT NULL,
|
data_inicio DATE NOT NULL,
|
||||||
data_fim DATE NOT NULL,
|
data_fim DATE NOT NULL,
|
||||||
|
|||||||
Reference in New Issue
Block a user