Compare commits

21 Commits

Author SHA1 Message Date
9107b0a825 fix(api): restringe produtos e precos a pauta vinculada ao representante
Rep so enxerga e vende itens das pautas do cadastro dele (cod_pauta1..6):

- catalog: lista de produtos exige pauta e recusa pauta de terceiro
- orders: create/update validam pauta do pedido e todo item na pauta
  com preco > 0, fechando preco forjado e produto fora da pauta

Gestor/admin seguem navegando livremente. O teto de desconto continua
em assertAlcadaItens; aqui so ancora o preco de tabela.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:29:07 +00:00
9b855f63b2 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>
2026-07-23 21:12:00 +00:00
1ce8d96fea refactor(api,web): remove fila de aprovacao e consolida painel do supervisor
Alcada agora e bloqueio duro no pedido (sem fluxo de aprovar/recusar):
remove ApprovalQueuePage, CarteirePage e rota /aprovacoes, absorvendo
carteira e acompanhamento de pedidos dentro do SupervisorPainel.
Colunas de desempenho por representante extraidas para componente
compartilhado (rep-performance-columns) entre gerente e supervisor.
Ajusta dashboard e orders (controller/service/contract) para o novo fluxo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:34:39 +00:00
ebf68523dd feat(api,web): card condicoes especiais no painel do representante
Card expansivel no painel do rep com promocoes e regras de desconto
vigentes hoje para o representante logado. Endpoints vigentes agora
resolvem nomes de produto/grupo/subgrupo; hooks com refetch de 60s
para a condicao sumir logo apos encerrar ou inativar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:31:55 +00:00
78612d59bd feat(api,web): mapa do brasil com faturamento por estado no painel gerencial
- KPIs financeiros agrupados (faturamento, meta, atingimento, falta) em metade da primeira dobra
- MapaBrasilCard: choropleth por UF (@svg-maps/brazil), tooltip, ranking lateral e modal Detalhar com representantes por estado
- API: faturamentoPorUf no /dashboard/manager (pedido -> cliente -> municipio -> UF)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:22:21 +00:00
dfdcca4674 feat(web): switch ativar/desativar em promocoes e regras de desconto
Coluna "Ativa" com switch nas tabelas das duas abas (liga/desliga na
hora, sem abrir o modal) e campo Ativa no modal de edicao. Desativada,
a promocao/regra sai imediatamente do catalogo e da alcada.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:57:16 +00:00
76cd90ae95 feat(api,web): politicas comerciais com multi-selecao de produtos, grupos e subgrupos
- Promocao: produtos com busca remota por codigo/descricao (multi) e
  grupos com busca por nome/codigo (multi) - backend cria uma promocao
  por alvo na mesma transacao ({ids})
- Regra de desconto: grupos e subgrupos multi (subgrupos filtram pelos
  grupos escolhidos) - uma regra por alvo
- Edicao continua por linha, com selects de busca no lugar dos inputs
  de texto livre
- Modais maiores (760px de largura, corpo com altura minima e rolagem)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:52:58 +00:00
756e56d8d3 feat(api,web): catalogo na area do gerente com todas as pautas e precos
- Menu Catalogo no cockpit do gerente (rota /catalogo existente)
- /catalog/pautas: gestor recebe TODAS as pautas ativas com contagem de
  reps que usam cada uma (rep segue vendo so as suas cod_pauta1..6)
- Detalhe do produto: gestor ve o preco em todas as pautas (rep segue
  restrito as dele)
- Select de pauta mostra "N reps" quando disponivel

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:40:34 +00:00
725a94ede1 feat(api,web): meta de positivacao diaria salva no servidor com botao Salvar
Antes a meta vivia no localStorage do navegador (perdia em outro
dispositivo). Agora: coluna meta_positivacao_dia em config_empresa,
PUT /dashboard/manager/meta-positivacao-dia (gerente/admin), meta vem
no dashboard e o card tem botao Salvar (desabilitado sem mudanca).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:33:38 +00:00
b6b16e1e21 feat(api,web): positivacao de novos clientes por representante no painel gerencial
- novosClientes por rep no dashboard do gerente: clientes da carteira
  cuja PRIMEIRA compra (historico todo do ERP) caiu no periodo
- Coluna "Novos no mes" (tag verde +N, ordenavel) na tabela de
  positivacao + total de novos clientes no cabecalho do card

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:23:13 +00:00
54ef63f5c7 feat(web): sugestao de comprados anteriormente fechada por padrao com banner de destaque
Painel do historico do cliente no lancamento agora abre sempre fechado,
com banner ambar chamativo ("Este cliente ja comprou N produtos com
voce") e botao Ver produtos - o rep le e escolhe abrir, em vez de a
lista ocupar a tela direto.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:15:18 +00:00
2ff1f9328a feat(api,web): peso total (kg) e cubagem (m3) do pedido na impressao
- vw_produtos passa a expor vol_m3 (coluna nova no FIM da view - CREATE
  OR REPLACE exige; aplicada no dev)
- PedidoDetail calcula pesoTotal/m3Total (qtd x peso_liquido/vol_m3),
  tanto para pedidos SAR quanto ERP
- Impressao mostra "Peso total: X kg" e "Volume: Y m3" ao lado dos
  totais (some quando zerado)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:59:27 +00:00
8a5db57f35 feat(api,web): logo da empresa na impressao e cabecalho A4 compacto
- Tabela sar.config_empresa (logo em data URL por empresa matriz) com
  migration idempotente, espelho no schema SQL e GRANT no provision
- PUT /catalog/company/logo (gerente/admin) e logoBase64 no GET
  /catalog/company - vale para a impressao de todos os representantes
- Nova tela /ger/config "Configuracoes" com upload/preview/remocao do
  logo (PNG/JPG/WebP ate 500KB)
- Cabecalho da impressao redesenhado: logo a esquerda, dados da empresa
  condensados em 3 linhas, numero/status/data compactos a direita -
  ocupa ~metade da altura anterior no A4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:48:05 +00:00
b33293e79b fix(web): clique nos 3 pontos da lista de pedidos nao abre mais o detalhe junto
O clique no botao de acoes propagava para o onClick da linha, abrindo o
detalhe por cima do menu. stopPropagation no bloco de acoes: o menu abre
sozinho e so a opcao escolhida executa algo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:27:24 +00:00
1e11de7f31 feat(api,web): edicao de orcamento e cancelamento com motivo
- PATCH /orders/:id edita orcamento (situa 0): substitui itens,
  recalcula totais, valida alcada do dono do pedido; depois de
  transmitido nao e mais editavel (400)
- Menu "Editar orcamento" abre o lancamento pre-carregado
  (/pedidos/novo?editar=id) com cliente, itens, pauta, forma de
  pagamento, obs e endereco; salvar faz PATCH em vez de POST
- Detalhe do pedido passa a incluir idPauta/codFormapag e
  codGrupo/codSubgrupo por item (teto de desconto na edicao)
- Cancelar pedido abre card com motivo (select) + descricao livre;
  gravado em motivo_recusa e no historico (ASCII: banco ERP e LATIN1,
  em-dash quebrava o UPDATE)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:16:55 +00:00
35fd773f14 feat(api,web): alcada bloqueia ja a criacao do orcamento, nao so a transmissao
- create() valida desconto global e por item (mesma regra do transmit):
  Concluir com desconto acima do teto retorna 400 com a mensagem do
  item que passou; transmit segue validando (defesa em profundidade)
- Botao Concluir desabilita em tempo real com o motivo quando algum
  item esta acima do teto; caminho offline tambem barrado no submit
- Mensagem de alcada generalizada ("para continuar")

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:56:13 +00:00
673130d60b feat(web,api): teto de desconto em tempo real no carrinho do pedido
- GET /politicas/alcada: limites de desconto do proprio usuario logado
- Carrinho (desktop e mobile) mostra "teto: X%" sob o campo de desconto
  de cada item - max(alcada do grupo, promocao, regra vigente), espelho
  do assertAlcadaItens; acima do teto o campo fica vermelho com aviso de
  que a transmissao sera bloqueada

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:46:58 +00:00
43daf7ad6d fix(api): promocao/regra de desconto e teto da alcada, nao folga somada
Com alcada base 5% e regra de 15%, o limite do item era 5+15=20% e um
desconto de 18% passava na transmissao. Agora vale o MAIOR entre alcada
do grupo, promocao e regra vigente: regra de 15% bloqueia acima de 15%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:40:06 +00:00
d5279576d7 fix(web): crypto.randomUUID indisponivel fora de contexto seguro quebrava a transmissao
Acesso pelo IP da rede (http://192.168...) nao e contexto seguro e o
navegador nao expoe crypto.randomUUID, derrubando a conclusao do pedido.
Helper randomUUID() cai para UUID v4 via crypto.getRandomValues.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:19:41 +00:00
36430fde63 feat(web): desconto de regra nao vem mais pre-preenchido no carrinho
Item entra com 0% mesmo com promocao/regra vigente: o selo de condicao
comercial avisa o rep, que concede o desconto apenas se o cliente
negociar. A alcada no backend segue aceitando ate o % da regra.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:00:06 +00:00
afa6171919 Merge branch 'fix/rep-authz-e-front': regras de desconto, usuarios dev por papel e escopo do supervisor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:58:19 +00:00
54 changed files with 3838 additions and 2298 deletions

View File

@@ -0,0 +1,10 @@
-- CreateTable: sar.config_empresa (configuracoes por empresa - logo da impressao)
-- logo_base64: data URL (data:image/png;base64,...) usado no cabecalho do pedido.
-- Definicao espelha scripts/sar-erp-schema.sql: provision-client.ts roda o schema SQL
-- antes de `prisma migrate deploy`, entao esta migration precisa ser idempotente.
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
CREATE TABLE IF NOT EXISTS sar.config_empresa (
id_empresa INTEGER PRIMARY KEY,
logo_base64 TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,5 @@
-- Meta diaria de positivacao do painel gerencial - salva por empresa para
-- nao depender do navegador do gerente.
-- Idempotente (provision roda o schema espelho antes do migrate deploy).
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
ALTER TABLE sar.config_empresa ADD COLUMN IF NOT EXISTS meta_positivacao_dia INTEGER;

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")
@@ -195,6 +197,20 @@ model RegraDesconto {
@@map("regras_desconto")
}
// ─── ConfigEmpresa ───────────────────────────────────────────────────────────
//
// Configurações por empresa. logo_base64: data URL exibido no cabeçalho da
// impressão do pedido de todos os representantes.
model ConfigEmpresa {
idEmpresa Int @id @map("id_empresa")
logoBase64 String? @map("logo_base64")
metaPositivacaoDia Int? @map("meta_positivacao_dia")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("config_empresa")
}
// ─── Oportunidade (Funil de Vendas) ──────────────────────────────────────────
//
// Oportunidade de venda do representante. Pode referenciar cliente ERP (id_cliente)

View File

@@ -1,8 +1,20 @@
import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from '@nestjs/common';
import {
Body,
Controller,
Get,
HttpCode,
NotFoundException,
Param,
ParseIntPipe,
Put,
Query,
} from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import {
ProdutoListQuerySchema,
UpdateLogoBodySchema,
type EmpresaInfo,
type UpdateLogoBody,
type FormaPagamento,
type Municipio,
type Pauta,
@@ -13,6 +25,7 @@ import {
import { CatalogService } from './catalog.service';
class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {}
class UpdateLogoDto extends createZodDto(UpdateLogoBodySchema) {}
// ADR 0006 revogado: UUID → Int para ID de produto. Sync removido (ERP direto via view).
@@ -40,6 +53,12 @@ export class CatalogController {
return this.catalog.company();
}
@Put('company/logo')
@HttpCode(204)
updateLogo(@Body() body: UpdateLogoDto): Promise<void> {
return this.catalog.updateLogo(body as unknown as UpdateLogoBody);
}
@Get()
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery;

View File

@@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common';
import { ForbiddenException, Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
EmpresaInfo,
UpdateLogoBody,
FormaPagamento,
Municipio,
Pauta,
@@ -101,6 +102,8 @@ export class CatalogService {
const r = rows[0];
if (!r) throw new Error(`Empresa matriz ${idEmpresa} não encontrada`);
const config = await prisma.configEmpresa.findUnique({ where: { idEmpresa } });
const clean = (v: string | null) => {
const t = (v ?? '').trim();
return t === '' ? null : t;
@@ -120,9 +123,28 @@ export class CatalogService {
cep: clean(r.cep),
telefone: clean(r.telefone),
email: clean(r.email),
logoBase64: config?.logoBase64 ?? null,
};
}
// Logo do cabeçalho da impressão — só gerente/admin altera; vale para todos
// os representantes da empresa (gravado na matriz, mesma fonte do company()).
async updateLogo(body: UpdateLogoBody): Promise<void> {
const role = this.cls.get('role') ?? 'rep';
if (role !== 'manager' && role !== 'admin') {
throw new ForbiddenException('Apenas gerentes podem alterar o logo da empresa');
}
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
await prisma.configEmpresa.upsert({
where: { idEmpresa },
create: { idEmpresa, logoBase64: body.logoBase64 },
update: { logoBase64: body.logoBase64 },
});
}
async municipios(q?: string): Promise<Municipio[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -158,29 +180,49 @@ export class CatalogService {
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const role = this.cls.get('role') ?? 'rep';
interface PautaRow {
id_pauta: number;
codigo: number;
descricao: string;
qtd_reps?: string;
}
const rows = await prisma.$queryRawUnsafe<PautaRow[]>(`
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao
FROM vw_pautas pa
JOIN vw_representantes r ON pa.codigo IN (
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
)
WHERE pa.id_empresa = ${idEmpresa}
AND pa.ativo = 1
AND r.codigo = ${codVendedor}
ORDER BY pa.codigo
`);
// Rep vê só as pautas atribuídas a ele (cod_pauta1..6); gestor vê TODAS as
// pautas ativas, com a contagem de reps que usam cada uma.
const rows =
role === 'rep'
? await prisma.$queryRawUnsafe<PautaRow[]>(`
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao
FROM vw_pautas pa
JOIN vw_representantes r ON pa.codigo IN (
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
)
WHERE pa.id_empresa = ${idEmpresa}
AND pa.ativo = 1
AND r.codigo = ${codVendedor}
ORDER BY pa.codigo
`)
: await prisma.$queryRawUnsafe<PautaRow[]>(`
SELECT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao,
COUNT(DISTINCT r.codigo)::text AS qtd_reps
FROM vw_pautas pa
LEFT JOIN vw_representantes r ON pa.codigo IN (
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
)
WHERE pa.id_empresa = ${idEmpresa}
AND pa.ativo = 1
GROUP BY pa.id_pauta, pa.codigo, pa.descricao
ORDER BY pa.codigo
`);
return rows.map((r) => ({
idPauta: Number(r.id_pauta),
codigo: Number(r.codigo),
descricao: r.descricao,
...(r.qtd_reps != null && { qtdReps: Number(r.qtd_reps) }),
}));
}
@@ -188,10 +230,22 @@ export class CatalogService {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
const role = this.cls.get('role') ?? 'rep';
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const { q, codGrupo, idPauta, page, limit } = query;
const offset = (page - 1) * limit;
// PGD-AUTHZ: o rep só enxerga produtos/preços das pautas vinculadas ao
// cadastro dele (cod_pauta1..6). Sem pauta selecionada → lista vazia (precisa
// escolher a pauta primeiro); pauta de terceiro → recusa. Gestor/admin
// navegam livremente (preço base ou pauta que escolherem).
if (role === 'rep') {
if (idPauta == null) return { data: [], total: 0, page, limit };
await this.assertPautaDoRep(idPauta, codVendedor, idEmpresa);
}
const grupoFilter = codGrupo != null ? `AND p.cod_grupo = ${codGrupo}` : '';
const searchFilter = q
? `AND (p.descricao ILIKE '%${escSql(q)}%' OR p.codigo ILIKE '%${escSql(q)}%')`
@@ -268,6 +322,33 @@ export class CatalogService {
return { data: rows.map((p) => this.mapRow(p, p.vl_preco1)), total, page, limit };
}
// Confere que a pauta pertence às pautas do rep (cod_pauta1..6 do cadastro).
// codVendedor/idPauta são Int (JWT/query coercida) — interpolação segura.
private async assertPautaDoRep(
idPauta: number,
codVendedor: number,
idMatriz: number,
): Promise<void> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const rows = await prisma.$queryRawUnsafe<{ id_pauta: number }[]>(`
SELECT DISTINCT pa.id_pauta
FROM vw_pautas pa
JOIN vw_representantes r ON pa.codigo IN (
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
)
WHERE pa.id_empresa = ${idMatriz}
AND pa.ativo = 1
AND r.codigo = ${codVendedor}
AND pa.id_pauta = ${Number(idPauta)}
LIMIT 1
`);
if (!rows[0]) {
throw new ForbiddenException('Pauta de preços não vinculada ao seu cadastro');
}
}
private mapRow(p: ProdutoRow, preco: string): ProdutoSummary {
return {
idErp: Number(p.id_erp),
@@ -337,12 +418,16 @@ export class CatalogService {
JOIN vw_pautas pa ON pa.id_pauta = pp.id_pauta
AND pa.id_empresa = ${idEmpresa}
AND pa.ativo = 1
JOIN vw_representantes r ON pa.codigo IN (
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
)
${
// Rep vê só as pautas dele; gestor vê os preços de TODAS as pautas
(this.cls.get('role') ?? 'rep') === 'rep'
? `JOIN vw_representantes r ON pa.codigo IN (
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
) AND r.codigo = ${codVendedor}`
: ''
}
WHERE pp.id_produto = ${idErp}
AND r.codigo = ${codVendedor}
AND pp.preco1 > 0
ORDER BY pa.codigo
`),

View File

@@ -1,9 +1,28 @@
import { Controller, ForbiddenException, Get, Query } from '@nestjs/common';
import {
Body,
Controller,
ForbiddenException,
Get,
HttpCode,
ParseIntPipe,
Put,
Query,
} from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface';
import { createZodDto } from 'nestjs-zod';
import {
SaveMetaPositivacaoBodySchema,
type RepDashboard,
type RepInativosDetail,
type SupervisorDashboard,
type ManagerDashboard,
type SaveMetaPositivacaoBody,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { DashboardService } from './dashboard.service';
class SaveMetaPositivacaoDto extends createZodDto(SaveMetaPositivacaoBodySchema) {}
@Controller({ path: 'dashboard' })
export class DashboardController {
constructor(
@@ -26,9 +45,29 @@ 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')
supervisorInativos(
@Query('codVendedor', ParseIntPipe) codVendedor: number,
): Promise<RepInativosDetail> {
this.assertGestor();
return this.dashboard.supervisorInativos(codVendedor);
}
@Put('manager/meta-positivacao-dia')
@HttpCode(204)
saveMetaPositivacaoDia(@Body() body: SaveMetaPositivacaoDto): Promise<void> {
this.assertGestor();
const parsed = SaveMetaPositivacaoBodySchema.parse(body) as SaveMetaPositivacaoBody;
return this.dashboard.saveMetaPositivacaoDia(parsed.meta);
}
@Get('manager')

View File

@@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common';
import { ForbiddenException, Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
RepDashboard,
RepInativosDetail,
SupervisorDashboard,
ManagerDashboard,
RankingRep,
@@ -11,8 +12,6 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util';
// Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado
// Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
const SITUA_PENDENTE = 1;
// vw_metas.tipo (gestao.metavenda): GL = meta global, GR = meta por grupo.
const TIPO_META_GLOBAL = 'GL';
@@ -418,6 +417,18 @@ export class DashboardService {
dia: string;
clientes: string;
}
interface NovosClientesRow {
cod_vendedor: number;
novos: string;
}
interface UfRepRow {
uf: string;
cod_vendedor: number;
nome_vendedor: string | null;
pedidos: string;
clientes: string;
faturamento: string;
}
const [
statsRows,
@@ -428,6 +439,8 @@ export class DashboardService {
metaGrupoRows,
realizadoGrupoRows,
positivacaoDiariaRows,
novosClientesRows,
ufRepRows,
] = await Promise.all([
prisma.$queryRawUnsafe<TotalRow[]>(`
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
@@ -535,6 +548,46 @@ export class DashboardService {
GROUP BY dt_pedido::date
ORDER BY dia
`),
prisma.$queryRawUnsafe<NovosClientesRow[]>(`
SELECT c.cod_vendedor, COUNT(*)::text AS novos
FROM (
SELECT id_cliente, MIN(dt_pedido) AS primeira_compra
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
GROUP BY id_cliente
) f
JOIN (SELECT DISTINCT id_cliente, cod_vendedor FROM vw_clientes) c
ON c.id_cliente = f.id_cliente
WHERE f.primeira_compra >= '${monthStartStr}'
AND f.primeira_compra <= '${monthEndStr}'
GROUP BY c.cod_vendedor
`),
// Faturamento por UF (município do cliente) e representante.
// vw_clientes pode repetir o cliente por empresa → LATERAL pega 1 município.
prisma.$queryRawUnsafe<UfRepRow[]>(`
SELECT COALESCE(NULLIF(TRIM(loc.uf), ''), 'ND') AS uf,
p.cod_vendedor,
(SELECT r.nome FROM vw_representantes r
WHERE r.codigo = p.cod_vendedor LIMIT 1) AS nome_vendedor,
COUNT(*)::text AS pedidos,
COUNT(DISTINCT p.id_cliente)::text AS clientes,
COALESCE(SUM(p.total), 0)::text AS faturamento
FROM vw_pedidos_erp p
LEFT JOIN LATERAL (
SELECT mu.uf::text AS uf
FROM vw_clientes c
JOIN sar.vw_municipios mu ON mu.id_municipio = c.id_municipio
WHERE c.id_cliente = p.id_cliente
LIMIT 1
) loc ON true
WHERE p.id_empresa = ${idEmpresa}
AND p.situa NOT IN (1, 5)
AND p.dt_pedido >= '${monthStartStr}'
AND p.dt_pedido <= '${monthEndStr}'
GROUP BY 1, p.cod_vendedor
ORDER BY 1, SUM(p.total) DESC
`),
]);
const pedidosMes = Number(statsRows[0]?.count ?? 0);
@@ -585,6 +638,14 @@ export class DashboardService {
const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size;
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0);
// Meta diária de positivação salva pelo gerente (config da empresa matriz)
const config = await prisma.configEmpresa.findUnique({
where: { idEmpresa: idEmpresaMatriz },
});
const novosMap = new Map(
novosClientesRows.map((n) => [Number(n.cod_vendedor), Number(n.novos)]),
);
const positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => {
const total = Number(r.total_clientes);
const positivados = Number(r.clientes_positivados);
@@ -594,9 +655,42 @@ export class DashboardService {
totalClientes: total,
clientesPositivados: positivados,
pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0,
novosClientes: novosMap.get(Number(r.cod_vendedor)) ?? 0,
};
});
// Agrega as linhas UF×rep em UF → { totais, reps[] } ordenado por faturamento.
const porUf = new Map<
string,
{ faturamento: number; pedidos: number; clientes: number; reps: UfRepRow[] }
>();
for (const r of ufRepRows) {
const acc = porUf.get(r.uf) ?? { faturamento: 0, pedidos: 0, clientes: 0, reps: [] };
acc.faturamento += Number(r.faturamento);
acc.pedidos += Number(r.pedidos);
acc.clientes += Number(r.clientes);
acc.reps.push(r);
porUf.set(r.uf, acc);
}
const faturamentoPorUf = [...porUf.entries()]
.map(([uf, t]) => ({
uf,
faturamento: t.faturamento,
pedidos: t.pedidos,
clientes: t.clientes,
pct: faturamentoMes > 0 ? Math.round((t.faturamento / faturamentoMes) * 1000) / 10 : 0,
reps: t.reps
.map((r) => ({
codVendedor: Number(r.cod_vendedor),
nomeVendedor: r.nome_vendedor ?? null,
faturamento: Number(r.faturamento),
pedidos: Number(r.pedidos),
clientes: Number(r.clientes),
}))
.sort((a, b) => b.faturamento - a.faturamento),
}))
.sort((a, b) => b.faturamento - a.faturamento);
return {
faturamentoMes,
pedidosMes,
@@ -611,35 +705,51 @@ export class DashboardService {
dia: r.dia,
clientes: Number(r.clientes),
})),
faturamentoPorUf,
metaPositivacaoDia: config?.metaPositivacaoDia ?? null,
syncedAt: now.toISOString(),
};
}
async supervisorDashboard(): Promise<SupervisorDashboard> {
// Salva a meta diária de positivação (config_empresa) — só gerente/admin.
async saveMetaPositivacaoDia(meta: number): Promise<void> {
const role = this.cls.get('role') ?? 'rep';
if (role !== 'manager' && role !== 'admin') {
throw new ForbiddenException('Apenas gerentes podem salvar a meta de positivação');
}
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
const value = meta > 0 ? meta : null;
await prisma.configEmpresa.upsert({
where: { idEmpresa },
create: { idEmpresa, metaPositivacaoDia: value },
update: { metaPositivacaoDia: value },
});
}
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(',')})` : '');
// Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP)
const approvalQueue = await prisma.pedido.findMany({
where: {
idEmpresa,
situa: SITUA_PENDENTE,
...(team ? { codVendedor: { in: team } } : {}),
},
orderBy: { dtPedido: 'asc' },
take: 50,
});
// Pedidos do dia — lê do ERP (situa != 5=Cancelado)
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayStr = todayStart.toISOString().slice(0, 10);
@@ -670,7 +780,152 @@ export class DashboardService {
`),
]);
// Top 3 reps com mais clientes inativos (>30 dias sem compra no ERP)
// Métricas do mês corrente da equipe — mesmas contas do painel gerencial,
// escopadas pelos códigos da equipe (gerente/admin sem filtro = empresa toda).
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
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);
interface RankingRow {
cod_vendedor: number;
nome: string | null;
pedidos: string;
clientes_atendidos: string;
faturamento: string;
meta_valor: string | null;
}
interface MetaTotalRow {
meta_total: string;
}
interface PositivacaoRow {
cod_vendedor: number;
nome_vendedor: string | null;
total_clientes: string;
clientes_positivados: string;
}
interface NovosClientesRow {
cod_vendedor: number;
novos: string;
}
const [mesRows, rankingRows, metaTotalRows, positivacaoRows, novosClientesRows] =
await Promise.all([
prisma.$queryRawUnsafe<DayRow[]>(`
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
AND dt_pedido >= '${monthStartStr}'
AND dt_pedido <= '${monthEndStr}'
${teamSqlFilter('cod_vendedor')}
`),
prisma.$queryRawUnsafe<RankingRow[]>(`
SELECT p.cod_vendedor,
(SELECT r.nome FROM vw_representantes r
WHERE r.codigo = p.cod_vendedor LIMIT 1) AS nome,
COUNT(*)::text AS pedidos,
COUNT(DISTINCT p.id_cliente)::text AS clientes_atendidos,
COALESCE(SUM(p.total), 0)::text AS faturamento,
(SELECT SUM(m.valor)::text FROM sar.vw_metas m
WHERE m.id_empresa = ${idEmpresaMatriz}
AND m.cod_vendedor = p.cod_vendedor
AND m.ano = ${year}
AND m.mes = ${month}
AND TRIM(m.tipo) = '${TIPO_META_GRUPO}') AS meta_valor
FROM vw_pedidos_erp p
WHERE p.id_empresa = ${idEmpresa}
AND p.situa NOT IN (1, 5)
AND p.dt_pedido >= '${monthStartStr}'
AND p.dt_pedido <= '${monthEndStr}'
${teamSqlFilter('p.cod_vendedor')}
GROUP BY p.cod_vendedor
ORDER BY SUM(p.total) DESC
LIMIT 10
`),
prisma.$queryRawUnsafe<MetaTotalRow[]>(`
SELECT COALESCE(SUM(valor), 0)::text AS meta_total
FROM sar.vw_metas
WHERE id_empresa = ${idEmpresaMatriz}
AND ano = ${year}
AND mes = ${month}
AND TRIM(tipo) = '${TIPO_META_GRUPO}'
${teamSqlFilter('cod_vendedor')}
`),
prisma.$queryRawUnsafe<PositivacaoRow[]>(`
SELECT
c.cod_vendedor,
(SELECT r.nome FROM vw_representantes r WHERE r.codigo = c.cod_vendedor LIMIT 1) AS nome_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 clientes_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
${teamSqlFilter('c.cod_vendedor')}
GROUP BY c.cod_vendedor
HAVING COUNT(DISTINCT c.id_cliente) > 0
ORDER BY COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END) DESC
`),
prisma.$queryRawUnsafe<NovosClientesRow[]>(`
SELECT c.cod_vendedor, COUNT(*)::text AS novos
FROM (
SELECT id_cliente, MIN(dt_pedido) AS primeira_compra
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
GROUP BY id_cliente
) f
JOIN (SELECT DISTINCT id_cliente, cod_vendedor FROM vw_clientes) c
ON c.id_cliente = f.id_cliente
WHERE f.primeira_compra >= '${monthStartStr}'
AND f.primeira_compra <= '${monthEndStr}'
${teamSqlFilter('c.cod_vendedor')}
GROUP BY c.cod_vendedor
`),
]);
const pedidosMes = Number(mesRows[0]?.count ?? 0);
const faturamentoMes = Number(mesRows[0]?.total ?? 0);
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0);
const rankingReps: RankingRep[] = rankingRows.map((r) => {
const fat = Number(r.faturamento);
const meta = r.meta_valor ? Number(r.meta_valor) : 0;
return {
codVendedor: Number(r.cod_vendedor),
nomeVendedor: r.nome ?? null,
pedidos: Number(r.pedidos),
clientesAtendidos: Number(r.clientes_atendidos),
faturamento: fat,
pctMeta: meta > 0 ? Math.round((fat / meta) * 100) : 0,
};
});
const novosMap = new Map(
novosClientesRows.map((n) => [Number(n.cod_vendedor), Number(n.novos)]),
);
const positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => {
const total = Number(r.total_clientes);
const positivados = Number(r.clientes_positivados);
return {
codVendedor: Number(r.cod_vendedor),
nomeVendedor: r.nome_vendedor ?? null,
totalClientes: total,
clientesPositivados: positivados,
pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0,
novosClientes: novosMap.get(Number(r.cod_vendedor)) ?? 0,
};
});
// Reps com mais clientes inativos (>30 dias sem compra no ERP)
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(`
SELECT inativos.cod_vendedor,
@@ -693,50 +948,10 @@ export class DashboardService {
) inativos
GROUP BY inativos.cod_vendedor
ORDER BY COUNT(*) DESC
LIMIT 3
LIMIT 10
`);
// Resolve nomes de cliente e representante da fila (pedidos SAR só têm os códigos)
const repCods = [...new Set(approvalQueue.map((p) => p.codVendedor))];
const cliIds = [...new Set(approvalQueue.map((p) => p.idCliente))];
const [repNameRows, cliNameRows] = await Promise.all([
repCods.length
? prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${repCods.join(',')})`,
)
: Promise.resolve([]),
cliIds.length
? prisma.$queryRawUnsafe<
{ id_cliente: number; nome: string | null; razao: string | null }[]
>(
`SELECT id_cliente, nome, razao FROM vw_clientes WHERE id_cliente IN (${cliIds.join(',')})`,
)
: Promise.resolve([]),
]);
const repNameMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
const cliNameMap = new Map(
cliNameRows.map((c) => [Number(c.id_cliente), { nome: c.nome, razao: c.razao }]),
);
const mapPedido = (o: (typeof approvalQueue)[number]) => ({
id: o.id,
numPedSar: o.numPedSar,
idCliente: o.idCliente,
nomeCliente: cliNameMap.get(o.idCliente)?.nome ?? null,
razaoCliente: cliNameMap.get(o.idCliente)?.razao ?? null,
codVendedor: o.codVendedor,
nomeVendedor: repNameMap.get(o.codVendedor) ?? null,
situa: o.situa,
dtPedido: o.dtPedido.toISOString(),
total: String(o.total),
descontoPerc: String(o.descontoPerc),
obs: o.obs,
createdAt: o.createdAt.toISOString(),
fonte: 'sar' as const,
});
return {
approvalQueue: approvalQueue.map(mapPedido),
pedidosDia: {
count: Number(todayRows[0]?.count ?? 0),
total: Number(todayRows[0]?.total ?? 0),
@@ -748,7 +963,93 @@ export class DashboardService {
nomeVendedor: r.nome_vendedor ?? null,
inativosCount: parseInt(r.inativos_count, 10),
})),
equipeMes: {
faturamento: faturamentoMes,
pedidos: pedidosMes,
ticketMedio: pedidosMes > 0 ? faturamentoMes / pedidosMes : 0,
metaTotal,
pctMeta: metaTotal > 0 ? Math.round((faturamentoMes / metaTotal) * 100) : 0,
},
rankingReps,
positivacaoReps,
syncedAt: now.toISOString(),
};
}
// Clientes inativos (>30 dias sem compra) de um representante — detalhe do
// card "Inativos por Rep". Supervisor só consulta reps da própria equipe;
// gerente/admin consultam qualquer rep.
async supervisorInativos(codVendedor: number): Promise<RepInativosDetail> {
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 role = this.cls.get('role');
const userId = this.cls.get('userId');
if (role === 'supervisor') {
const team = await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0);
if (!team.includes(codVendedor)) {
throw new ForbiddenException('Representante fora da sua equipe');
}
}
// Mesmo corte do resumo inativosPorRep: sem compra há mais de 30 dias.
const cutoffStr = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const rows = await prisma.$queryRawUnsafe<NaoPositivadoRow[]>(`
WITH ultimo_pedido AS (
SELECT id_cliente, MAX(dt_pedido) AS dt_max
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa != 5
GROUP BY id_cliente
)
SELECT
c.id_cliente,
TRIM(c.nome) AS nome,
TRIM(c.razao) AS razao,
TO_CHAR(up.dt_max, 'YYYY-MM-DD') AS dt_ultimo_pedido,
(CURRENT_DATE - up.dt_max::date) AS dias_sem_pedido,
(up.dt_max IS NOT NULL) AS comprou_antes,
ct.whatsapp
FROM vw_clientes c
LEFT JOIN ultimo_pedido up ON up.id_cliente = c.id_cliente
LEFT JOIN LATERAL (
SELECT TRIM(whatsapp) AS whatsapp
FROM sar.vw_contatos
WHERE id_corrent = c.id_cliente
AND whatsapp IS NOT NULL AND TRIM(whatsapp) != ''
AND ativo = 1
LIMIT 1
) ct ON true
WHERE c.cod_vendedor = ${codVendedor}
AND c.ativo = 1
AND (up.dt_max IS NULL OR up.dt_max < '${cutoffStr}')
ORDER BY up.dt_max ASC NULLS FIRST
`);
const nomeRows = await prisma.$queryRawUnsafe<{ nome: string | null }[]>(
`SELECT nome FROM vw_representantes WHERE codigo = ${codVendedor} LIMIT 1`,
);
// Deduplica por idCliente — vw_clientes pode repetir o cliente por empresa.
const seen = new Set<number>();
const clientes = rows
.filter((c) => {
const id = Number(c.id_cliente);
if (seen.has(id)) return false;
seen.add(id);
return true;
})
.map((c) => ({
idCliente: Number(c.id_cliente),
nome: c.nome,
razao: c.razao ?? null,
diasSemPedido: c.dias_sem_pedido != null ? Number(c.dias_sem_pedido) : 999,
ultimoPedido: c.dt_ultimo_pedido ?? null,
comprouAntes: Boolean(c.comprou_antes),
whatsapp: c.whatsapp ?? null,
}));
return { codVendedor, nomeVendedor: nomeRows[0]?.nome ?? null, clientes };
}
}

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

@@ -1,7 +1,6 @@
import {
Body,
Controller,
ForbiddenException,
Get,
HttpCode,
Param,
@@ -11,38 +10,33 @@ import {
Post,
Query,
} from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { createZodDto } from 'nestjs-zod';
import {
AprovarPedidoSchema,
CancelPedidoSchema,
CreatePedidoSchema,
UpdatePedidoSchema,
PedidoErpConsultaQuerySchema,
PedidoListQuerySchema,
RecusarPedidoSchema,
type AprovarPedido,
type CancelPedido,
type CreatePedido,
type UpdatePedido,
type PedidoDetail,
type PedidoErpConsultaQuery,
type PedidoErpConsultaResponse,
type PedidoListQuery,
type PedidoListResponse,
type RecusarPedido,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { OrdersService } from './orders.service';
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
class UpdatePedidoDto extends createZodDto(UpdatePedidoSchema) {}
class CancelPedidoDto extends createZodDto(CancelPedidoSchema) {}
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
@Controller({ path: 'orders' })
export class OrdersController {
constructor(
private readonly orders: OrdersService,
private readonly cls: ClsService<WorkspaceClsStore>,
) {}
constructor(private readonly orders: OrdersService) {}
@Get()
list(@Query() query: PedidoListQueryDto): Promise<PedidoListResponse> {
@@ -62,31 +56,23 @@ export class OrdersController {
return this.orders.transmit(id);
}
@Patch(':id/approve')
approve(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: AprovarPedidoDto,
): Promise<PedidoDetail> {
const role = this.cls.get('role') ?? 'rep';
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem aprovar pedidos');
const parsed = AprovarPedidoSchema.parse(body) as AprovarPedido;
return this.orders.approve(id, parsed);
}
@Patch(':id/reject')
reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: RecusarPedidoDto,
): Promise<PedidoDetail> {
const role = this.cls.get('role') ?? 'rep';
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem recusar pedidos');
const parsed = RecusarPedidoSchema.parse(body) as RecusarPedido;
return this.orders.reject(id, parsed);
}
@Patch(':id/cancel')
cancel(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
return this.orders.cancel(id);
cancel(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: CancelPedidoDto,
): Promise<PedidoDetail> {
const parsed = CancelPedidoSchema.parse(body) as CancelPedido;
return this.orders.cancel(id, parsed);
}
// Edição de orçamento (situa 0) — depois de transmitido não é mais editável
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: UpdatePedidoDto,
): Promise<PedidoDetail> {
const parsed = UpdatePedidoSchema.parse(body) as UpdatePedido;
return this.orders.update(id, parsed);
}
@Get('erp/:idPedido')

View File

@@ -2,8 +2,9 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { ClsService } from 'nestjs-cls';
import { Prisma } from '@prisma/client';
import type {
AprovarPedido,
CancelPedido,
CreatePedido,
UpdatePedido,
PedidoDetail,
PedidoErpConsultaItem,
PedidoErpConsultaQuery,
@@ -11,16 +12,14 @@ import type {
PedidoListQuery,
PedidoListResponse,
PedidoSummary,
RecusarPedido,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util';
import { NotificationsService } from '../notifications/notifications.service';
// Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado
// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado
// Situa SAR: 0=Orçamento, 2=Confirmado, 3=Cancelado, 4=Faturado
// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado
const SITUA_ORCAMENTO = 0;
const SITUA_PENDENTE = 1;
const SITUA_APROVADO = 2;
const SITUA_CANCELADO = 3;
@@ -288,20 +287,6 @@ export class OrdersService {
return { data, total, page, limit };
}
// Supervisor só age sobre pedidos da própria equipe; gerente/admin passam
// direto. NotFound (não Forbidden) para não revelar pedidos de outras equipes.
private async assertPedidoDaEquipe(codVendedorPedido: number, idPedido: string): Promise<void> {
const role = this.cls.get('role');
if (role !== 'supervisor') return;
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const userId = this.cls.get('userId') ?? '0';
const team = await getTeamCodes(prisma, parseInt(userId, 10));
if (!team.includes(codVendedorPedido)) {
throw new NotFoundException(`Pedido ${idPedido} não encontrado`);
}
}
async findOne(id: string): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -330,8 +315,119 @@ export class OrdersService {
return this.mapDetail(o);
}
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A validação de alçada e a
// notificação ao supervisor acontecem no transmit(), não aqui.
// PGD-AUTHZ: o rep só vende produtos/preços das pautas vinculadas ao cadastro
// dele (cod_pauta1..6). Valida que (a) a pauta do pedido é do rep e (b) todo
// item existe naquela pauta com preço > 0 — fecha preço forjado (ex.: 0,01) e
// produto fantasma/fora da pauta. O TETO de desconto (alçada + promoção/regra
// ativa da política comercial) continua em assertAlcadaItens; aqui só ancora o
// preço de tabela na pauta do rep para que aquele cálculo nunca caia no
// fallback de "sem preço de referência".
private async assertPautaDoRep(
codVendedor: number,
idPauta: number | null | undefined,
itens: { ordem: number; idProduto: number; codProduto?: string | null }[],
): Promise<void> {
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;
if (idPauta == null) {
throw new BadRequestException('Selecione a pauta de preços para lançar o pedido.');
}
// codVendedor (JWT) e idPauta/idProduto (payload validado como Int) — seguros.
const pautaRows = await prisma.$queryRawUnsafe<{ id_pauta: number }[]>(`
SELECT DISTINCT pa.id_pauta
FROM vw_pautas pa
JOIN vw_representantes r ON pa.codigo IN (
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
)
WHERE pa.id_empresa = ${idMatriz}
AND pa.ativo = 1
AND r.codigo = ${codVendedor}
AND pa.id_pauta = ${Number(idPauta)}
LIMIT 1
`);
if (!pautaRows[0]) {
throw new BadRequestException('Pauta de preços não vinculada ao seu cadastro.');
}
const prodIds = [...new Set(itens.map((i) => i.idProduto))];
if (prodIds.length === 0) return;
const okRows = await prisma.$queryRawUnsafe<{ id_produto: number }[]>(`
SELECT pp.id_produto
FROM vw_pauta_produtos pp
JOIN vw_produtos p ON p.id_erp = pp.id_produto
AND p.id_empresa = ${idMatriz}
AND p.ativo = 1
WHERE pp.id_pauta = ${Number(idPauta)}
AND pp.preco1 > 0
AND pp.id_produto IN (${prodIds.join(',')})
`);
const validos = new Set(okRows.map((r) => Number(r.id_produto)));
const foraDaPauta = itens.filter((it) => !validos.has(it.idProduto));
if (foraDaPauta.length > 0) {
const lista = foraDaPauta
.map((it) => `item ${it.ordem} (${it.codProduto ?? it.idProduto})`)
.join(', ');
throw new BadRequestException(
`Produto fora da pauta selecionada: ${lista}. Só é possível vender itens da sua pauta de preços.`,
);
}
}
// Valida a alçada de um payload de criação/edição (mesma regra do transmit):
// desconto global e por item contra alcada + promoção/regra do dono do pedido.
private async assertAlcadaDto(
codVendedor: number,
dto: {
descontoPerc: number;
idPauta?: number;
itens: {
ordem: number;
idProduto: number;
codProduto?: string;
precoUnitario: number;
descontoPerc: number;
}[];
},
): Promise<void> {
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 limitRows = await prisma.alcadaDesconto.findMany({ where: { codVendedor, idEmpresa } });
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
const limiteDefault = limitMap.get(0) ?? 5;
if (dto.descontoPerc > limiteDefault) {
throw new BadRequestException(
`Desconto de ${dto.descontoPerc}% acima do máximo permitido para você (${limiteDefault}%). Reduza o desconto para continuar.`,
);
}
await this.assertAlcadaItens(
{
codVendedor,
descontoPerc: dto.descontoPerc,
idPauta: dto.idPauta ?? null,
itens: dto.itens.map((it) => ({
ordem: it.ordem,
idProduto: it.idProduto,
codProduto: it.codProduto ?? null,
precoUnitario: it.precoUnitario,
descontoPerc: it.descontoPerc,
})),
},
limitMap,
limiteDefault,
);
}
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A alçada de desconto valida
// JÁ AQUI (bloqueia o Concluir com a mensagem do que passou) e de novo no
// transmit() — defesa em profundidade, pois aprovação/ERP podem mudar valores.
// A notificação ao supervisor continua só no transmit().
// Idempotency-Key: retorna pedido existente se já processado (FR-4.3).
async create(dto: CreatePedido): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma');
@@ -365,6 +461,14 @@ export class OrdersService {
if (existing) return this.mapDetail(existing);
}
// Rep: pauta do pedido e itens têm que ser da pauta vinculada ao cadastro
if (role === 'rep') {
await this.assertPautaDoRep(codVendedor, dto.idPauta, dto.itens);
}
// Alçada: bloqueia a criação do orçamento se algum desconto passa do teto
await this.assertAlcadaDto(codVendedor, dto);
const itemsData = dto.itens.map((it) => {
const descontoValor =
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
@@ -496,23 +600,23 @@ export class OrdersService {
// Alçada por ITEM (complementa o gate global do transmit): o desconto EFETIVO
// de cada item — preço cobrado vs preço de tabela, combinado com o desconto
// global — deve caber no limite do grupo do produto (alcada_desconto por
// codGrupo; 0 = default), somado à maior folga entre promoção ativa
// (produto/grupo) e regra de desconto vigente (grupo/subgrupo/rep). Cobre o
// desconto lançado no item E desconto disfarçado de preço unitário reduzido.
// Preço de tabela: pauta do pedido > promocional > preço base; produto sem
// referência valida só pelo campo de desconto.
// global — deve caber no maior valor entre a alçada do grupo do produto
// (alcada_desconto por codGrupo; 0 = default), a promoção ativa
// (produto/grupo) e a regra de desconto vigente (grupo/subgrupo/rep) — teto,
// não soma. Cobre o desconto lançado no item E desconto disfarçado de preço
// unitário reduzido. Preço de tabela: pauta do pedido > promocional > preço
// base; produto sem referência valida só pelo campo de desconto.
private async assertAlcadaItens(
pedido: {
codVendedor: number;
descontoPerc: Prisma.Decimal;
descontoPerc: Prisma.Decimal | number;
idPauta: number | null;
itens: {
ordem: number;
idProduto: number;
codProduto: string | null;
precoUnitario: Prisma.Decimal;
descontoPerc: Prisma.Decimal;
precoUnitario: Prisma.Decimal | number;
descontoPerc: Prisma.Decimal | number;
}[];
},
limitMap: Map<number, number>,
@@ -590,20 +694,25 @@ 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);
const limiteItem =
(codGrupo != null ? (limitMap.get(codGrupo) ?? limiteDefault) : limiteDefault) +
Math.max(promoPct, regraPct);
// Promoção/regra definem TETO próprio, não somam à alçada: com alçada 5%
// e regra de 15%, o máximo do item é 15% (não 20%). Vale o maior entre a
// alçada do grupo e a melhor condição vigente que alcança o produto.
const alcadaBase =
codGrupo != null ? (limitMap.get(codGrupo) ?? limiteDefault) : limiteDefault;
const limiteItem = Math.max(alcadaBase, promoPct, regraPct);
// Preço de tabela: pauta do pedido > promocional (se menor) > preço base
const precoPauta = prod?.preco_pauta != null ? Number(prod.preco_pauta) : 0;
@@ -629,121 +738,108 @@ export class OrdersService {
if (problemas.length > 0) {
throw new BadRequestException(
`Desconto acima da sua alçada: ${problemas.join('; ')}. Ajuste preço/desconto para transmitir.`,
`Desconto acima da sua alçada: ${problemas.join('; ')}. Ajuste preço/desconto para continuar.`,
);
}
}
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
// Edita um orçamento (situa 0). Depois de transmitido não é mais editável.
// Substitui itens e recalcula totais; alçada valida contra o dono do pedido.
async update(id: string, dto: UpdatePedido): Promise<PedidoDetail> {
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 role = this.cls.get('role');
const userId = this.cls.get('userId') ?? '0';
const codVendedor = parseInt(userId, 10);
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
const repFilter =
role === 'rep'
? { codVendedor }
: role === 'supervisor'
? { codVendedor: { in: await getTeamCodes(prisma, codVendedor) } }
: {};
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } });
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (pedido.situa !== SITUA_PENDENTE)
if (pedido.situa !== SITUA_ORCAMENTO)
throw new BadRequestException(
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
'Apenas orçamentos podem ser editados — este pedido já foi transmitido',
);
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
const now = new Date();
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc);
const newTotal =
Math.round(Number(pedido.totalProdutos) * (1 - newDescontoPerc / 100) * 100) / 100;
// PGD-AUTHZ: rep só aponta o pedido para cliente da própria carteira.
if (role === 'rep' && dto.idCliente !== pedido.idCliente) {
const cliRows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
dto.idCliente,
);
if (!cliRows[0] || Number(cliRows[0].cod_vendedor) !== codVendedor) {
throw new NotFoundException(`Cliente ${dto.idCliente} não encontrado`);
}
}
await prisma.pedido.update({
// Rep: pauta do pedido e itens têm que ser da pauta vinculada ao cadastro
if (role === 'rep') {
await this.assertPautaDoRep(pedido.codVendedor, dto.idPauta, dto.itens);
}
// Alçada do DONO do pedido (supervisor editando usa o limite do rep)
await this.assertAlcadaDto(pedido.codVendedor, dto);
const itemsData = dto.itens.map((it) => {
const descontoValor =
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
const total = Math.round(it.qtd * it.precoUnitario * (1 - it.descontoPerc / 100) * 100) / 100;
return {
ordem: it.ordem,
idProduto: it.idProduto,
codProduto: it.codProduto ?? null,
descProduto: it.descProduto,
qtd: it.qtd,
precoUnitario: it.precoUnitario,
descontoPerc: it.descontoPerc,
descontoValor,
total,
};
});
const totalProdutos = itemsData.reduce((acc, it) => acc + it.total, 0);
const descontoValorGlobal = Math.round(totalProdutos * (dto.descontoPerc / 100) * 100) / 100;
const total = Math.round(totalProdutos * (1 - dto.descontoPerc / 100) * 100) / 100;
const final = await prisma.pedido.update({
where: { id },
data: {
situa: SITUA_APROVADO,
descontoPerc: newDescontoPerc,
total: newTotal,
aprovadoPor: codVendedor,
aprovadoEm: now,
idCliente: dto.idCliente,
idPauta: dto.idPauta ?? null,
codFormapag: dto.codFormapag ?? null,
totalProdutos,
total,
descontoPerc: dto.descontoPerc,
descontoValor: descontoValorGlobal,
obs: dto.obs ?? null,
endEntrega: dto.endEntrega ?? null,
itens: { deleteMany: {}, create: itemsData },
historico: {
create: [
{
situaAnterior: SITUA_ORCAMENTO,
situaNova: SITUA_ORCAMENTO,
changedBy: codVendedor,
changedAt: new Date(),
nota: 'Orçamento editado',
},
],
},
},
});
await prisma.historicoPedido.create({
data: {
idPedido: id,
situaAnterior: SITUA_PENDENTE,
situaNova: SITUA_APROVADO,
changedBy: codVendedor,
changedAt: now,
nota: dto.nota ?? null,
},
});
const final = await prisma.pedido.findUniqueOrThrow({
where: { id },
include: {
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
void this.notifications.notifyUser(String(pedido.codVendedor), {
title: 'Pedido aprovado',
body: `${final.numPedSar} aprovado${dto.descontoPerc !== undefined ? ` com ${newDescontoPerc}% de desconto` : ''}`,
url: `/pedidos/${id}`,
});
return this.mapDetail(final);
}
async reject(id: string, dto: RecusarPedido): Promise<PedidoDetail> {
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 userId = this.cls.get('userId') ?? '0';
const codVendedor = parseInt(userId, 10);
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (pedido.situa !== SITUA_PENDENTE)
throw new BadRequestException(
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
);
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
const now = new Date();
await prisma.pedido.update({
where: { id },
data: { situa: SITUA_CANCELADO, motivoRecusa: dto.motivo },
});
await prisma.historicoPedido.create({
data: {
idPedido: id,
situaAnterior: SITUA_PENDENTE,
situaNova: SITUA_CANCELADO,
changedBy: codVendedor,
changedAt: now,
nota: dto.motivo,
},
});
const final = await prisma.pedido.findUniqueOrThrow({
where: { id },
include: {
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
void this.notifications.notifyUser(String(pedido.codVendedor), {
title: 'Pedido recusado',
body: `${final.numPedSar}: ${dto.motivo}`,
url: `/pedidos/${id}`,
});
return this.mapDetail(final);
}
async cancel(id: string): Promise<PedidoDetail> {
async cancel(id: string, dto: CancelPedido): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
@@ -757,8 +853,15 @@ export class OrdersService {
throw new BadRequestException('Apenas orçamentos podem ser cancelados pelo representante');
const now = new Date();
// ATENCAO: banco ERP e LATIN1 - sem em-dash/caracteres fora do LATIN1 aqui
const motivoCompleto = dto.descricao?.trim()
? `${dto.motivo} - ${dto.descricao.trim()}`
: dto.motivo;
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_CANCELADO } });
await prisma.pedido.update({
where: { id },
data: { situa: SITUA_CANCELADO, motivoRecusa: motivoCompleto },
});
await prisma.historicoPedido.create({
data: {
idPedido: id,
@@ -766,7 +869,7 @@ export class OrdersService {
situaNova: SITUA_CANCELADO,
changedBy: codVendedor,
changedAt: now,
nota: 'Cancelado pelo representante',
nota: `Cancelado pelo representante - ${motivoCompleto}`,
},
});
@@ -818,6 +921,8 @@ export class OrdersService {
codVendedor: number;
situa: number;
dtPedido: Date;
idPauta: number | null;
codFormapag: number | null;
total: Prisma.Decimal;
descontoPerc: Prisma.Decimal;
descontoValor: Prisma.Decimal;
@@ -856,6 +961,41 @@ export class OrdersService {
}[];
}): Promise<PedidoDetail> {
const names = await this.lookupNames(o.idCliente, o.codVendedor);
// Grupo/subgrupo dos produtos (vw_produtos, empresa matriz) — a edição do
// orçamento usa para calcular o teto de desconto por item no carrinho.
const prisma = this.cls.get('prisma');
const idEmpresa = this.cls.get('idEmpresa');
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
const prodIds = [...new Set(o.itens.map((i) => i.idProduto))];
const grupoRows =
prisma && prodIds.length > 0
? await prisma.$queryRawUnsafe<
{
id_erp: number;
cod_grupo: number | null;
cod_subgrupo: number | null;
peso_liquido: string | null;
vol_m3: string | null;
}[]
>(
`SELECT id_erp, cod_grupo, cod_subgrupo, peso_liquido::text, vol_m3::text
FROM vw_produtos
WHERE id_empresa = ${idMatriz} AND id_erp IN (${prodIds.join(',')})`,
)
: [];
const grupoMap = new Map(grupoRows.map((g) => [Number(g.id_erp), g]));
// Peso total (kg) e cubagem (m3) do pedido: soma de qtd x peso/vol do produto
let pesoTotal = 0;
let m3Total = 0;
for (const it of o.itens) {
const prod = grupoMap.get(it.idProduto);
const qtd = Number(it.qtd);
pesoTotal += qtd * Number(prod?.peso_liquido ?? 0);
m3Total += qtd * Number(prod?.vol_m3 ?? 0);
}
return {
id: o.id,
numPedSar: o.numPedSar,
@@ -866,6 +1006,10 @@ export class OrdersService {
nomeVendedor: names.nomeVendedor,
situa: o.situa,
dtPedido: o.dtPedido.toISOString(),
idPauta: o.idPauta,
codFormapag: o.codFormapag,
pesoTotal: pesoTotal.toFixed(3),
m3Total: m3Total.toFixed(4),
total: decimalToString(o.total),
descontoPerc: decimalToString(o.descontoPerc),
obs: o.obs,
@@ -894,6 +1038,14 @@ export class OrdersService {
precoUnitario: decimalToString(it.precoUnitario),
descontoPerc: decimalToString(it.descontoPerc),
total: decimalToString(it.total),
codGrupo:
grupoMap.get(it.idProduto)?.cod_grupo != null
? Number(grupoMap.get(it.idProduto)?.cod_grupo)
: null,
codSubgrupo:
grupoMap.get(it.idProduto)?.cod_subgrupo != null
? Number(grupoMap.get(it.idProduto)?.cod_subgrupo)
: null,
})),
historico: o.historico.map((h) => ({
id: h.id,
@@ -948,6 +1100,8 @@ export class OrdersService {
preco_unitario: string;
desconto_perc: string;
total: string;
peso_liquido: string | null;
vol_m3: string | null;
}
const vendedorFilter =
@@ -980,7 +1134,8 @@ export class OrdersService {
SELECT ei.ordem, ei.id_produto,
TRIM(p.codigo) AS codigo, TRIM(p.descricao) AS descricao,
ei.qtd::text, ei.preco_unitario::text,
ei.desconto_perc::text, ei.total::text
ei.desconto_perc::text, ei.total::text,
p.peso_liquido::text AS peso_liquido, p.vol_m3::text AS vol_m3
FROM vw_peditens_erp ei
LEFT JOIN vw_produtos p
ON p.id_erp = ei.id_produto
@@ -993,6 +1148,15 @@ export class OrdersService {
if (!headerRows[0]) throw new NotFoundException(`Pedido ERP ${idPedido} não encontrado`);
const h = headerRows[0];
const pesoTotalErp = itemRows.reduce(
(acc, it) => acc + Number(it.qtd) * Number(it.peso_liquido ?? 0),
0,
);
const m3TotalErp = itemRows.reduce(
(acc, it) => acc + Number(it.qtd) * Number(it.vol_m3 ?? 0),
0,
);
return {
id: `erp-${h.id_pedido}`,
numPedSar: (h.num_ped_sar ?? '').trim(),
@@ -1023,6 +1187,8 @@ export class OrdersService {
aprovadoEm: null,
motivoRecusa: null,
idempotencyKey: null,
pesoTotal: pesoTotalErp.toFixed(3),
m3Total: m3TotalErp.toFixed(4),
itens: itemRows.map((it) => ({
id: `${idPedido}-${it.ordem}`,
idProduto: Number(it.id_produto),

View File

@@ -11,26 +11,23 @@ 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,
type RegrasDescontoResponse,
type RegrasVigentesResponse,
type GruposProdutoResponse,
} 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) {}
@@ -40,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();
@@ -61,8 +47,13 @@ export class PoliticasController {
return this.politicas.listPromocoesVigentes();
}
@Get('alcada')
minhaAlcada(): Promise<AlcadaRepResponse> {
return this.politicas.minhaAlcada();
}
@Post('promocoes')
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> {
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ ids: number[] }> {
return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
}
@@ -91,7 +82,7 @@ export class PoliticasController {
}
@Post('regras')
createRegra(@Body() body: CreateRegraDescontoDto): Promise<{ id: number }> {
createRegra(@Body() body: CreateRegraDescontoDto): Promise<{ ids: number[] }> {
return this.politicas.createRegra(body as unknown as CreateRegraDescontoBody);
}

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,
@@ -11,6 +9,7 @@ import type {
UpdateRegraDescontoBody,
RegrasVigentesResponse,
PromocoesVigentesResponse,
AlcadaRepResponse,
GruposProdutoResponse,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
@@ -26,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');
@@ -108,26 +52,41 @@ export class PoliticasService {
};
}
async createPromocao(body: CreatePromocaoBody): Promise<{ id: number }> {
// Cria uma promoção POR ALVO (produto ou grupo). Sem alvo, cria uma linha
// genérica (comportamento anterior preservado).
async createPromocao(body: CreatePromocaoBody): Promise<{ ids: number[] }> {
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 userId = this.cls.get('userId') ?? 'system';
const p = await prisma.promocao.create({
data: {
idEmpresa,
descricao: body.descricao,
codProduto: body.codProduto ?? null,
grpProd: body.grpProd ?? null,
descPct: body.descPct,
dataInicio: new Date(body.dataInicio),
dataFim: new Date(body.dataFim),
createdBy: userId,
},
const alvos: { codProduto: string | null; grpProd: string | null }[] = [
...(body.codProdutos ?? []).map((c) => ({ codProduto: c, grpProd: null })),
...(body.grpProds ?? []).map((g) => ({ codProduto: null, grpProd: g })),
];
if (alvos.length === 0) alvos.push({ codProduto: null, grpProd: null });
const ids = await prisma.$transaction(async (tx) => {
const created: number[] = [];
for (const alvo of alvos) {
const p = await tx.promocao.create({
data: {
idEmpresa,
descricao: body.descricao,
codProduto: alvo.codProduto,
grpProd: alvo.grpProd,
descPct: body.descPct,
dataInicio: new Date(body.dataInicio),
dataFim: new Date(body.dataFim),
createdBy: userId,
},
});
created.push(p.id);
}
return created;
});
return { id: p.id };
return { ids };
}
async updatePromocao(id: number, body: UpdatePromocaoBody): Promise<void> {
@@ -204,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');
@@ -236,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,
@@ -244,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),
@@ -256,27 +237,50 @@ export class PoliticasService {
};
}
async createRegra(body: CreateRegraDescontoBody): Promise<{ id: number }> {
// 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');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId') ?? 'system';
const r = await prisma.regraDesconto.create({
data: {
idEmpresa,
descricao: body.descricao,
codGrupo: body.codGrupo ?? null,
codSubgrupo: body.codSubgrupo ?? null,
codVendedor: body.codVendedor ?? null,
descPct: body.descPct,
dataInicio: new Date(body.dataInicio),
dataFim: new Date(body.dataFim),
createdBy: userId,
},
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, idPauta: null });
const ids = await prisma.$transaction(async (tx) => {
const created: number[] = [];
for (const alvo of alvos) {
const r = await tx.regraDesconto.create({
data: {
idEmpresa,
descricao: body.descricao,
codGrupo: alvo.codGrupo,
codSubgrupo: alvo.codSubgrupo,
idPauta: alvo.idPauta,
codVendedor: body.codVendedor ?? null,
descPct: body.descPct,
dataInicio: new Date(body.dataInicio),
dataFim: new Date(body.dataFim),
createdBy: userId,
},
});
created.push(r.id);
}
return created;
});
return { id: r.id };
return { ids };
}
async updateRegra(id: number, body: UpdateRegraDescontoBody): Promise<void> {
@@ -294,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) }),
@@ -315,6 +320,25 @@ export class PoliticasService {
await prisma.regraDesconto.delete({ where: { id } });
}
// Alçada do próprio usuário logado — o carrinho usa para mostrar o teto de
// desconto por item (codGrupo 0 = default; sem linha 0, o backend assume 5%).
async minhaAlcada(): Promise<AlcadaRepResponse> {
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 codVendedor = parseInt(this.cls.get('userId') ?? '0', 10);
const rows = await prisma.alcadaDesconto.findMany({
where: { codVendedor, idEmpresa },
});
return {
limites: rows.map((r) => ({
codGrupo: r.codGrupo,
limitePerc: Number(r.limitePerc),
})),
};
}
// Promoções vigentes hoje — qualquer usuário autenticado. Usadas pelo rep para
// sinalizar no catálogo que o produto tem condição comercial diferenciada.
async listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
@@ -334,12 +358,36 @@ export class PoliticasService {
orderBy: [{ descPct: 'desc' }],
});
// Nomes de produto e grupo — UI sempre mostra o nome, nunca só o código
const codProdutos = [
...new Set(
rows.map((p) => p.codProduto?.trim()).filter((c): c is string => !!c && c.length > 0),
),
];
const prodRows =
codProdutos.length > 0
? await prisma.$queryRawUnsafe<{ codigo: string; descricao: string | null }[]>(
`SELECT TRIM(codigo) AS codigo, TRIM(descricao) AS descricao
FROM vw_produtos
WHERE id_empresa = ${idMatriz}
AND TRIM(codigo) IN (${codProdutos.map((c) => `'${c.replace(/'/g, "''")}'`).join(',')})`,
)
: [];
const prodNameMap = new Map(prodRows.map((r) => [r.codigo, r.descricao]));
const grupos = rows.some((p) => p.grpProd != null) ? await this.loadGruposProduto() : [];
const grupoNameMap = new Map(
grupos.filter((g) => g.cod_grupo != null).map((g) => [String(Number(g.cod_grupo)), g.grupo]),
);
return {
promocoes: rows.map((p) => ({
id: p.id,
descricao: p.descricao,
codProduto: p.codProduto,
nomeProduto: p.codProduto ? (prodNameMap.get(p.codProduto.trim()) ?? null) : null,
grpProd: p.grpProd,
grupo: p.grpProd ? (grupoNameMap.get(p.grpProd.trim()) ?? null) : null,
descPct: Number(p.descPct),
dataFim: p.dataFim.toISOString().slice(0, 10),
})),
@@ -368,12 +416,30 @@ export class PoliticasService {
orderBy: [{ descPct: 'desc' }],
});
// Nomes de grupo/subgrupo — UI sempre mostra o nome, nunca só o código
const grupos = rows.some((r) => r.codGrupo != null || r.codSubgrupo != null)
? await this.loadGruposProduto()
: [];
const grupoNameMap = new Map(
grupos.filter((g) => g.cod_grupo != null).map((g) => [Number(g.cod_grupo), g.grupo]),
);
const subgrupoNameMap = new Map(
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,
descricao: r.descricao,
codGrupo: r.codGrupo,
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

@@ -12,6 +12,7 @@
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.3.1",
"@hookform/resolvers": "^5.4.0",
"@svg-maps/brazil": "^2.0.0",
"@tanstack/react-query": "^5.100.14",
"@tanstack/react-router": "^1.170.8",
"antd": "^6.4.3",

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,85 +10,118 @@ 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 && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
cód. {row.codVendedor}
</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}%
</Text>
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. {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>
{/* Meta do time */}
<div>
<Flex justify="space-between" align="baseline" style={{ marginBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
Meta do time
</Text>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
{s.metaTotal > 0 ? `${s.pctMeta}%` : 'sem meta'}
</Text>
</Flex>
<Progress
percent={s.metaTotal > 0 ? Math.min(s.pctMeta, 100) : 0}
size="small"
strokeColor={metaColor(s.pctMeta)}
showInfo={false}
/>
</div>
{/* Ticket + positivação */}
<Flex justify="space-between" gap={12}>
<Space orientation="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
TICKET MÉDIO
</Text>
<Text strong className="tabular-nums">
{s.ticketMedio > 0 ? fmt(s.ticketMedio) : '—'}
</Text>
</Space>
<Space orientation="vertical" size={0} style={{ textAlign: 'right' }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
POSITIVAÇÃO
</Text>
<Text strong className="tabular-nums">
{s.pctPositivacao}%{' '}
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
({s.clientesPositivados}/{s.totalClientes})
</Text>
</Text>
</Space>
</Flex>
<Button block onClick={onDetalhar}>
Detalhar
</Button>
</Flex>
</Card>
);
}
export function EquipePage() {
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 }} />
</Flex>
);
}
const { data, isLoading } = useSupervisores();
const [detalhe, setDetalhe] = useState<SupervisorCard | null>(null);
const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
@@ -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>
<Card>
<Table<RepStats>
rowKey="codVendedor"
columns={columns}
dataSource={data.reps}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhum representante encontrado.' }}
scroll={{ x: 700 }}
/>
</Card>
{isLoading || !data ? (
<Row gutter={[24, 24]}>
{[1, 2, 3].map((i) => (
<Col key={i} xs={24} md={12} lg={8}>
<Card>
<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>
<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

@@ -0,0 +1,157 @@
import { useState } from 'react';
import { Button, Card, Flex, Popconfirm, Skeleton, Typography, Upload, message } from 'antd';
import type { UploadProps } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faImage, faTrash, faUpload } from '@fortawesome/free-solid-svg-icons';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCompany } from '../../lib/queries/company';
import { apiFetch } from '../../lib/api-client';
const { Title, Text } = Typography;
const MAX_LOGO_BYTES = 500 * 1024; // ~500KB — logo de cabeçalho não precisa mais
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error('Falha ao ler o arquivo'));
reader.readAsDataURL(file);
});
}
export function GerConfigPage() {
const { data: empresa, isLoading } = useCompany();
const qc = useQueryClient();
const [msg, msgCtx] = message.useMessage();
const [uploading, setUploading] = useState(false);
const updateLogo = useMutation({
mutationFn: (logoBase64: string | null) =>
apiFetch('/catalog/company/logo', { method: 'PUT', body: { logoBase64 } }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['company'] }),
});
const beforeUpload: UploadProps['beforeUpload'] = async (file) => {
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
void msg.error('Use uma imagem PNG, JPG ou WebP');
return Upload.LIST_IGNORE;
}
if (file.size > MAX_LOGO_BYTES) {
void msg.error('Imagem muito grande — use um arquivo de até 500KB');
return Upload.LIST_IGNORE;
}
setUploading(true);
try {
const dataUrl = await fileToDataUrl(file);
await updateLogo.mutateAsync(dataUrl);
void msg.success('Logo atualizado — já vale para as impressões de todos os representantes');
} catch {
// erro já notificado pelo handler global do QueryClient
} finally {
setUploading(false);
}
return Upload.LIST_IGNORE; // upload manual — nada vai para action padrão
};
async function removeLogo() {
try {
await updateLogo.mutateAsync(null);
} catch {
return;
}
void msg.success('Logo removido');
}
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
{msgCtx}
<Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}>
Configurações
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
Preferências da empresa aplicadas a todos os representantes
</Text>
</Flex>
<Card
title={
<Flex align="center" gap={8}>
<FontAwesomeIcon icon={faImage} style={{ color: 'var(--jcs-blue)' }} />
Logo da Empresa
</Flex>
}
style={{ maxWidth: 640 }}
>
{isLoading ? (
<Skeleton active paragraph={{ rows: 3 }} />
) : (
<Flex vertical gap={16}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
Aparece no cabeçalho da impressão do pedido (PDF) de todos os representantes da{' '}
{empresa?.nomeFantasia ?? empresa?.razaoSocial ?? 'empresa'}. PNG com fundo
transparente fica melhor. Máx. 500KB.
</Text>
<Flex align="center" gap={24} wrap="wrap">
<div
style={{
width: 220,
height: 90,
border: '1px dashed #CBD5E1',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#F8FAFC',
overflow: 'hidden',
}}
>
{empresa?.logoBase64 ? (
<img
src={empresa.logoBase64}
alt="Logo da empresa"
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
/>
) : (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Nenhum logo cadastrado
</Text>
)}
</div>
<Flex vertical gap={8}>
<Upload
accept="image/png,image/jpeg,image/webp"
showUploadList={false}
beforeUpload={beforeUpload}
>
<Button
type="primary"
icon={<FontAwesomeIcon icon={faUpload} />}
loading={uploading || updateLogo.isPending}
>
{empresa?.logoBase64 ? 'Trocar logo' : 'Enviar logo'}
</Button>
</Upload>
{empresa?.logoBase64 && (
<Popconfirm
title="Remover o logo?"
okText="Sim"
cancelText="Nao"
onConfirm={() => void removeLogo()}
>
<Button danger icon={<FontAwesomeIcon icon={faTrash} />}>
Remover
</Button>
</Popconfirm>
)}
</Flex>
</Flex>
</Flex>
)}
</Card>
</Flex>
);
}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Button,
Card,
@@ -11,7 +12,9 @@ import {
Skeleton,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import { useNavigate } from '@tanstack/react-router';
import type { TableColumnsType } from 'antd';
@@ -42,6 +45,12 @@ import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface';
import { useManagerDashboard } from '../../lib/queries/gerente';
import {
positivacaoRepColumns,
rankingRepColumns,
} from '../../components/dashboard/rep-performance-columns';
import { apiFetch } from '../../lib/api-client';
import { MapaBrasilCard } from './MapaBrasilCard';
ChartJS.register(
CategoryScale,
@@ -55,9 +64,6 @@ ChartJS.register(
const { Title, Text } = Typography;
// Meta diária de positivação é preferência local do gerente (não vem do ERP)
const META_POSITIVACAO_DIA_KEY = 'sar:ger:meta-positivacao-dia';
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
@@ -66,45 +72,6 @@ function today(): string {
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
}
const positivacaoColumns: TableColumnsType<PositivacaoRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: PositivacaoRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
ellipsis: true,
},
{
title: 'Positivados / Carteira',
key: 'pos',
width: 160,
align: 'center' as const,
render: (_: unknown, row: PositivacaoRep) => (
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
{row.clientesPositivados} / {row.totalClientes}
</Text>
),
},
{
title: '% Positivação',
dataIndex: 'pctPositivacao',
width: 170,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 30 ? 'var(--green)' : pct >= 15 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex>
),
},
];
const metasGrupoColumns: TableColumnsType<MetaItem> = [
{
title: 'Grupo',
@@ -162,76 +129,33 @@ const metasGrupoColumns: TableColumnsType<MetaItem> = [
},
];
const rankingColumns: TableColumnsType<RankingRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: RankingRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Pedidos',
dataIndex: 'pedidos',
width: 80,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Clientes',
dataIndex: 'clientesAtendidos',
width: 80,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Faturamento',
dataIndex: 'faturamento',
width: 150,
align: 'right',
render: (v: number) => fmt(v),
className: 'tabular-nums',
},
{
title: '% Meta',
dataIndex: 'pctMeta',
width: 160,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex>
),
},
];
function PositivacaoDiariaCard({
dados,
metaSalva,
periodo,
periodoLabel,
isMesAtual,
}: {
dados: PositivacaoDia[];
metaSalva: number;
periodo: Dayjs;
periodoLabel: string;
isMesAtual: boolean;
}) {
const [meta, setMeta] = useState<number>(() => {
const saved = Number(localStorage.getItem(META_POSITIVACAO_DIA_KEY));
return Number.isFinite(saved) && saved > 0 ? saved : 0;
});
// Meta salva no servidor (config_empresa) — vale em qualquer dispositivo
const [meta, setMeta] = useState<number>(metaSalva);
useEffect(() => setMeta(metaSalva), [metaSalva]);
const qc = useQueryClient();
const [msg, msgCtx] = message.useMessage();
function changeMeta(v: number | null) {
const val = v ?? 0;
setMeta(val);
localStorage.setItem(META_POSITIVACAO_DIA_KEY, String(val));
}
const salvarMeta = useMutation({
mutationFn: (m: number) =>
apiFetch('/dashboard/manager/meta-positivacao-dia', { method: 'PUT', body: { meta: m } }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
void msg.success('Meta salva');
},
});
// Eixo com todos os dias: até hoje no mês atual, mês inteiro nos anteriores
const ultimoDia = isMesAtual ? dayjs().date() : periodo.endOf('month').date();
@@ -318,13 +242,23 @@ function PositivacaoDiariaCard({
min={0}
size="small"
value={meta > 0 ? meta : undefined}
onChange={changeMeta}
onChange={(v) => setMeta(v ?? 0)}
placeholder="—"
style={{ width: 80 }}
/>
<Button
type="primary"
size="small"
disabled={meta === metaSalva}
loading={salvarMeta.isPending}
onClick={() => salvarMeta.mutate(meta)}
>
Salvar
</Button>
</Flex>
}
>
{msgCtx}
<div style={{ height: 280 }}>
<Chart type="bar" data={chartData} options={options} />
</div>
@@ -371,6 +305,7 @@ export function GerPainel() {
rankingReps,
positivacaoReps,
positivacaoDiaria,
faturamentoPorUf,
syncedAt,
} = data;
@@ -419,155 +354,179 @@ export function GerPainel() {
</Flex>
</Flex>
{/* KPIs linha 1 */}
{/* Primeira dobra: visão financeira (½) + mapa do Brasil (½) */}
<Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()}
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(faturamentoMes)}
</Title>
<FontAwesomeIcon
icon={faFileInvoiceDollar}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/>
</Flex>
</Card>
<Col xs={24} xl={12}>
<Flex vertical gap={16} style={{ height: '100%' }}>
<Row gutter={[16, 16]} style={{ flex: 1 }}>
<Col xs={24} sm={12} style={{ display: 'flex' }}>
<Card style={{ flex: 1 }}>
<Flex vertical gap={4}>
<Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()}
</Text>
<FontAwesomeIcon
icon={faFileInvoiceDollar}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/>
</Flex>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(faturamentoMes)}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{pedidosMes} pedido{pedidosMes !== 1 ? 's' : ''} no{' '}
{isMesAtual ? 'mês' : 'período'}
</Text>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} style={{ display: 'flex' }}>
<Card style={{ flex: 1 }}>
<Flex vertical gap={4}>
<Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
</Text>
<FontAwesomeIcon
icon={faBullseye}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/>
</Flex>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{metaTotal > 0 ? fmt(metaTotal) : '—'}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{metaTotal > 0
? `soma das metas por representante`
: 'sem meta cadastrada no ERP'}
</Text>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} style={{ display: 'flex' }}>
<Card style={{ flex: 1 }}>
<Flex vertical gap={8}>
<Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
ATINGIMENTO DA META
</Text>
<FontAwesomeIcon
icon={faArrowTrendUp}
style={{ color: metaCorColor, opacity: 0.7 }}
/>
</Flex>
<Title
level={3}
style={{ margin: 0, color: metaTotal > 0 ? metaCorColor : undefined }}
className="tabular-nums"
>
{metaTotal > 0 ? `${pctMeta}%` : '—'}
</Title>
<Progress
percent={metaTotal > 0 ? Math.min(pctMeta, 100) : 0}
strokeColor={metaCorColor}
showInfo={false}
size="small"
/>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} style={{ display: 'flex' }}>
<Card style={{ flex: 1 }}>
<Flex vertical gap={4}>
<Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
</Text>
<FontAwesomeIcon
icon={faChartBar}
style={{
color: superouMeta > 0 ? 'var(--green)' : 'var(--jcs-blue)',
opacity: 0.5,
}}
/>
</Flex>
<Title
level={3}
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
className="tabular-nums"
>
{metaTotal > 0 ? fmt(superouMeta > 0 ? superouMeta : faltaMeta) : '—'}
</Title>
{metaTotal === 0 ? (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
sem meta cadastrada no ERP
</Text>
) : superouMeta > 0 ? (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{pctMeta - 100}% acima da meta
</Text>
) : vendaPorDia > 0 ? (
<Text
style={{ fontSize: 'var(--text-xs)', color: metaCorColor, fontWeight: 600 }}
>
{fmt(vendaPorDia)}/dia &mdash; {diasRestantes} dia
{diasRestantes !== 1 ? 's' : ''} restante{diasRestantes !== 1 ? 's' : ''}
</Text>
) : diasRestantes === 0 ? (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
período encerrado
</Text>
) : null}
</Flex>
</Card>
</Col>
</Row>
{/* Indicadores secundários */}
<Card styles={{ body: { padding: '12px 24px' } }}>
<Flex justify="space-around" align="center" wrap="wrap" gap={16}>
<Flex vertical align="center" gap={0}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
TICKET MÉDIO
</Text>
<Text strong className="tabular-nums" style={{ fontSize: 'var(--text-lg)' }}>
{fmt(ticketMedio)}
</Text>
</Flex>
<Flex vertical align="center" gap={0}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
REPS COM PEDIDOS
</Text>
<Text strong className="tabular-nums" style={{ fontSize: 'var(--text-lg)' }}>
<FontAwesomeIcon
icon={faUsers}
style={{ color: 'var(--jcs-blue)', opacity: 0.5, marginRight: 6 }}
/>
{totalReps}
</Text>
</Flex>
<Flex vertical align="center" gap={0}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
PROMOÇÕES ATIVAS
</Text>
<Text strong className="tabular-nums" style={{ fontSize: 'var(--text-lg)' }}>
<FontAwesomeIcon
icon={faTags}
style={{ color: 'var(--jcs-blue)', opacity: 0.5, marginRight: 6 }}
/>
{promocoesAtivas}
</Text>
</Flex>
</Flex>
</Card>
</Flex>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
PEDIDOS NO {isMesAtual ? 'MÊS' : 'PERÍODO'}
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{pedidosMes}
</Title>
<FontAwesomeIcon
icon={faChartBar}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
TICKET MÉDIO
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(ticketMedio)}
</Title>
<FontAwesomeIcon icon={faUsers} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
PROMOÇÕES ATIVAS
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{promocoesAtivas}
</Title>
<FontAwesomeIcon icon={faTags} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
</Flex>
</Card>
<Col xs={24} xl={12}>
<MapaBrasilCard dados={faturamentoPorUf} periodoLabel={periodoLabel} />
</Col>
</Row>
{/* KPIs linha 2 — Meta */}
{metaTotal > 0 && (
<Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={8}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(metaTotal)}
</Title>
<FontAwesomeIcon
icon={faBullseye}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card>
<Flex vertical gap={8}>
<Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
ATINGIMENTO DA META
</Text>
<FontAwesomeIcon
icon={faArrowTrendUp}
style={{ color: metaCorColor, opacity: 0.7 }}
/>
</Flex>
<Title
level={3}
style={{ margin: 0, color: metaCorColor }}
className="tabular-nums"
>
{pctMeta}%
</Title>
<Progress
percent={Math.min(pctMeta, 100)}
strokeColor={metaCorColor}
showInfo={false}
size="small"
/>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
</Text>
<Title
level={3}
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
className="tabular-nums"
>
{fmt(superouMeta > 0 ? superouMeta : faltaMeta)}
</Title>
{superouMeta > 0 ? (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{pctMeta - 100}% acima da meta
</Text>
) : vendaPorDia > 0 ? (
<Text
style={{ fontSize: 'var(--text-xs)', color: metaCorColor, fontWeight: 600 }}
>
{fmt(vendaPorDia)}/dia &mdash; {diasRestantes} dia
{diasRestantes !== 1 ? 's' : ''} restante{diasRestantes !== 1 ? 's' : ''}
</Text>
) : diasRestantes === 0 ? (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
período encerrado
</Text>
) : null}
</Flex>
</Card>
</Col>
</Row>
)}
{/* Metas por Grupo */}
{metasPorGrupo.length > 0 && (
<Card
@@ -618,7 +577,7 @@ export function GerPainel() {
>
<Table<RankingRep>
rowKey="codVendedor"
columns={rankingColumns}
columns={rankingRepColumns}
dataSource={rankingReps}
pagination={false}
size="small"
@@ -629,6 +588,7 @@ export function GerPainel() {
{/* Positivação Diária */}
<PositivacaoDiariaCard
dados={positivacaoDiaria}
metaSalva={data.metaPositivacaoDia ?? 0}
periodo={periodo}
periodoLabel={periodoLabel}
isMesAtual={isMesAtual}
@@ -643,14 +603,25 @@ export function GerPainel() {
</Space>
}
extra={
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação
</Text>
<Flex align="center" gap={12}>
{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0) > 0 && (
<Tag color="green" style={{ margin: 0, fontWeight: 600 }}>
+{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0)} novo
{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0) !== 1 ? 's' : ''}{' '}
cliente
{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0) !== 1 ? 's' : ''} no
período
</Tag>
)}
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação
</Text>
</Flex>
}
>
<Table<PositivacaoRep>
rowKey="codVendedor"
columns={positivacaoColumns}
columns={positivacaoRepColumns}
dataSource={positivacaoReps}
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }}
size="small"

View File

@@ -0,0 +1,360 @@
import { useMemo, useState } from 'react';
import { Button, Card, Empty, Flex, Modal, Progress, Table, Tag, Tooltip, Typography } from 'antd';
import type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faMapLocationDot, faUserTie } from '@fortawesome/free-solid-svg-icons';
import type { UfFaturamento, UfRep } from '@sar/api-interface';
import brazilMap from '@svg-maps/brazil';
const { Text } = Typography;
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
// R$ compacto para a lista lateral (R$ 12,4 mil / R$ 1,2 mi)
function fmtCompacto(v: number): string {
if (v >= 1_000_000)
return `R$ ${(v / 1_000_000).toLocaleString('pt-BR', { maximumFractionDigits: 1 })} mi`;
if (v >= 1_000)
return `R$ ${(v / 1_000).toLocaleString('pt-BR', { maximumFractionDigits: 1 })} mil`;
return fmt(v);
}
// Escala sequencial (um matiz, claro→escuro) derivada do azul JCS.
const SEQ_STEPS = ['#dbe9f6', '#aecde9', '#7BA7D7', '#3d74b8', '#004a99'];
const COR_SEM_VENDA = '#edf0f3';
function corDaUf(valor: number, max: number): string {
if (valor <= 0 || max <= 0) return COR_SEM_VENDA;
const idx = Math.min(SEQ_STEPS.length - 1, Math.floor((valor / max) * SEQ_STEPS.length));
return SEQ_STEPS[idx] ?? COR_SEM_VENDA;
}
const NOME_UF = new Map(brazilMap.locations.map((l) => [l.id.toUpperCase(), l.name]));
const repColumns: TableColumnsType<UfRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, r: UfRep) => (
<Flex align="center" gap={8}>
<FontAwesomeIcon icon={faUserTie} style={{ color: 'var(--jcs-blue)', opacity: 0.45 }} />
{r.nomeVendedor ?? `Cód. ${r.codVendedor}`}
</Flex>
),
ellipsis: true,
},
{
title: 'Pedidos',
dataIndex: 'pedidos',
width: 90,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Clientes',
dataIndex: 'clientes',
width: 90,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Faturamento',
dataIndex: 'faturamento',
width: 150,
align: 'right',
render: (v: number) => fmt(v),
className: 'tabular-nums',
},
];
function detalheColumns(): TableColumnsType<UfFaturamento> {
return [
{
title: 'Estado',
key: 'uf',
render: (_: unknown, r: UfFaturamento) => (
<Flex align="center" gap={8}>
<Tag style={{ margin: 0, fontWeight: 700, width: 40, textAlign: 'center' }}>{r.uf}</Tag>
<Text>{NOME_UF.get(r.uf) ?? 'Sem UF cadastrada'}</Text>
</Flex>
),
},
{
title: 'Pedidos',
dataIndex: 'pedidos',
width: 90,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Clientes',
dataIndex: 'clientes',
width: 90,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Faturamento',
dataIndex: 'faturamento',
width: 150,
align: 'right',
render: (v: number) => (
<Text strong className="tabular-nums">
{fmt(v)}
</Text>
),
},
{
title: 'Participação',
dataIndex: 'pct',
width: 170,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor="var(--jcs-blue)"
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 48 }}>
{pct.toLocaleString('pt-BR')}%
</Text>
</Flex>
),
},
];
}
export function MapaBrasilCard({
dados,
periodoLabel,
}: {
dados: UfFaturamento[];
periodoLabel: string;
}) {
const [detalheAberto, setDetalheAberto] = useState(false);
const [ufsExpandidas, setUfsExpandidas] = useState<string[]>([]);
const [ufHover, setUfHover] = useState<string | null>(null);
const porUf = useMemo(() => new Map(dados.map((d) => [d.uf, d])), [dados]);
const max = dados.reduce((m, d) => Math.max(m, d.faturamento), 0);
const comVenda = dados.filter((d) => d.faturamento > 0 && d.uf !== 'ND');
const topUfs = comVenda.slice(0, 8);
const semUf = dados.find((d) => d.uf === 'ND');
const abrirDetalhe = (uf?: string) => {
const primeira = comVenda[0]?.uf;
setUfsExpandidas(uf ? [uf] : primeira ? [primeira] : []);
setDetalheAberto(true);
};
return (
<Card
title={
<Flex align="center" gap={8}>
<FontAwesomeIcon icon={faMapLocationDot} style={{ color: 'var(--jcs-blue)' }} />
Faturamento por Estado
</Flex>
}
extra={
<Button type="primary" ghost size="small" onClick={() => abrirDetalhe()}>
Detalhar
</Button>
}
style={{ height: '100%' }}
styles={{ body: { paddingTop: 12 } }}
>
{dados.length === 0 ? (
<Empty description="Nenhuma venda no período." image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<Flex gap={16} wrap="wrap">
{/* Mapa choropleth */}
<Flex vertical gap={8} style={{ flex: '1 1 260px', minWidth: 240 }}>
<svg
viewBox={brazilMap.viewBox}
role="img"
aria-label={`Mapa do Brasil com faturamento por estado — ${periodoLabel}`}
style={{ width: '100%', height: 'auto', maxHeight: 330 }}
>
{brazilMap.locations.map((loc) => {
const uf = loc.id.toUpperCase();
const dado = porUf.get(uf);
const valor = dado?.faturamento ?? 0;
const hover = ufHover === uf;
return (
<Tooltip
key={loc.id}
title={
<Flex vertical gap={2}>
<Text strong style={{ color: '#fff' }}>
{loc.name} ({uf})
</Text>
{dado ? (
<>
<span>
{fmt(valor)} · {dado.pct.toLocaleString('pt-BR')}% do total
</span>
<span>
{dado.pedidos} pedido{dado.pedidos !== 1 ? 's' : ''} · {dado.clientes}{' '}
cliente{dado.clientes !== 1 ? 's' : ''}
</span>
</>
) : (
<span>Sem vendas no período</span>
)}
</Flex>
}
>
<path
d={loc.path}
fill={corDaUf(valor, max)}
stroke={hover ? 'var(--jcs-blue-hover)' : '#fff'}
strokeWidth={hover ? 2 : 1}
style={{ cursor: dado ? 'pointer' : 'default', transition: 'fill 0.15s' }}
onMouseEnter={() => setUfHover(uf)}
onMouseLeave={() => setUfHover(null)}
onClick={() => dado && abrirDetalhe(uf)}
/>
</Tooltip>
);
})}
</svg>
{/* Legenda da escala */}
<Flex align="center" gap={6} style={{ paddingInline: 4 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Sem venda
</Text>
<span
style={{
width: 14,
height: 10,
background: COR_SEM_VENDA,
border: '1px solid #d9d9d9',
borderRadius: 2,
}}
/>
<span style={{ width: 8 }} />
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Menor
</Text>
{SEQ_STEPS.map((c) => (
<span key={c} style={{ width: 14, height: 10, background: c, borderRadius: 2 }} />
))}
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Maior
</Text>
</Flex>
</Flex>
{/* Ranking de estados */}
<Flex vertical gap={6} style={{ flex: '1 1 190px', minWidth: 180 }}>
{topUfs.map((d, i) => (
<Flex
key={d.uf}
align="center"
gap={8}
style={{
padding: '4px 8px',
borderRadius: 6,
background: ufHover === d.uf ? 'var(--jcs-blue-light)' : undefined,
cursor: 'pointer',
}}
onMouseEnter={() => setUfHover(d.uf)}
onMouseLeave={() => setUfHover(null)}
onClick={() => abrirDetalhe(d.uf)}
>
<Text
type="secondary"
className="tabular-nums"
style={{ width: 16, fontSize: 'var(--text-xs)' }}
>
{i + 1}º
</Text>
<span
style={{
width: 10,
height: 10,
borderRadius: 2,
background: corDaUf(d.faturamento, max),
border: '1px solid rgba(0,0,0,0.08)',
flexShrink: 0,
}}
/>
<Text strong style={{ width: 28 }}>
{d.uf}
</Text>
<Text
className="tabular-nums"
style={{ marginLeft: 'auto', fontSize: 'var(--text-sm)' }}
>
{fmtCompacto(d.faturamento)}
</Text>
<Text
type="secondary"
className="tabular-nums"
style={{ width: 44, textAlign: 'right', fontSize: 'var(--text-xs)' }}
>
{d.pct.toLocaleString('pt-BR')}%
</Text>
</Flex>
))}
{comVenda.length > topUfs.length && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', paddingInline: 8 }}>
+ {comVenda.length - topUfs.length} outro
{comVenda.length - topUfs.length !== 1 ? 's' : ''} estado
{comVenda.length - topUfs.length !== 1 ? 's' : ''} ver Detalhar
</Text>
)}
{semUf && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', paddingInline: 8 }}>
{fmtCompacto(semUf.faturamento)} sem UF cadastrada
</Text>
)}
</Flex>
</Flex>
)}
{/* Detalhe por estado → representantes */}
<Modal
title={
<Flex align="center" gap={8}>
<FontAwesomeIcon icon={faMapLocationDot} style={{ color: 'var(--jcs-blue)' }} />
Faturamento por Estado &mdash; {periodoLabel}
</Flex>
}
open={detalheAberto}
onCancel={() => setDetalheAberto(false)}
footer={null}
width={900}
centered
>
<Table<UfFaturamento>
rowKey="uf"
columns={detalheColumns()}
dataSource={dados}
pagination={false}
size="small"
scroll={{ y: 420 }}
expandable={{
expandedRowKeys: ufsExpandidas,
onExpandedRowsChange: (keys) => setUfsExpandidas([...keys] as string[]),
expandedRowRender: (r) => (
<Table<UfRep>
rowKey="codVendedor"
columns={repColumns}
dataSource={r.reps}
pagination={false}
size="small"
/>
),
}}
locale={{ emptyText: 'Nenhuma venda no período.' }}
/>
</Modal>
</Card>
);
}

View File

@@ -11,6 +11,7 @@ import {
Select,
Skeleton,
Space,
Switch,
Table,
Tabs,
Tag,
@@ -23,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,
@@ -44,140 +37,81 @@ import {
useUpdateRegraDesconto,
useDeleteRegraDesconto,
} from '../../lib/queries/gerente';
import { useCatalog, usePautas } from '../../lib/queries/catalog';
const { Title, Text } = Typography;
// ─── ProdutoSelect: busca remota por código ou descrição ─────────────────────
// ─── Tab Descontos ────────────────────────────────────────────────────────────
function ProdutoSelect({
multiple,
value,
onChange,
}: {
multiple?: boolean;
value?: string[] | string;
onChange?: (v: string[] | string) => void;
}) {
const [q, setQ] = useState('');
const { data, isFetching } = useCatalog({ q: q || undefined, limit: 20 });
// Guarda os rótulos dos já selecionados — a lista de opções muda a cada busca
const [labels, setLabels] = useState<Map<string, string>>(new Map());
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);
const opts = (data?.data ?? []).map((p) => ({
value: p.codigo.trim(),
label: `${p.codigo.trim()}${p.descricao.trim()}`,
}));
const selecionados = multiple
? ((value as string[] | undefined) ?? [])
: value
? [value as string]
: [];
const merged = [...opts];
for (const v of selecionados) {
if (!merged.some((o) => o.value === v)) merged.push({ value: v, label: labels.get(v) ?? v });
}
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>
</>
<Select
mode={multiple ? 'multiple' : undefined}
showSearch
allowClear
filterOption={false}
onSearch={setQ}
loading={isFetching}
options={merged}
value={value}
onChange={(vals) => {
setLabels((prev) => {
const next = new Map(prev);
for (const o of opts) next.set(o.value, o.label);
return next;
});
setQ('');
onChange?.(vals as string[] | string);
}}
placeholder="Busque por código ou descrição"
notFoundContent={q && !isFetching ? 'Nenhum produto encontrado' : null}
/>
);
}
const { Title, Text } = Typography;
// ─── Tab Promocoes ────────────────────────────────────────────────────────────
interface PromocaoForm {
descricao: string;
codProdutos?: string[];
grpProds?: string[];
codProduto?: string;
grpProd?: string;
descPct: number;
periodo: [dayjs.Dayjs, dayjs.Dayjs];
ativa?: boolean;
}
function TabPromocoes() {
const { data, isLoading } = usePromocoes();
const { data: gruposData } = useGruposProduto();
const createMutation = useCreatePromocao();
const updateMutation = useUpdatePromocao();
const deleteMutation = useDeletePromocao();
@@ -186,6 +120,18 @@ function TabPromocoes() {
const [editTarget, setEditTarget] = useState<Promocao | null>(null);
const [msg, msgCtx] = message.useMessage();
// Grupos com nome+código no rótulo — busca funciona pelos dois
const grupoOptions = [
...new Map(
(gruposData?.grupos ?? [])
.filter((g) => g.codGrupo != null)
.map((g) => [
g.codGrupo,
{ value: String(g.codGrupo), label: `${g.codGrupo}${g.grupo ?? 'Sem nome'}` },
]),
).values(),
];
function openCreate() {
setEditTarget(null);
form.resetFields();
@@ -200,27 +146,49 @@ function TabPromocoes() {
grpProd: p.grpProd ?? undefined,
descPct: p.descPct,
periodo: [dayjs(p.dataInicio), dayjs(p.dataFim)],
ativa: p.ativa,
});
setModalOpen(true);
}
async function toggleAtiva(p: Promocao, ativa: boolean) {
try {
await updateMutation.mutateAsync({ id: p.id, body: { ativa } });
} catch {
return; // erro já notificado pelo handler global do QueryClient
}
void msg.success(ativa ? 'Promocao ativada' : 'Promocao desativada');
}
async function handleSubmit(values: PromocaoForm) {
const [inicio, fim] = values.periodo;
const body = {
descricao: values.descricao,
codProduto: values.codProduto || undefined,
grpProd: values.grpProd || undefined,
descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'),
};
try {
if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, body });
await updateMutation.mutateAsync({
id: editTarget.id,
body: {
descricao: values.descricao,
codProduto: values.codProduto ?? null,
grpProd: values.grpProd ?? null,
descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'),
ativa: values.ativa ?? true,
},
});
void msg.success('Promocao atualizada');
} else {
await createMutation.mutateAsync(body);
void msg.success('Promocao criada');
const res = (await createMutation.mutateAsync({
descricao: values.descricao,
codProdutos: values.codProdutos?.length ? values.codProdutos : undefined,
grpProds: values.grpProds?.length ? values.grpProds : undefined,
descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'),
})) as { ids: number[] };
void msg.success(
res.ids.length > 1 ? `${res.ids.length} promocoes criadas` : 'Promocao criada',
);
}
} catch {
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
@@ -280,6 +248,19 @@ function TabPromocoes() {
return <Tag color="green">Ativa</Tag>;
},
},
{
title: 'Ativa',
width: 70,
align: 'center' as const,
render: (_: unknown, row: Promocao) => (
<Switch
size="small"
checked={row.ativa}
loading={updateMutation.isPending}
onChange={(checked) => void toggleAtiva(row, checked)}
/>
),
},
{
title: '',
width: 100,
@@ -337,7 +318,8 @@ function TabPromocoes() {
onOk={() => void form.validateFields().then(handleSubmit)}
okText={editTarget ? 'Salvar' : 'Criar'}
confirmLoading={createMutation.isPending || updateMutation.isPending}
width={520}
width={760}
styles={{ body: { minHeight: 380, maxHeight: '72vh', overflowY: 'auto' } }}
centered
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
@@ -349,14 +331,49 @@ function TabPromocoes() {
<Input placeholder="Ex.: Promocao de inverno" />
</Form.Item>
<Flex gap={16}>
<Form.Item name="codProduto" label="Codigo do produto" style={{ flex: 1 }}>
<Input placeholder="Opcional" />
</Form.Item>
<Form.Item name="grpProd" label="Grupo de produto" style={{ flex: 1 }}>
<Input placeholder="Opcional" />
</Form.Item>
</Flex>
{editTarget ? (
<Flex gap={16}>
<Form.Item name="codProduto" label="Produto" style={{ flex: 1 }}>
<ProdutoSelect />
</Form.Item>
<Form.Item name="grpProd" label="Grupo de produto" style={{ flex: 1 }}>
<Select
options={grupoOptions}
placeholder="Opcional"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
<Form.Item name="ativa" label="Ativa" valuePropName="checked">
<Switch />
</Form.Item>
</Flex>
) : (
<>
<Form.Item
name="codProdutos"
label="Produtos"
extra="Busque por código ou descrição e selecione quantos quiser — será criada uma promoção por produto"
>
<ProdutoSelect multiple />
</Form.Item>
<Form.Item
name="grpProds"
label="Grupos de produto"
extra="Busque por nome ou código — uma promoção por grupo selecionado"
>
<Select
mode="multiple"
options={grupoOptions}
placeholder="Busque por nome ou código do grupo"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
</>
)}
<Form.Item
name="descPct"
@@ -388,16 +405,22 @@ function TabPromocoes() {
interface RegraForm {
descricao: string;
codVendedor?: number;
codGrupos?: number[];
codSubgrupos?: number[];
idPautas?: number[];
codGrupo?: number;
codSubgrupo?: number;
idPauta?: number;
descPct: number;
periodo: [dayjs.Dayjs, dayjs.Dayjs];
ativa?: boolean;
}
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();
@@ -406,27 +429,43 @@ function TabRegras() {
const [editTarget, setEditTarget] = useState<RegraDesconto | null>(null);
const [msg, msgCtx] = message.useMessage();
const grupoSelecionado = Form.useWatch('codGrupo', form);
const gruposSelecionados = Form.useWatch('codGrupos', form);
const repOptions = (equipe?.reps ?? []).map((r: RepStats) => ({
value: 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.
// Subgrupos filtram pelos grupos escolhidos (single na edição, multi na criação).
const filtroGrupos = new Set<number>([
...(grupoSelecionado != null ? [grupoSelecionado] : []),
...(gruposSelecionados ?? []),
]);
const grupoRows: GrupoProdutoItem[] = gruposData?.grupos ?? [];
const grupoMap = new Map<number, { value: number; label: string }>();
const subgrupoMap = new Map<number, { value: number; label: string }>();
for (const g of grupoRows) {
if (g.codGrupo != null && !grupoMap.has(g.codGrupo)) {
grupoMap.set(g.codGrupo, { value: g.codGrupo, label: g.grupo ?? `Grupo ${g.codGrupo}` });
grupoMap.set(g.codGrupo, {
value: g.codGrupo,
label: `${g.codGrupo}${g.grupo ?? 'Sem nome'}`,
});
}
if (
g.codSubgrupo != null &&
(grupoSelecionado == null || g.codGrupo === grupoSelecionado) &&
(filtroGrupos.size === 0 || (g.codGrupo != null && filtroGrupos.has(g.codGrupo))) &&
!subgrupoMap.has(g.codSubgrupo)
) {
subgrupoMap.set(g.codSubgrupo, {
value: g.codSubgrupo,
label: g.subgrupo ?? `Subgrupo ${g.codSubgrupo}`,
label: `${g.codSubgrupo} ${g.subgrupo ?? 'Sem nome'}`,
});
}
}
@@ -446,30 +485,54 @@ 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,
});
setModalOpen(true);
}
async function toggleAtiva(r: RegraDesconto, ativa: boolean) {
try {
await updateMutation.mutateAsync({ id: r.id, body: { ativa } });
} catch {
return; // erro já notificado pelo handler global do QueryClient
}
void msg.success(ativa ? 'Regra ativada' : 'Regra desativada');
}
async function handleSubmit(values: RegraForm) {
const [inicio, fim] = values.periodo;
const body = {
descricao: values.descricao,
codVendedor: values.codVendedor ?? null,
codGrupo: values.codGrupo ?? null,
codSubgrupo: values.codSubgrupo ?? null,
descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'),
};
try {
if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, body });
await updateMutation.mutateAsync({
id: editTarget.id,
body: {
descricao: values.descricao,
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'),
ativa: values.ativa ?? true,
},
});
void msg.success('Regra atualizada');
} else {
await createMutation.mutateAsync(body);
void msg.success('Regra criada');
const res = (await createMutation.mutateAsync({
descricao: values.descricao,
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'),
})) as { ids: number[] };
void msg.success(res.ids.length > 1 ? `${res.ids.length} regras criadas` : 'Regra criada');
}
} catch {
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
@@ -505,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 = [
@@ -515,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',
@@ -541,6 +614,19 @@ function TabRegras() {
return <Tag color="green">Ativa</Tag>;
},
},
{
title: 'Ativa',
width: 70,
align: 'center' as const,
render: (_: unknown, row: RegraDesconto) => (
<Switch
size="small"
checked={row.ativa}
loading={updateMutation.isPending}
onChange={(checked) => void toggleAtiva(row, checked)}
/>
),
},
{
title: '',
width: 100,
@@ -591,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
@@ -602,7 +688,8 @@ function TabRegras() {
onOk={() => void form.validateFields().then(handleSubmit)}
okText={editTarget ? 'Salvar' : 'Criar'}
confirmLoading={createMutation.isPending || updateMutation.isPending}
width={520}
width={760}
styles={{ body: { minHeight: 380, maxHeight: '72vh', overflowY: 'auto' } }}
centered
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
@@ -624,27 +711,88 @@ function TabRegras() {
/>
</Form.Item>
<Flex gap={16}>
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
<Select
options={grupoOptions}
placeholder="Todos os grupos"
allowClear
showSearch
optionFilterProp="label"
onChange={() => form.setFieldValue('codSubgrupo', undefined)}
/>
</Form.Item>
<Form.Item name="codSubgrupo" label="Subgrupo" style={{ flex: 1 }}>
<Select
options={subgrupoOptions}
placeholder="Todos os subgrupos"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
</Flex>
{editTarget ? (
<>
<Flex gap={16}>
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
<Select
options={grupoOptions}
placeholder="Todos os grupos"
allowClear
showSearch
optionFilterProp="label"
onChange={() => form.setFieldValue('codSubgrupo', undefined)}
/>
</Form.Item>
<Form.Item name="codSubgrupo" label="Subgrupo" style={{ flex: 1 }}>
<Select
options={subgrupoOptions}
placeholder="Todos os subgrupos"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
<Form.Item name="ativa" label="Ativa" valuePropName="checked">
<Switch />
</Form.Item>
</Flex>
<Form.Item name="idPauta" label="Pauta de precos">
<Select
options={pautaOptions}
placeholder="Todas as pautas"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
</>
) : (
<>
<Form.Item
name="codGrupos"
label="Grupos de produto"
extra="Busque por nome ou código e selecione quantos quiser — uma regra por grupo"
>
<Select
mode="multiple"
options={grupoOptions}
placeholder="Todos os grupos"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
<Form.Item
name="codSubgrupos"
label="Subgrupos"
extra="Uma regra por subgrupo selecionado (a lista filtra pelos grupos escolhidos)"
>
<Select
mode="multiple"
options={subgrupoOptions}
placeholder="Todos os subgrupos"
allowClear
showSearch
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>
</>
)}
<Form.Item
name="descPct"
@@ -675,11 +823,6 @@ function TabRegras() {
export function PoliticasPage() {
const items = [
{
key: 'descontos',
label: 'Desconto por Representante',
children: <TabDescontos />,
},
{
key: 'promocoes',
label: 'Promocoes',
@@ -699,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

@@ -1,398 +0,0 @@
import { useState } from 'react';
import {
Alert,
Badge,
Button,
Card,
Col,
Flex,
Row,
Skeleton,
Space,
Statistic,
Table,
Tag,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import {
ArrowLeftOutlined,
ExclamationCircleOutlined,
FireOutlined,
RiseOutlined,
TeamOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { useNavigate } from '@tanstack/react-router';
import type { CarteiraCliente } from '@sar/api-interface';
import { useReportCarteira } from '../../lib/queries/reports';
const { Title, Text } = Typography;
function fmt(v: number | string): string {
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function fmtDate(v: string | null | undefined): string {
if (!v) return '—';
return new Date(v + 'T00:00:00').toLocaleDateString('pt-BR');
}
type Filtro = 'todos' | 'ativos' | 'risco' | 'inativos' | 'semPedido';
function statusBadge(c: CarteiraCliente) {
if (c.totalPedidos === 0)
return <Badge color="#94A3B8" text={<Text style={{ fontSize: 12 }}>Sem pedido</Text>} />;
if (c.diasSemPedido != null && c.diasSemPedido >= 60)
return (
<Badge
color="#ef4444"
text={<Text style={{ fontSize: 12, color: '#ef4444' }}>Inativo 60d+</Text>}
/>
);
if (c.diasSemPedido != null && c.diasSemPedido >= 30)
return (
<Badge
color="#f59e0b"
text={<Text style={{ fontSize: 12, color: '#f59e0b' }}>Em risco 30d+</Text>}
/>
);
return (
<Badge color="#22c55e" text={<Text style={{ fontSize: 12, color: '#22c55e' }}>Ativo</Text>} />
);
}
function InsightCard({
icon,
cor,
titulo,
descricao,
}: {
icon: React.ReactNode;
cor: string;
titulo: string;
descricao: string;
}) {
return (
<Card
styles={{ body: { padding: '14px 18px' } }}
style={{ borderLeft: `4px solid ${cor}`, borderRadius: 8 }}
>
<Flex gap={12} align="flex-start">
<span style={{ color: cor, fontSize: 18, marginTop: 2 }}>{icon}</span>
{/* minWidth: 0 — flex item precisa poder encolher; titulo/descricao
embutem razão social e valores que podem não ter ponto de quebra. */}
<div style={{ minWidth: 0 }}>
<Text strong style={{ fontSize: 13, color: '#1e293b' }}>
{titulo}
</Text>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
{descricao}
</Text>
</div>
</div>
</Flex>
</Card>
);
}
export function CarteirePage() {
const navigate = useNavigate();
const { data, isLoading, isError, error } = useReportCarteira();
const [filtro, setFiltro] = useState<Filtro>('todos');
const clientes = data?.data ?? [];
const ativos = clientes.filter(
(c) => c.totalPedidos > 0 && (c.diasSemPedido == null || c.diasSemPedido < 30),
);
const emRisco = clientes.filter(
(c) => c.diasSemPedido != null && c.diasSemPedido >= 30 && c.diasSemPedido < 60,
);
const inativos = clientes.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60);
const semPedido = clientes.filter((c) => c.totalPedidos === 0);
const filtered = (() => {
if (filtro === 'ativos') return ativos;
if (filtro === 'risco') return emRisco;
if (filtro === 'inativos') return inativos;
if (filtro === 'semPedido') return semPedido;
return clientes;
})();
// Insights calculados
const top5Fat = clientes
.slice()
.sort((a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal))
.slice(0, 5);
const top5Pct = top5Fat.reduce((acc, c) => acc + c.participacaoPct, 0);
const maiorCliEmRisco = emRisco.sort(
(a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal),
)[0];
const maiorCliInativo = inativos.sort(
(a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal),
)[0];
const insights: { icon: React.ReactNode; cor: string; titulo: string; descricao: string }[] = [];
if (top5Pct >= 60) {
insights.push({
icon: <ExclamationCircleOutlined />,
cor: '#f59e0b',
titulo: `Concentração: top 5 clientes = ${top5Pct.toFixed(0)}% do faturamento`,
descricao: 'Risco alto de dependência. Considere expandir outros clientes da carteira.',
});
}
if (maiorCliEmRisco) {
const nome =
maiorCliEmRisco.razao ?? maiorCliEmRisco.nome ?? `Cliente ${maiorCliEmRisco.idCliente}`;
insights.push({
icon: <WarningOutlined />,
cor: '#f59e0b',
titulo: `${nome} está ${maiorCliEmRisco.diasSemPedido}d sem comprar`,
descricao: `Faturamento histórico: ${fmt(maiorCliEmRisco.faturamentoTotal)} — priorize o contato.`,
});
}
if (maiorCliInativo) {
const nome =
maiorCliInativo.razao ?? maiorCliInativo.nome ?? `Cliente ${maiorCliInativo.idCliente}`;
insights.push({
icon: <FireOutlined />,
cor: '#ef4444',
titulo: `${nome} inativo há ${maiorCliInativo.diasSemPedido}d`,
descricao: `Era um cliente de ${fmt(maiorCliInativo.faturamentoTotal)} — vale reativar.`,
});
}
if (semPedido.length > 0) {
insights.push({
icon: <RiseOutlined />,
cor: '#003B8E',
titulo: `${semPedido.length} cliente${semPedido.length > 1 ? 's' : ''} sem nenhum pedido`,
descricao: 'Potencial inexplorado na sua carteira. Primeira visita pode gerar receita nova.',
});
}
const columns: TableColumnsType<CarteiraCliente> = [
{
title: 'Cliente',
key: 'cliente',
render: (_: unknown, c: CarteiraCliente) => (
<Space orientation="vertical" size={0}>
<Text
strong
style={{ fontSize: 13, cursor: 'pointer', color: '#003B8E' }}
onClick={() => navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })}
>
{c.razao ?? c.nome ?? `Cód. ${c.idCliente}`}
</Text>
{c.razao && c.nome && (
<Text type="secondary" style={{ fontSize: 11 }}>
{c.nome}
</Text>
)}
</Space>
),
},
{
title: 'Status',
key: 'status',
width: 140,
render: (_: unknown, c: CarteiraCliente) => statusBadge(c),
},
{
title: 'Último Pedido',
dataIndex: 'ultimoPedido',
width: 130,
render: (v: string | null) => (
<Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>
),
sorter: (a, b) => (a.ultimoPedido ?? '').localeCompare(b.ultimoPedido ?? ''),
},
{
title: 'Dias Sem Compra',
dataIndex: 'diasSemPedido',
width: 140,
align: 'right' as const,
render: (v: number | null) =>
v != null ? (
<Text
strong
className="tabular-nums"
style={{ color: v >= 60 ? '#ef4444' : v >= 30 ? '#f59e0b' : '#22c55e' }}
>
{v}d
</Text>
) : (
<Text type="secondary"></Text>
),
sorter: (a, b) => (a.diasSemPedido ?? 9999) - (b.diasSemPedido ?? 9999),
},
{
title: 'Faturado (total)',
dataIndex: 'faturamentoTotal',
width: 150,
align: 'right' as const,
render: (v: string) => (
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{fmt(v)}
</Text>
),
sorter: (a, b) => Number(a.faturamentoTotal) - Number(b.faturamentoTotal),
defaultSortOrder: 'descend' as const,
},
{
title: 'Participação',
dataIndex: 'participacaoPct',
width: 110,
align: 'right' as const,
render: (v: number) => (
<Tag
color={v >= 10 ? 'blue' : v >= 5 ? 'geekblue' : 'default'}
style={{ borderRadius: 20, fontWeight: 600 }}
>
{v.toFixed(1)}%
</Tag>
),
sorter: (a, b) => a.participacaoPct - b.participacaoPct,
},
{
title: 'Ticket Médio',
dataIndex: 'ticketMedio',
width: 130,
align: 'right' as const,
render: (v: string) => (
<Text className="tabular-nums" style={{ color: '#475569' }}>
{fmt(v)}
</Text>
),
sorter: (a, b) => Number(a.ticketMedio) - Number(b.ticketMedio),
},
{
title: 'Pedidos',
dataIndex: 'totalPedidos',
width: 80,
align: 'right' as const,
render: (v: number) => <Text className="tabular-nums">{v}</Text>,
sorter: (a, b) => a.totalPedidos - b.totalPedidos,
},
];
const filtros: { key: Filtro; label: string; count: number; cor: string }[] = [
{ key: 'todos', label: 'Todos', count: clientes.length, cor: '#64748b' },
{ key: 'ativos', label: 'Ativos', count: ativos.length, cor: '#22c55e' },
{ key: 'risco', label: 'Em risco (30d+)', count: emRisco.length, cor: '#f59e0b' },
{ key: 'inativos', label: 'Inativos (60d+)', count: inativos.length, cor: '#ef4444' },
{ key: 'semPedido', label: 'Sem pedido', count: semPedido.length, cor: '#94A3B8' },
];
return (
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
{/* Cabeçalho */}
<Flex align="center" gap={12} style={{ marginBottom: 24 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate({ to: '/clientes' })}
type="text"
style={{ color: '#64748b' }}
/>
<div>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
<TeamOutlined style={{ marginRight: 8 }} />
Carteira de Clientes
</Title>
<Text type="secondary" style={{ fontSize: 13 }}>
Visão completa da sua carteira com indicadores de saúde e oportunidades
</Text>
</div>
</Flex>
{isError && (
<Alert
type="error"
showIcon
message="Erro ao carregar carteira"
description={error instanceof Error ? error.message : 'Erro desconhecido'}
style={{ marginBottom: 24 }}
/>
)}
{isLoading ? (
<Skeleton active paragraph={{ rows: 10 }} />
) : (
<>
{/* Cards de resumo */}
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{filtros.map((f) => (
<Col key={f.key} xs={12} sm={8} md={4} style={{ minWidth: 120 }}>
<Card
hoverable
onClick={() => setFiltro(f.key)}
styles={{ body: { padding: '12px 16px' } }}
style={{
borderRadius: 8,
border: filtro === f.key ? `2px solid ${f.cor}` : '1px solid #EBF0F5',
cursor: 'pointer',
}}
>
<Statistic
title={f.label}
value={f.count}
styles={{ content: { color: f.cor, fontSize: 24 } }}
/>
</Card>
</Col>
))}
{data && (
<Col xs={12} sm={8} md={6}>
<Card styles={{ body: { padding: '12px 16px' } }} style={{ borderRadius: 8 }}>
<Statistic
title="Faturamento Total (ERP)"
value={fmt(data.faturamentoRepTotal)}
styles={{ content: { color: '#003B8E', fontSize: 18 } }}
/>
</Card>
</Col>
)}
</Row>
{/* Insights */}
{insights.length > 0 && (
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{insights.map((ins, i) => (
<Col key={i} xs={24} md={12}>
<InsightCard {...ins} />
</Col>
))}
</Row>
)}
{/* Tabela */}
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: 0 } }}
>
<Table<CarteiraCliente>
rowKey="idCliente"
columns={columns}
dataSource={filtered}
size="middle"
pagination={{ pageSize: 20, showSizeChanger: false }}
scroll={{ x: 900 }}
style={{ borderRadius: 10, overflow: 'hidden' }}
onRow={(c) => ({
onClick: () =>
navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } }),
style: { cursor: 'pointer' },
})}
/>
</Card>
</>
)}
</div>
);
}

View File

@@ -161,7 +161,10 @@ export function CatalogPage() {
}}
options={pautas?.map((p) => ({
value: p.idPauta,
label: `${p.codigo}${p.descricao}`,
label:
p.qtdReps != null
? `${p.codigo}${p.descricao} · ${p.qtdReps} rep${p.qtdReps !== 1 ? 's' : ''}`
: `${p.codigo}${p.descricao}`,
}))}
/>
</Space>

View File

@@ -35,16 +35,12 @@ import {
UserOutlined,
WhatsAppOutlined,
} from '@ant-design/icons';
import { Doughnut } from 'react-chartjs-2';
import { Chart as ChartJS, ArcElement, Tooltip as ChartTooltip, Legend } from 'chart.js';
import { useNavigate } from '@tanstack/react-router';
import type { ActivityStatus, ClientSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useClientList, useClientDetail } from '../../lib/queries/clients';
import { useClientOrders } from '../../lib/queries/orders';
ChartJS.register(ArcElement, ChartTooltip, Legend);
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
@@ -184,150 +180,6 @@ function CustomerMetrics({ stats }: { stats: PortfolioStats }) {
);
}
// ─── CustomerPortfolioCard ────────────────────────────────────────────────────
function CustomerPortfolioCard({
stats,
onDetalhar,
}: {
stats: PortfolioStats;
onDetalhar: () => void;
}) {
const mesAtual = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
const total = stats.ativos + stats.emAlerta + stats.inativos;
const donutData = {
labels: ['Ativos', 'Em alerta', 'Inativos'],
datasets: [
{
data: [stats.ativos, stats.emAlerta, stats.inativos],
backgroundColor: ['#52C41A', '#FAAD14', '#FF4D4F'],
borderColor: '#fff',
borderWidth: 3,
hoverOffset: 6,
},
],
};
const donutOptions = {
responsive: true,
maintainAspectRatio: true,
cutout: '68%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx: { label: string; raw: unknown }) => {
const v = ctx.raw as number;
const pct = total > 0 ? ((v / total) * 100).toFixed(1) : '0.0';
return `${ctx.label}: ${v.toLocaleString('pt-BR')} (${pct}%)`;
},
},
},
},
};
const legendItems = [
{ label: 'Ativos', value: stats.ativos, color: '#52C41A' },
{ label: 'Em alerta', value: stats.emAlerta, color: '#FAAD14' },
{ label: 'Inativos', value: stats.inativos, color: '#FF4D4F' },
];
return (
<Card
style={{
borderRadius: 12,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 6px rgba(0,0,0,0.07)',
}}
styles={{ body: { padding: '20px 20px 16px' } }}
>
<div style={{ marginBottom: 16 }}>
<Text strong style={{ fontSize: 14, color: '#1F2937', display: 'block' }}>
Carteira de Clientes
</Text>
<Text style={{ fontSize: 12, color: '#64748B', textTransform: 'capitalize' }}>
{mesAtual}
</Text>
</div>
<div style={{ position: 'relative', width: 180, margin: '0 auto 16px' }}>
{stats.loaded && total > 0 ? (
<>
<Doughnut data={donutData} options={donutOptions} />
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%,-50%)',
textAlign: 'center',
pointerEvents: 'none',
}}
>
<Text
strong
style={{ fontSize: 22, color: '#1F2937', display: 'block', lineHeight: 1.1 }}
>
{stats.total.toLocaleString('pt-BR')}
</Text>
<Text style={{ fontSize: 11, color: '#64748B' }}>Clientes</Text>
</div>
</>
) : (
<div
style={{ height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<Spin />
</div>
)}
</div>
<Space orientation="vertical" size={6} style={{ width: '100%', marginBottom: 16 }}>
{legendItems.map((item) => {
const pct = total > 0 ? ((item.value / total) * 100).toFixed(1) : '0.0';
return (
<div
key={item.label}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<Space size={6}>
<div
style={{
width: 10,
height: 10,
borderRadius: 2,
background: item.color,
flexShrink: 0,
}}
/>
<Text style={{ fontSize: 12, color: '#475569' }}>{item.label}</Text>
</Space>
<Space size={8}>
<Text strong style={{ fontSize: 12 }}>
{item.value.toLocaleString('pt-BR')}
</Text>
<Text style={{ fontSize: 11, color: '#94A3B8', width: 44, textAlign: 'right' }}>
{pct}%
</Text>
</Space>
</div>
);
})}
</Space>
<Divider style={{ margin: '12px 0' }} />
<Button
block
onClick={onDetalhar}
style={{ borderRadius: 8, fontWeight: 600, color: '#003B8E', borderColor: '#003B8E' }}
>
Detalhar carteira
</Button>
</Card>
);
}
// ─── CustomerExpandedDetail ───────────────────────────────────────────────────
function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) {
@@ -1460,22 +1312,9 @@ export function ClientsPage() {
</Row>
</Card>
{/* ── Portfolio mobile (antes da lista) ─────────────────────────── */}
{isMobile && (
<>
<CustomerPortfolioCard
stats={stats}
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
/>
<div style={{ marginBottom: 16 }} />
</>
)}
{/* ── Área principal ────────────────────────────────────────────── */}
<Row gutter={[20, 20]}>
{/* minWidth: 0 — Col é flex item (min-width auto). Sem isto a tabela com
scroll x estoura a coluna e engole o card vizinho. */}
<Col xs={24} lg={17} style={{ minWidth: 0 }}>
<Col xs={24} style={{ minWidth: 0 }}>
{isLoading ? (
<div style={{ textAlign: 'center', padding: 64 }}>
<Spin size="large" />
@@ -1548,15 +1387,6 @@ export function ClientsPage() {
</Card>
)}
</Col>
{!isMobile && (
<Col lg={7}>
<CustomerPortfolioCard
stats={stats}
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
/>
</Col>
)}
</Row>
{/* ── Modais ────────────────────────────────────────────────────── */}

View File

@@ -0,0 +1,146 @@
import { useState } from 'react';
import { Button, Card, Divider, Flex, Space, Tag, Typography } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronDown, faTags } from '@fortawesome/free-solid-svg-icons';
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
import { usePromocoesVigentes, useRegrasVigentes } from '../../lib/queries/politicas';
const { Text } = Typography;
function fmtData(iso: string): string {
return new Date(`${iso}T00:00:00`).toLocaleDateString('pt-BR');
}
function alvoPromocao(p: PromocaoVigente): string {
if (p.codProduto) return p.nomeProduto ?? `Produto ${p.codProduto}`;
if (p.grpProd) return `Grupo ${p.grupo ?? p.grpProd}`;
return 'Todos os produtos';
}
function alvoRegra(r: RegraVigente): string {
if (r.codGrupo != null) return `Grupo ${r.grupo ?? r.codGrupo}`;
if (r.codSubgrupo != null) return `Subgrupo ${r.subgrupo ?? r.codSubgrupo}`;
return 'Todos os produtos';
}
function LinhaCondicao({
tipo,
descricao,
alvo,
descPct,
dataFim,
}: {
tipo: 'promocao' | 'regra';
descricao: string;
alvo: string;
descPct: number;
dataFim: string;
}) {
return (
<Flex justify="space-between" align="center" gap={12} wrap>
<Space orientation="vertical" size={0}>
<Space size={8}>
<Tag
color={tipo === 'promocao' ? 'orange' : 'blue'}
style={{ borderRadius: 20, fontSize: 10, fontWeight: 700, marginInlineEnd: 0 }}
>
{tipo === 'promocao' ? 'PROMOÇÃO' : 'DESCONTO LIBERADO'}
</Tag>
<Text strong>{descricao}</Text>
</Space>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{alvo}
</Text>
</Space>
<Space size={8}>
<Tag color="green" className="tabular-nums" style={{ borderRadius: 20, fontWeight: 700 }}>
{descPct}% off
</Tag>
<Text type="secondary" className="tabular-nums" style={{ fontSize: 'var(--text-xs)' }}>
até {fmtData(dataFim)}
</Text>
</Space>
</Flex>
);
}
// Card expansível do painel do rep: promoções e regras de desconto vigentes
// HOJE para o representante logado. Some sozinho quando a condição encerra ou
// é inativada pelo gerente (refetch a cada minuto nos hooks de vigentes).
export function CondicoesEspeciaisCard() {
const { data: promosData } = usePromocoesVigentes();
const { data: regrasData } = useRegrasVigentes();
const [expandido, setExpandido] = useState(false);
const promocoes = promosData?.promocoes ?? [];
const regras = regrasData?.regras ?? [];
const total = promocoes.length + regras.length;
if (total === 0) return null;
return (
<Card
styles={{ body: expandido ? {} : { display: 'none' } }}
title={
<Flex
justify="space-between"
align="center"
style={{ cursor: 'pointer' }}
onClick={() => setExpandido((v) => !v)}
>
<Space>
<FontAwesomeIcon icon={faTags} style={{ color: 'var(--orange)' }} />
Condições Especiais
<Tag color="orange" style={{ borderRadius: 20, fontWeight: 700 }}>
{total}
</Tag>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', fontWeight: 400 }}>
vigentes hoje
</Text>
</Space>
<Button
type="text"
size="small"
aria-label={expandido ? 'Recolher' : 'Expandir'}
icon={
<FontAwesomeIcon
icon={faChevronDown}
style={{
transition: 'transform 0.2s',
transform: expandido ? 'rotate(180deg)' : undefined,
}}
/>
}
/>
</Flex>
}
>
<Flex vertical gap={12}>
{promocoes.map((p: PromocaoVigente, i: number) => (
<div key={`p-${p.id}`}>
{i > 0 && <Divider style={{ margin: '0 0 12px' }} />}
<LinhaCondicao
tipo="promocao"
descricao={p.descricao}
alvo={alvoPromocao(p)}
descPct={p.descPct}
dataFim={p.dataFim}
/>
</div>
))}
{regras.map((r: RegraVigente, i: number) => (
<div key={`r-${r.id}`}>
{(i > 0 || promocoes.length > 0) && <Divider style={{ margin: '0 0 12px' }} />}
<LinhaCondicao
tipo="regra"
descricao={r.descricao}
alvo={alvoRegra(r)}
descPct={r.descPct}
dataFim={r.dataFim}
/>
</div>
))}
</Flex>
</Card>
);
}

View File

@@ -29,9 +29,12 @@ import {
CheckCircleOutlined,
CloseOutlined,
DeleteOutlined,
DownOutlined,
HistoryOutlined,
PlusOutlined,
SearchOutlined,
ShoppingCartOutlined,
UpOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useNavigate, useSearch } from '@tanstack/react-router';
@@ -41,16 +44,16 @@ import type {
FormaPagamento,
Pauta,
ProdutoSummary,
RegraVigente,
TopProduto,
} from '@sar/api-interface';
import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients';
import { useOrderDetail } from '../../lib/queries/orders';
import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog';
import { useRegrasVigentes } from '../../lib/queries/politicas';
import { useCondicoesComerciais } from '../../lib/condicoes-comerciais';
import { useCondicoesComerciais, useTetoDesconto } from '../../lib/condicoes-comerciais';
import { CondicaoTag } from '../../components/CondicaoTag';
import { apiFetch } from '../../lib/api-client';
import { enqueueOrder } from '../../lib/offline/order-queue';
import { randomUUID } from '../../lib/uuid';
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
@@ -67,9 +70,12 @@ type CartItem = {
precoUnitario: number;
descontoPerc: number;
loteMulVenda: number | null;
// Grupo/subgrupo do produto — usados para calcular o teto de desconto do item
codGrupo: number | null;
codSubgrupo: number | null;
};
type SearchParams = { clientId?: string };
type SearchParams = { clientId?: string; editar?: string };
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -630,17 +636,20 @@ function CatalogBrowserModal({
function MobileCartItem({
item,
teto,
onQtyChange,
onDiscChange,
onRemove,
}: {
item: CartItem;
teto: number;
onQtyChange: (key: string, qty: number) => void;
onDiscChange: (key: string, disc: number) => void;
onRemove: (key: string) => void;
}) {
const lote = item.loteMulVenda ?? 1;
const invalid = lote > 1 && item.qtd % lote !== 0;
const acimaTeto = item.descontoPerc > teto + 0.001;
return (
<div
@@ -712,16 +721,29 @@ function MobileCartItem({
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
Desc%
</Text>
<InputNumber
min={0}
max={100}
step={0.5}
value={item.descontoPerc}
size="small"
style={{ width: 88 }}
addonAfter="%"
onChange={(n) => onDiscChange(item.key, n ?? 0)}
/>
<Tooltip
title={
acimaTeto
? `Acima do teto de ${teto}% — a transmissão será bloqueada`
: `Teto do item: ${teto}%`
}
color={acimaTeto ? 'red' : 'blue'}
>
<InputNumber
min={0}
max={100}
step={0.5}
value={item.descontoPerc}
size="small"
style={{ width: 88 }}
addonAfter="%"
status={acimaTeto ? 'error' : undefined}
onChange={(n) => onDiscChange(item.key, n ?? 0)}
/>
</Tooltip>
<div style={{ fontSize: 10, color: acimaTeto ? '#f5222d' : '#94A3B8', marginTop: 2 }}>
{acimaTeto ? `acima do teto ${teto}%` : `teto: ${teto}%`}
</div>
</div>
<div style={{ marginLeft: 'auto', textAlign: 'right' }}>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
@@ -749,7 +771,8 @@ function ClientHistoryPanel({
}) {
const screens = useBreakpoint();
const isMobile = !screens.md;
const [collapsed, setCollapsed] = useState(false);
// Sempre fecha ao abrir a tela — o banner chamativo convida o rep a expandir
const [collapsed, setCollapsed] = useState(true);
const [qtys, setQtys] = useState<Record<number, number>>({});
const { data: produtos = [], isLoading } = useClientTopProdutos(idCliente, 15);
@@ -910,40 +933,61 @@ function ClientHistoryPanel({
return (
<div style={{ marginTop: 16 }}>
{/* Banner âmbar de destaque — fechado por padrão, convida o rep a abrir */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
gap: 10,
padding: '10px 14px',
background: '#FFF7E6',
border: '1px solid #FFD591',
borderRadius: collapsed ? 8 : '8px 8px 0 0',
cursor: 'pointer',
userSelect: 'none',
}}
onClick={() => setCollapsed((c) => !c)}
>
<Text
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<HistoryOutlined style={{ color: '#D46B08', fontSize: 18, flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<Text
strong
style={{ color: '#873800', fontSize: 13, display: 'block', lineHeight: 1.3 }}
>
Este cliente comprou {produtos.length} produto{produtos.length !== 1 ? 's' : ''}{' '}
com você
</Text>
<Text style={{ color: '#AD6800', fontSize: 11 }}>
Veja o histórico e repita itens no pedido com um clique
</Text>
</div>
</div>
<Button
size="small"
style={{
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#64748B',
borderColor: '#FFD591',
color: '#D46B08',
background: '#fff',
fontWeight: 600,
flexShrink: 0,
}}
icon={collapsed ? <DownOutlined /> : <UpOutlined />}
iconPosition="end"
>
Comprados anteriormente · {produtos.length} produto{produtos.length !== 1 ? 's' : ''}
</Text>
<Button type="link" size="small" style={{ padding: 0, fontSize: 12 }}>
{collapsed ? 'exibir' : 'ocultar'}
{collapsed ? 'Ver produtos' : 'Ocultar'}
</Button>
</div>
{!collapsed && (
<div
style={{
border: '1px solid #EBF0F5',
borderRadius: 8,
border: '1px solid #FFD591',
borderTop: 'none',
borderRadius: '0 0 8px 8px',
overflow: 'hidden',
background: '#FAFBFC',
background: '#FFFDF7',
}}
>
<Table<TopProduto>
@@ -970,11 +1014,13 @@ function ClientHistoryPanel({
function OrderItemsTable({
items,
tetoDe,
onQtyChange,
onDiscChange,
onRemove,
}: {
items: CartItem[];
tetoDe: (item: CartItem) => number;
onQtyChange: (key: string, qty: number) => void;
onDiscChange: (key: string, disc: number) => void;
onRemove: (key: string) => void;
@@ -1015,6 +1061,7 @@ function OrderItemsTable({
<MobileCartItem
key={item.key}
item={item}
teto={tetoDe(item)}
onQtyChange={onQtyChange}
onDiscChange={onDiscChange}
onRemove={onRemove}
@@ -1091,19 +1138,38 @@ function OrderItemsTable({
{
title: 'Desc %',
dataIndex: 'descontoPerc',
width: 100,
render: (v: number, row: CartItem) => (
<InputNumber
min={0}
max={100}
step={0.5}
value={v}
size="small"
style={{ width: 76 }}
addonAfter="%"
onChange={(n) => onDiscChange(row.key, n ?? 0)}
/>
),
width: 110,
render: (v: number, row: CartItem) => {
const teto = tetoDe(row);
const acimaTeto = v > teto + 0.001;
return (
<div>
<Tooltip
title={
acimaTeto
? `Acima do teto de ${teto}% — a transmissão será bloqueada`
: `Teto do item: ${teto}%`
}
color={acimaTeto ? 'red' : 'blue'}
>
<InputNumber
min={0}
max={100}
step={0.5}
value={v}
size="small"
style={{ width: 76 }}
addonAfter="%"
status={acimaTeto ? 'error' : undefined}
onChange={(n) => onDiscChange(row.key, n ?? 0)}
/>
</Tooltip>
<div style={{ fontSize: 10, color: acimaTeto ? '#f5222d' : '#94A3B8', marginTop: 2 }}>
{acimaTeto ? `acima do teto ${teto}%` : `teto: ${teto}%`}
</div>
</div>
);
},
},
{
title: 'Total',
@@ -1251,11 +1317,16 @@ function OrderSummaryFooter({
// ─── NewOrderPage ─────────────────────────────────────────────────────────────
export function NewOrderPage() {
const { clientId: clientIdParam } = useSearch({ strict: false }) as SearchParams;
const { clientId: clientIdParam, editar: editarId } = useSearch({
strict: false,
}) as SearchParams;
const navigate = useNavigate();
const qc = useQueryClient();
const { message } = App.useApp();
// ── Modo edição: orçamento (situa 0) carregado para alteração ──
const { data: editOrder } = useOrderDetail(editarId);
// ── Dados do cliente ──
const [clientSearch, setClientSearch] = useState('');
const [selectedClient, setSelectedClient] = useState<ClientSummary | null>(null);
@@ -1267,8 +1338,11 @@ export function NewOrderPage() {
const { data: selectedClientDetail } = useClientDetail(
!clientIdParam && selectedClient ? selectedClient.idCliente : undefined,
);
const clientDetail = selectedClientDetail ?? preloadedClient ?? null;
const effectiveClient = selectedClient ?? preloadedClient ?? null;
const { data: editClient } = useClientDetail(
editarId && editOrder ? editOrder.idCliente : undefined,
);
const clientDetail = selectedClientDetail ?? preloadedClient ?? editClient ?? null;
const effectiveClient = selectedClient ?? preloadedClient ?? editClient ?? null;
// ── Campos comerciais ──
const { data: pautas = [] } = usePautas();
@@ -1293,6 +1367,39 @@ export function NewOrderPage() {
const [error, setError] = useState<string | null>(null);
const [catalogOpen, setCatalogOpen] = useState(false);
// Pré-carrega o formulário quando entra em modo edição (uma vez só)
const [editLoaded, setEditLoaded] = useState(false);
useEffect(() => {
if (!editarId || !editOrder || editLoaded) return;
setEditLoaded(true);
if (editOrder.situa !== 0) {
setError('Este pedido já foi transmitido e não pode mais ser editado.');
return;
}
setCart(
editOrder.itens.map((it) => ({
key: String(it.idProduto),
idProduto: it.idProduto,
codProduto: it.codProduto ?? '',
descProduto: it.descProduto ?? '',
unidade: '',
qtd: Number(it.qtd),
precoUnitario: Number(it.precoUnitario),
descontoPerc: Number(it.descontoPerc),
loteMulVenda: null,
codGrupo: it.codGrupo ?? null,
codSubgrupo: it.codSubgrupo ?? null,
})),
);
setIdPauta(editOrder.idPauta ?? undefined);
setCodFormapag(editOrder.codFormapag ?? undefined);
setObs(editOrder.obs ?? '');
if (editOrder.endEntrega) {
setEndTipo('outro');
setEndCustom(editOrder.endEntrega);
}
}, [editarId, editOrder, editLoaded]);
// Endereço cadastrado do cliente — usa Detail (tem endereco/bairro/cep), não Summary.
const clientEndStr = (() => {
if (!clientDetail) return '';
@@ -1306,43 +1413,40 @@ export function NewOrderPage() {
})();
const hasClientEnd = !!clientEndStr;
// Teto de desconto por item (alçada + promoção/regra) — aviso em tempo real
const tetoDesconto = useTetoDesconto();
const tetoDe = (it: CartItem) =>
tetoDesconto({ codigo: it.codProduto, codGrupo: it.codGrupo, codSubgrupo: it.codSubgrupo });
const totalPedido = cart.reduce((acc, it) => acc + itemTotal(it), 0);
const loteErrors = cart.filter((it) => {
const lote = it.loteMulVenda ?? 1;
return lote > 1 && it.qtd % lote !== 0;
});
const canSubmit = !!effectiveClient && cart.length > 0 && loteErrors.length === 0;
// Desconto acima do teto bloqueia o Concluir — o backend recusa a criação
// do orçamento de qualquer forma; aqui a mensagem chega antes do clique.
const tetoErrors = cart.filter((it) => it.descontoPerc > tetoDe(it) + 0.001);
const canSubmit =
!!effectiveClient && cart.length > 0 && loteErrors.length === 0 && tetoErrors.length === 0;
const missingReason = !effectiveClient
? 'Selecione um cliente para continuar'
: cart.length === 0
? 'Adicione ao menos um produto ao pedido'
: loteErrors.length > 0
? 'Corrija as quantidades com lote múltiplo'
: undefined;
: tetoErrors.length > 0
? `Desconto acima do teto em: ${tetoErrors.map((it) => it.codProduto.trim()).join(', ')}`
: undefined;
function clearClient() {
setSelectedClient(null);
setClientSearch('');
}
// ── Regras de desconto vigentes (criadas pelo gerente para este rep) ──
const { data: regrasData } = useRegrasVigentes();
// Maior desconto liberado por regra vigente que casa com o grupo/subgrupo do
// produto. Regra sem grupo/subgrupo vale para qualquer produto. Pré-aplicado
// ao adicionar o item; o rep pode ajustar dentro da alçada.
const regraDescontoPct = (codGrupo: number | null, codSubgrupo: number | null): number => {
const regras: RegraVigente[] = regrasData?.regras ?? [];
return regras
.filter(
(r) =>
(r.codGrupo == null || (codGrupo != null && r.codGrupo === codGrupo)) &&
(r.codSubgrupo == null || (codSubgrupo != null && r.codSubgrupo === codSubgrupo)),
)
.reduce((max, r) => Math.max(max, r.descPct), 0);
};
// ── Handlers do carrinho ──
// Item entra SEM desconto mesmo quando há promoção/regra vigente (selo
// "Condição comercial" avisa o rep): a proposta é carta de negociação — o rep
// concede só se o cliente pedir, e a alçada no backend aceita até o % da regra.
const addToCart = (p: ProdutoSummary, qty?: number) => {
const lote = p.loteMulVenda ?? 1;
const finalQty = qty ?? lote;
@@ -1363,8 +1467,10 @@ export function NewOrderPage() {
unidade: p.unidade ?? '',
qtd: finalQty,
precoUnitario: Number(p.vlPreco1),
descontoPerc: regraDescontoPct(p.codGrupo, p.codSubgrupo),
descontoPerc: 0,
loteMulVenda: p.loteMulVenda,
codGrupo: p.codGrupo,
codSubgrupo: p.codSubgrupo,
},
];
});
@@ -1386,9 +1492,11 @@ export function NewOrderPage() {
unidade: p.unidade ?? '',
qtd: qty,
precoUnitario: p.ultimoPreco,
// TopProduto não traz grupo/subgrupo — aplica só regra sem restrição
descontoPerc: regraDescontoPct(null, null),
descontoPerc: 0,
loteMulVenda: null,
// TopProduto não traz grupo/subgrupo — teto considera só alçada default
codGrupo: null,
codSubgrupo: null,
},
];
});
@@ -1416,6 +1524,13 @@ export function NewOrderPage() {
`Quantidade inválida: ${invalidos.map((it) => `${it.codProduto.trim()} (lote múltiplo: ${it.loteMulVenda})`).join(', ')}`,
);
}
// Cobre também o caminho offline (enqueueOrder não passa pelo backend)
const acimaTeto = cart.filter((it) => it.descontoPerc > tetoDe(it) + 0.001);
if (acimaTeto.length > 0) {
throw new Error(
`Desconto acima do teto: ${acimaTeto.map((it) => `${it.codProduto.trim()} (máx. ${tetoDe(it)}%)`).join(', ')}`,
);
}
const obsCompleta = [
contato ? `Contato: ${contato}` : null,
@@ -1435,7 +1550,7 @@ export function NewOrderPage() {
descontoPerc: 0,
obs: obsCompleta || undefined,
endEntrega: endEntregaValue,
idempotencyKey: crypto.randomUUID(),
idempotencyKey: editarId ? undefined : randomUUID(),
itens: cart.map((it, idx) => ({
idProduto: it.idProduto,
codProduto: it.codProduto,
@@ -1447,6 +1562,16 @@ export function NewOrderPage() {
})),
};
// Edição altera o orçamento existente — requer conexão
if (editarId) {
if (!navigator.onLine) {
throw new Error('Edição de orçamento requer conexão. Tente novamente online.');
}
return apiFetch(`/orders/${editarId}`, { method: 'PATCH', body }) as Promise<{
id: string;
}>;
}
if (!navigator.onLine) {
await enqueueOrder(body, effectiveClient.nome);
window.dispatchEvent(new CustomEvent('sar:order-queued'));
@@ -1458,6 +1583,11 @@ export function NewOrderPage() {
onSuccess: (created) => {
void qc.invalidateQueries({ queryKey: ['orders'] });
void qc.invalidateQueries({ queryKey: ['clients'] });
if (editarId && created) {
message.success('Orçamento atualizado');
void navigate({ to: '/pedidos/$id', params: { id: created.id } });
return;
}
if (!created) {
setError(null);
setCart([]);
@@ -1475,7 +1605,7 @@ export function NewOrderPage() {
}
void navigate({ to: '/pedidos/$id', params: { id: created.id } });
},
onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao criar pedido'),
onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao salvar pedido'),
});
return (
@@ -1490,7 +1620,9 @@ export function NewOrderPage() {
}}
>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
Lançamento de Pedido
{editarId
? `Editar Orçamento${editOrder ? ` ${editOrder.numPedSar}` : ''}`
: 'Lançamento de Pedido'}
</Title>
<Button
icon={<ArrowLeftOutlined />}
@@ -1700,6 +1832,7 @@ export function NewOrderPage() {
<OrderItemsTable
items={cart}
tetoDe={tetoDe}
onQtyChange={setQty}
onDiscChange={setDisc}
onRemove={removeItem}

View File

@@ -6,16 +6,12 @@ import {
Button,
Descriptions,
Divider,
Form,
InputNumber,
Modal,
Space,
Spin,
Table,
Tag,
Timeline,
Typography,
Input,
message,
} from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -32,7 +28,6 @@ import { apiFetch } from '../../lib/api-client';
import { authStore } from '../../lib/auth-store';
const { Title, Text } = Typography;
const { TextArea } = Input;
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -144,105 +139,6 @@ function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
);
}
// ─── Approve Modal ────────────────────────────────────────────────────────────
function ApproveModal({
open,
originalDiscount,
onConfirm,
onCancel,
loading,
}: {
open: boolean;
originalDiscount: string;
onConfirm: (descontoPerc?: number, nota?: string) => void;
onCancel: () => void;
loading: boolean;
}) {
const [disc, setDisc] = useState<number | null>(null);
const [nota, setNota] = useState('');
return (
<Modal
title="Aprovar Pedido"
open={open}
onOk={() => onConfirm(disc ?? undefined, nota || undefined)}
onCancel={onCancel}
okText="Confirmar Aprovação"
cancelText="Voltar"
confirmLoading={loading}
>
<Form layout="vertical">
<Form.Item
label={`Desconto global (original: ${originalDiscount}%)`}
help="Deixe em branco para manter o desconto solicitado."
>
<InputNumber
min={0}
max={100}
step={0.5}
placeholder={originalDiscount}
value={disc}
onChange={(v) => setDisc(v)}
addonAfter="%"
style={{ width: 160 }}
/>
</Form.Item>
<Form.Item label="Observação (opcional)">
<TextArea
rows={2}
value={nota}
onChange={(e) => setNota(e.target.value)}
maxLength={300}
/>
</Form.Item>
</Form>
</Modal>
);
}
// ─── Reject Modal ─────────────────────────────────────────────────────────────
function RejectModal({
open,
onConfirm,
onCancel,
loading,
}: {
open: boolean;
onConfirm: (motivo: string) => void;
onCancel: () => void;
loading: boolean;
}) {
const [motivo, setMotivo] = useState('');
return (
<Modal
title="Recusar Pedido"
open={open}
onOk={() => motivo.trim() && onConfirm(motivo.trim())}
onCancel={onCancel}
okText="Confirmar Recusa"
okButtonProps={{ danger: true, disabled: !motivo.trim() }}
cancelText="Voltar"
confirmLoading={loading}
>
<Form layout="vertical">
<Form.Item label="Motivo da recusa" required>
<TextArea
rows={3}
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
maxLength={500}
showCount
placeholder="Informe o motivo para o representante..."
/>
</Form.Item>
</Form>
</Modal>
);
}
// ─── OrderDetailPage ──────────────────────────────────────────────────────────
export function OrderDetailPage() {
@@ -254,7 +150,6 @@ export function OrderDetailPage() {
const role = getRoleFromToken();
const isErp = order?.fonte === 'erp';
const canAct = !isErp && role !== 'rep' && order?.situa === 1;
const canTransmit = !isErp && role === 'rep' && order?.situa === 0;
const canShare =
role === 'rep' &&
@@ -262,39 +157,9 @@ export function OrderDetailPage() {
typeof navigator !== 'undefined' &&
!!navigator.share;
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [chamadoOpen, setChamadoOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const approveMutation = useMutation({
mutationFn: ({ descontoPerc, nota }: { descontoPerc?: number; nota?: string }) =>
apiFetch(`/orders/${id}/approve`, { method: 'PATCH', body: { descontoPerc, nota } }),
onSuccess: () => {
setApproveOpen(false);
void qc.invalidateQueries({ queryKey: ['orders', id] });
void qc.invalidateQueries({ queryKey: ['orders'] });
},
onError: (e: unknown) => {
setApproveOpen(false);
setActionError(e instanceof Error ? e.message : 'Erro ao aprovar');
},
});
const rejectMutation = useMutation({
mutationFn: (motivo: string) =>
apiFetch(`/orders/${id}/reject`, { method: 'PATCH', body: { motivo } }),
onSuccess: () => {
setRejectOpen(false);
void qc.invalidateQueries({ queryKey: ['orders', id] });
void qc.invalidateQueries({ queryKey: ['orders'] });
},
onError: (e: unknown) => {
setRejectOpen(false);
setActionError(e instanceof Error ? e.message : 'Erro ao recusar');
},
});
const transmitMutation = useMutation({
mutationFn: () => apiFetch(`/orders/${id}/transmit`, { method: 'PATCH' }),
onSuccess: () => {
@@ -309,11 +174,6 @@ export function OrderDetailPage() {
if (error || !order)
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
const timeWaiting =
order.situa === 1
? Math.floor((Date.now() - new Date(order.createdAt).getTime()) / 3_600_000)
: null;
// Orçamento: tela mais larga para consulta/revisão com o cliente.
const isOrcamento = order.situa === 0;
@@ -339,9 +199,6 @@ export function OrderDetailPage() {
</Tag>
}
/>
{timeWaiting !== null && timeWaiting > 2 && (
<Tag color="red">Urgente {timeWaiting}h aguardando</Tag>
)}
<Button
icon={<FilePdfOutlined />}
onClick={() => navigate({ to: '/pedidos/$id/imprimir', params: { id } })}
@@ -361,16 +218,6 @@ export function OrderDetailPage() {
Transmitir pedido
</Button>
)}
{canAct && (
<Space>
<Button type="primary" onClick={() => setApproveOpen(true)}>
Aprovar
</Button>
<Button danger onClick={() => setRejectOpen(true)}>
Recusar
</Button>
</Space>
)}
{canShare && (
<Button
icon={<FontAwesomeIcon icon={faShareNodes} />}
@@ -420,11 +267,6 @@ export function OrderDetailPage() {
<Descriptions.Item label="Data">
{new Date(order.dtPedido).toLocaleDateString('pt-BR')}
</Descriptions.Item>
{order.aprovadoEm && (
<Descriptions.Item label="Aprovado em">
{new Date(order.aprovadoEm).toLocaleString('pt-BR')} cód. {order.aprovadoPor}
</Descriptions.Item>
)}
{order.formaPagamento && (
<Descriptions.Item label="Cond. Pagamento" span={2}>
{order.formaPagamento}
@@ -511,19 +353,6 @@ export function OrderDetailPage() {
<Divider titlePlacement="left">Histórico do Pedido</Divider>
<HistoryTimeline history={order.historico} />
<ApproveModal
open={approveOpen}
originalDiscount={order.descontoPerc}
onConfirm={(descontoPerc, nota) => approveMutation.mutate({ descontoPerc, nota })}
onCancel={() => setApproveOpen(false)}
loading={approveMutation.isPending}
/>
<RejectModal
open={rejectOpen}
onConfirm={(motivo) => rejectMutation.mutate(motivo)}
onCancel={() => setRejectOpen(false)}
loading={rejectMutation.isPending}
/>
{order && (
<AbrirChamadoModal
open={chamadoOpen}

View File

@@ -145,60 +145,71 @@ export function OrderPrintPage() {
color: INK,
}}
>
{/* ── Cabeçalho: empresa matriz que fatura ───────────────────────── */}
{/* ── Cabeçalho compacto: logo + empresa matriz + nº do pedido ────── */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
padding: '22px 28px',
borderTop: `5px solid ${BLUE}`,
background: 'linear-gradient(180deg,#F8FAFD 0%,#fff 100%)',
borderBottom: `1px solid ${LINE}`,
alignItems: 'center',
gap: 16,
padding: '12px 28px',
borderTop: `4px solid ${BLUE}`,
borderBottom: `2px solid ${BLUE}`,
}}
>
<div style={{ maxWidth: 460 }}>
<div style={{ fontSize: 19, fontWeight: 800, color: BLUE, lineHeight: 1.1 }}>
{empresa?.nomeFantasia ?? empresa?.razaoSocial ?? '...'}
</div>
<div style={{ fontSize: 11, color: MUTED, marginTop: 2 }}>{empresa?.razaoSocial}</div>
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6, lineHeight: 1.5 }}>
{empresa?.cnpj && <>CNPJ {empresa.cnpj}</>}
{empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}</>}
{enderecoEmp && <div>{enderecoEmp}</div>}
{cidadeEmp && <div>{cidadeEmp}</div>}
{(empresa?.telefone || empresa?.email) && (
<div>
{empresa?.telefone && <>Tel {phone(empresa.telefone)}</>}
{empresa?.telefone && empresa?.email && <> · </>}
{empresa?.email}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 14, minWidth: 0 }}>
{empresa?.logoBase64 && (
<img
src={empresa.logoBase64}
alt="Logo"
style={{ maxHeight: 52, maxWidth: 140, objectFit: 'contain', flexShrink: 0 }}
/>
)}
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 800, color: BLUE, lineHeight: 1.15 }}>
{empresa?.nomeFantasia ?? empresa?.razaoSocial ?? '...'}
</div>
<div style={{ fontSize: 8.5, color: MUTED, marginTop: 2, lineHeight: 1.45 }}>
{empresa?.razaoSocial && empresa.razaoSocial !== empresa.nomeFantasia && (
<>{empresa.razaoSocial} · </>
)}
{empresa?.cnpj && <>CNPJ {empresa.cnpj}</>}
{empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}</>}
<br />
{[enderecoEmp, cidadeEmp].filter(Boolean).join(' · ')}
{(empresa?.telefone || empresa?.email) && (
<>
<br />
{empresa?.telefone && <>Tel {phone(empresa.telefone)}</>}
{empresa?.telefone && empresa?.email && <> · </>}
{empresa?.email}
</>
)}
</div>
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 10, color: MUTED, fontWeight: 700, letterSpacing: '0.1em' }}>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 9, color: MUTED, fontWeight: 700, letterSpacing: '0.1em' }}>
PEDIDO
</div>
<div style={{ fontSize: 22, fontWeight: 800, color: INK, lineHeight: 1.1 }}>
<div style={{ fontSize: 19, fontWeight: 800, color: INK, lineHeight: 1.1 }}>
{order.numPedSar}
</div>
<div
style={{
display: 'inline-block',
marginTop: 6,
padding: '2px 10px',
borderRadius: 20,
background: `${BLUE}12`,
color: BLUE,
fontSize: 10.5,
fontWeight: 700,
}}
>
{SITUA_LABEL[order.situa] ?? String(order.situa)}
</div>
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6 }}>
Emissão: {dateBR(order.dtPedido)}
<div style={{ fontSize: 9.5, color: MUTED, marginTop: 3 }}>
<span
style={{
display: 'inline-block',
padding: '1px 8px',
borderRadius: 20,
background: `${BLUE}12`,
color: BLUE,
fontWeight: 700,
marginRight: 6,
}}
>
{SITUA_LABEL[order.situa] ?? String(order.situa)}
</span>
{dateBR(order.dtPedido)}
</div>
</div>
</div>
@@ -370,7 +381,30 @@ export function OrderPrintPage() {
</div>
{/* ── Totais ──────────────────────────────────────────────────────── */}
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '14px 28px 4px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-end',
gap: 16,
padding: '14px 28px 4px',
}}
>
{/* Peso e cubagem do pedido (logística/frete) */}
<div style={{ fontSize: 10.5, color: MUTED, lineHeight: 1.8 }}>
{Number(order.pesoTotal ?? 0) > 0 && (
<div>
<strong style={{ color: INK }}>Peso total:</strong>{' '}
{Number(order.pesoTotal).toLocaleString('pt-BR', { maximumFractionDigits: 2 })} kg
</div>
)}
{Number(order.m3Total ?? 0) > 0 && (
<div>
<strong style={{ color: INK }}>Volume:</strong>{' '}
{Number(order.m3Total).toLocaleString('pt-BR', { maximumFractionDigits: 3 })} m³
</div>
)}
</div>
<div style={{ width: 300 }}>
<TotRow k="Total dos produtos" v={money(order.totalProdutos)} />
{Number(order.totalIpi) > 0 && <TotRow k="IPI" v={money(order.totalIpi)} />}

View File

@@ -6,6 +6,8 @@ import {
Card,
Col,
DatePicker,
Form,
Input,
Modal,
Dropdown,
Grid,
@@ -28,6 +30,7 @@ import {
CloseCircleOutlined,
CopyOutlined,
DollarOutlined,
EditOutlined,
EllipsisOutlined,
EyeOutlined,
FilePdfOutlined,
@@ -139,13 +142,11 @@ const STATUS: Record<number, { label: string; color: string; rowBg: string; tagC
function useOrderStats() {
const all = useOrderList({ limit: 1 });
const pendentes = useOrderList({ limit: 1, situa: 1 });
const aprovados = useOrderList({ limit: 1, situa: 2 });
const transmitidos = useOrderList({ limit: 1, situa: 2 });
const faturados = useOrderList({ limit: 1, situa: 4 });
return {
total: all.data?.total ?? 0,
pendentes: pendentes.data?.total ?? 0,
aprovados: aprovados.data?.total ?? 0,
transmitidos: transmitidos.data?.total ?? 0,
faturados: faturados.data?.total ?? 0,
loaded: !!all.data,
};
@@ -164,19 +165,18 @@ function OrdersMetrics({ stats }: { stats: OrderStats }) {
color: '#003B8E',
},
{
label: 'Ag. Aprovação',
value: stats.pendentes,
icon: <ClockCircleOutlined />,
color: '#d46b08',
label: 'Transmitidos',
value: stats.transmitidos,
icon: <CheckCircleOutlined />,
color: '#389e0d',
},
{ label: 'Aprovados', value: stats.aprovados, icon: <CheckCircleOutlined />, color: '#389e0d' },
{ label: 'Faturados', value: stats.faturados, icon: <DollarOutlined />, color: '#1d39c4' },
];
return (
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{metrics.map((m) => (
<Col key={m.label} xs={12} sm={6}>
<Col key={m.label} xs={12} sm={8}>
<Card
style={{
borderRadius: 10,
@@ -244,6 +244,14 @@ function OrderStatusBadge({ situa, descr }: { situa: number; descr?: string }) {
// ─── OrderActionsMenu ─────────────────────────────────────────────────────────
const MOTIVOS_CANCELAMENTO = [
'Cliente desistiu',
'Preço',
'Prazo de entrega',
'Erro de lançamento',
'Outro',
];
function OrderActionsMenu({
order,
onView,
@@ -253,18 +261,24 @@ function OrderActionsMenu({
}) {
const navigate = useNavigate();
const qc = useQueryClient();
const { modal, message: msg } = App.useApp();
const { message: msg } = App.useApp();
const [cancelOpen, setCancelOpen] = useState(false);
const [cancelForm] = Form.useForm<{ motivo: string; descricao?: string }>();
const cancelMutation = useMutation({
mutationFn: () => apiFetch(`/orders/${order.id}/cancel`, { method: 'PATCH' }),
mutationFn: (body: { motivo: string; descricao?: string }) =>
apiFetch(`/orders/${order.id}/cancel`, { method: 'PATCH', body }),
onSuccess: () => {
void msg.success('Orçamento cancelado.');
setCancelOpen(false);
void qc.invalidateQueries({ queryKey: ['orders'] });
},
onError: (e: unknown) => void msg.error(e instanceof Error ? e.message : 'Erro ao cancelar'),
});
// Orçamento (situa 0) pode ser editado e cancelado; transmitido, não.
const canCancel = order.situa === 0 && order.fonte !== 'erp';
const canEdit = canCancel;
const items: MenuProps['items'] = [
{
@@ -273,6 +287,16 @@ function OrderActionsMenu({
label: 'Ver detalhes',
onClick: () => onView(order.id),
},
{
key: 'edit',
icon: <EditOutlined />,
label: 'Editar orçamento',
disabled: !canEdit,
onClick: () => {
if (!canEdit) return;
void navigate({ to: '/pedidos/novo', search: { editar: order.id } });
},
},
{
key: 'duplicate',
icon: <CopyOutlined style={{ color: '#0057D9' }} />,
@@ -305,22 +329,59 @@ function OrderActionsMenu({
disabled: !canCancel,
onClick: () => {
if (!canCancel) return;
void modal.confirm({
title: 'Cancelar orçamento?',
content: `O pedido ${order.numPedSar} será marcado como cancelado. Esta ação não pode ser desfeita.`,
okText: 'Cancelar pedido',
okButtonProps: { danger: true },
cancelText: 'Voltar',
onOk: () => cancelMutation.mutate(),
});
cancelForm.resetFields();
setCancelOpen(true);
},
},
];
return (
<Dropdown menu={{ items }} trigger={['click']} placement="bottomRight">
<Button type="text" icon={<EllipsisOutlined />} size="small" />
</Dropdown>
// stopPropagation: o clique nos "..." não pode acionar o clique da LINHA
// (que abre o detalhe) — o menu abre sozinho e só a opção escolhida age.
<span onClick={(e) => e.stopPropagation()}>
<Dropdown menu={{ items }} trigger={['click']} placement="bottomRight">
<Button type="text" icon={<EllipsisOutlined />} size="small" />
</Dropdown>
<Modal
title={`Cancelar orçamento ${order.numPedSar}?`}
open={cancelOpen}
onCancel={() => setCancelOpen(false)}
okText="Cancelar pedido"
okButtonProps={{ danger: true }}
cancelText="Voltar"
confirmLoading={cancelMutation.isPending}
onOk={() =>
void cancelForm.validateFields().then((values) => cancelMutation.mutate(values))
}
centered
width={480}
>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
O pedido será marcado como cancelado. Esta ação não pode ser desfeita.
</Typography.Text>
<Form form={cancelForm} layout="vertical">
<Form.Item
name="motivo"
label="Motivo do cancelamento"
rules={[{ required: true, message: 'Informe o motivo' }]}
>
<Select
placeholder="Selecione o motivo"
options={MOTIVOS_CANCELAMENTO.map((m) => ({ value: m, label: m }))}
/>
</Form.Item>
<Form.Item name="descricao" label="Descrição (opcional)">
<Input.TextArea
rows={3}
maxLength={500}
showCount
placeholder="Detalhe o que aconteceu, se quiser"
/>
</Form.Item>
</Form>
</Modal>
</span>
);
}
@@ -801,7 +862,7 @@ export function OrdersPage() {
Pedidos
</Title>
<p style={{ margin: '4px 0 0', color: '#64748B', fontSize: 14 }}>
Acompanhe seus pedidos, status de aprovação e histórico comercial.
Acompanhe seus pedidos, status de transmissão e histórico comercial.
</p>
</div>
{!isMobile && (
@@ -885,7 +946,6 @@ export function OrdersPage() {
}}
options={[
{ value: 0, label: 'Orçamento' },
{ value: 1, label: 'Ag. Aprovação' },
{ value: 2, label: 'Transmitido' },
{ value: 3, label: 'Cancelado' },
{ value: 4, label: 'Faturado' },

View File

@@ -1,7 +1,6 @@
import { useState } from 'react';
import {
Alert,
Badge,
Card,
Col,
DatePicker,
@@ -17,10 +16,10 @@ import {
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import { AlertOutlined, BarChartOutlined, RiseOutlined, TeamOutlined } from '@ant-design/icons';
import { AlertOutlined, BarChartOutlined, RiseOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { CarteiraCliente, AbcProduto } from '@sar/api-interface';
import { useReportMeta, useReportCarteira, useReportAbc } from '../../lib/queries/reports';
import type { AbcProduto } from '@sar/api-interface';
import { useReportMeta, useReportAbc } from '../../lib/queries/reports';
const { Title, Text } = Typography;
const { Option } = Select;
@@ -29,11 +28,6 @@ function fmt(v: number | string): string {
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function fmtDate(v: string | null | undefined): string {
if (!v) return '—';
return new Date(v).toLocaleDateString('pt-BR');
}
// ─── Aba 1: Desempenho vs. Meta ───────────────────────────────────────────────
function TabMeta() {
@@ -196,177 +190,7 @@ function TabMeta() {
);
}
// ─── Aba 2: Carteira de Clientes ─────────────────────────────────────────────
type FiltroCarteira = 'todos' | 'inativos30' | 'inativos60' | 'semPedido';
function TabCarteira() {
const [filtro, setFiltro] = useState<FiltroCarteira>('todos');
const { data, isLoading, isError, error } = useReportCarteira();
const clientes = data?.data ?? [];
const filtered = clientes.filter((c: CarteiraCliente) => {
if (filtro === 'inativos30') return c.diasSemPedido != null && c.diasSemPedido >= 30;
if (filtro === 'inativos60') return c.diasSemPedido != null && c.diasSemPedido >= 60;
if (filtro === 'semPedido') return c.totalPedidos === 0;
return true;
});
function statusBadge(c: CarteiraCliente) {
if (c.totalPedidos === 0)
return <Badge color="#94A3B8" text={<Text style={{ fontSize: 12 }}>Sem pedidos</Text>} />;
if (c.diasSemPedido != null && c.diasSemPedido >= 60)
return (
<Badge
color="#ef4444"
text={<Text style={{ fontSize: 12, color: '#ef4444' }}>Inativo 60d+</Text>}
/>
);
if (c.diasSemPedido != null && c.diasSemPedido >= 30)
return (
<Badge
color="#f59e0b"
text={<Text style={{ fontSize: 12, color: '#f59e0b' }}>Inativo 30d+</Text>}
/>
);
return (
<Badge color="#22c55e" text={<Text style={{ fontSize: 12, color: '#22c55e' }}>Ativo</Text>} />
);
}
const columns: TableColumnsType<CarteiraCliente> = [
{
title: 'Cliente',
key: 'cliente',
render: (_: unknown, c: CarteiraCliente) => (
<Space orientation="vertical" size={0}>
<Text strong style={{ fontSize: 13 }}>
{c.razao ?? c.nome ?? `Cód. ${c.idCliente}`}
</Text>
{c.razao && c.nome && (
<Text type="secondary" style={{ fontSize: 11 }}>
{c.nome}
</Text>
)}
</Space>
),
},
{
title: 'Status',
key: 'status',
width: 140,
render: (_: unknown, c: CarteiraCliente) => statusBadge(c),
},
{
title: 'Último Pedido',
dataIndex: 'ultimoPedido',
width: 130,
render: (v: string | null) => (
<Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>
),
},
{
title: 'Dias Sem Pedido',
dataIndex: 'diasSemPedido',
width: 140,
align: 'right' as const,
render: (v: number | null) =>
v != null ? (
<Text
strong
className="tabular-nums"
style={{ color: v >= 60 ? '#ef4444' : v >= 30 ? '#f59e0b' : '#22c55e' }}
>
{v}d
</Text>
) : (
<Text type="secondary"></Text>
),
},
{
title: 'Pedidos',
dataIndex: 'totalPedidos',
width: 90,
align: 'right' as const,
render: (v: number) => <Text className="tabular-nums">{v}</Text>,
},
{
title: 'Ticket Médio',
dataIndex: 'ticketMedio',
width: 130,
align: 'right' as const,
render: (v: string) => (
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{fmt(v)}
</Text>
),
},
];
if (isError) {
return (
<Alert
type="error"
showIcon
message="Erro ao carregar carteira de clientes"
description={error instanceof Error ? error.message : 'Erro desconhecido'}
/>
);
}
return (
<div>
{data && (
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{[
{ key: 'todos', label: 'Total', value: data.total, color: '#003B8E' },
{ key: 'inativos30', label: 'Inativos 30d+', value: data.inativos30, color: '#f59e0b' },
{ key: 'inativos60', label: 'Inativos 60d+', value: data.inativos60, color: '#ef4444' },
{ key: 'semPedido', label: 'Sem Pedido', value: data.semPedido, color: '#94A3B8' },
].map((item) => (
<Col key={item.key} xs={12} md={6}>
<Card
hoverable
onClick={() => setFiltro(item.key as FiltroCarteira)}
style={{
borderRadius: 8,
border: filtro === item.key ? `2px solid ${item.color}` : '1px solid #EBF0F5',
cursor: 'pointer',
}}
styles={{ body: { padding: '12px 16px' } }}
>
<Statistic
title={item.label}
value={item.value}
styles={{ content: { color: item.color, fontSize: 24 } }}
/>
</Card>
</Col>
))}
</Row>
)}
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: 0 } }}
>
<Table<CarteiraCliente>
rowKey="idCliente"
columns={columns}
dataSource={filtered}
loading={isLoading}
size="middle"
pagination={{ pageSize: 15, showSizeChanger: false }}
scroll={{ x: 700 }}
style={{ borderRadius: 10, overflow: 'hidden' }}
/>
</Card>
</div>
);
}
// ─── Aba 3: Curva ABC ─────────────────────────────────────────────────────────
// ─── Aba 2: Curva ABC ─────────────────────────────────────────────────────────
const CURVA_COLOR: Record<string, string> = { A: '#003B8E', B: '#f59e0b', C: '#94A3B8' };
@@ -550,16 +374,6 @@ export function RelatoriosPage() {
),
children: <TabMeta />,
},
{
key: 'carteira',
label: (
<Space>
<TeamOutlined />
Carteira de Clientes
</Space>
),
children: <TabCarteira />,
},
{
key: 'abc',
label: (
@@ -579,7 +393,7 @@ export function RelatoriosPage() {
Relatórios
</Title>
<Text type="secondary" style={{ fontSize: 14 }}>
Análise de desempenho, carteira e mix de produtos.
Análise de desempenho e mix de produtos.
</Text>
</div>

View File

@@ -39,6 +39,7 @@ import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar
import { SITUA_LABEL } from '@sar/api-interface';
import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth';
import { CondicoesEspeciaisCard } from './CondicoesEspeciaisCard';
ChartJS.register(
CategoryScale,
@@ -553,6 +554,9 @@ export function RepPainel() {
</Col>
</Row>
{/* Condições especiais vigentes hoje (promoções + descontos liberados) */}
<CondicoesEspeciaisCard />
{/* Metas por Grupo */}
{metasPorGrupo.length > 0 && (
<Card

View File

@@ -1,108 +0,0 @@
import { Table, Tag, Typography, Badge, Space } from 'antd';
import type { TableColumnsType } from 'antd';
import { Link } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
import { useOrderList } from '../../lib/queries/orders';
const { Title } = Typography;
function hoursWaiting(createdAt: string): number {
return Math.floor((Date.now() - new Date(createdAt).getTime()) / 3_600_000);
}
const columns: TableColumnsType<PedidoSummary> = [
{
title: 'Nº',
dataIndex: 'numPedSar',
width: 120,
render: (num: string, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
{
title: 'Representante',
key: 'rep',
width: 160,
render: (_: unknown, row: PedidoSummary) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Cliente',
key: 'cliente',
width: 200,
render: (_: unknown, row: PedidoSummary) =>
row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`,
},
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right',
render: (v: string) =>
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Desc. %',
dataIndex: 'descontoPerc',
width: 90,
align: 'right',
render: (v: string) => `${v}%`,
},
{
title: 'Aguardando',
dataIndex: 'createdAt',
width: 130,
render: (v: string) => {
const h = hoursWaiting(v);
return <Tag color={h > 2 ? 'red' : 'orange'}>{h}h</Tag>;
},
},
{
title: '',
width: 100,
render: (_: unknown, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
<Tag color="blue" style={{ cursor: 'pointer' }}>
Analisar
</Tag>
</Link>
),
},
];
export function ApprovalQueuePage() {
// situa=1 = Pendente de Aprovação
const { data, isLoading } = useOrderList({ situa: 1, limit: 200 });
const urgentCount = data?.data.filter((o) => hoursWaiting(o.createdAt) > 2).length ?? 0;
return (
<div style={{ padding: 24 }}>
<Space align="center" style={{ marginBottom: 16 }}>
<Title level={3} style={{ margin: 0 }}>
Fila de Aprovações
</Title>
{urgentCount > 0 && (
<Badge
count={urgentCount}
style={{ backgroundColor: '#cf1322' }}
title={`${urgentCount} urgente(s) — mais de 2h aguardando`}
/>
)}
</Space>
<Table<PedidoSummary>
rowKey="id"
columns={columns}
dataSource={data?.data ?? []}
loading={isLoading}
rowClassName={(row) => (hoursWaiting(row.createdAt) > 2 ? 'row-urgent' : '')}
pagination={false}
locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }}
/>
<style>{`.row-urgent td { background: #fff1f0 !important; }`}</style>
</div>
);
}

View File

@@ -1,15 +1,41 @@
import { Badge, Card, Col, Flex, Row, Skeleton, Space, Table, Tag, Typography } from 'antd';
import { useState } from 'react';
import {
Button,
Card,
Col,
Flex,
Modal,
Progress,
Row,
Skeleton,
Space,
Spin,
Table,
Tag,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import { WhatsAppOutlined } from '@ant-design/icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCheckCircle,
faAddressCard,
faCircleExclamation,
faClipboardList,
faRankingStar,
} from '@fortawesome/free-solid-svg-icons';
import { Link } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
import { useSupervisorDashboard } from '../../lib/queries/dashboard';
import type {
ClienteNaoPositivado,
PositivacaoRep,
RankingRep,
RepInativosSummary,
} from '@sar/api-interface';
import { useRepInativos, useSupervisorDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth';
import {
positivacaoRepColumns,
rankingRepColumns,
} from '../../components/dashboard/rep-performance-columns';
const { Title, Text } = Typography;
@@ -17,10 +43,6 @@ function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function hoursWaiting(createdAt: string): number {
return Math.floor((Date.now() - new Date(createdAt).getTime()) / 3_600_000);
}
function delta(current: number, previous: number): { label: string; positive: boolean } | null {
if (previous === 0) return null;
const pct = Math.round(((current - previous) / previous) * 100);
@@ -38,62 +60,116 @@ function today(): string {
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
}
const queueColumns: TableColumnsType<PedidoSummary> = [
{
title: 'Pedido',
dataIndex: 'numPedSar',
width: 120,
render: (num: string, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
{
title: 'Representante',
key: 'rep',
width: 150,
render: (_: unknown, row: PedidoSummary) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
function mesLabel(): string {
return new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
}
function waLink(whatsapp: string): string {
const digits = whatsapp.replace(/\D/g, '');
const number = digits.startsWith('55') ? digits : `55${digits}`;
return `https://wa.me/${number}`;
}
// ─── Modal de clientes inativos de um rep ─────────────────────────────────────
const inativosColumns: TableColumnsType<ClienteNaoPositivado> = [
{
title: 'Cliente',
key: 'cliente',
width: 180,
render: (_: unknown, row: PedidoSummary) =>
row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`,
render: (_: unknown, c: ClienteNaoPositivado) => (
<Space orientation="vertical" size={0}>
<Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
{c.razao ?? c.nome}
</Link>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
cód. {c.idCliente}
</Text>
</Space>
),
},
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right',
render: (v: string) => fmt(Number(v)),
title: 'Última compra',
dataIndex: 'ultimoPedido',
width: 140,
render: (v: string | null) =>
v ? new Date(`${v}T00:00:00`).toLocaleDateString('pt-BR') : <Tag>Nunca comprou</Tag>,
},
{
title: 'Aguardando',
dataIndex: 'createdAt',
title: 'Sem compra',
dataIndex: 'diasSemPedido',
width: 120,
render: (v: string) => {
const h = hoursWaiting(v);
return <Tag color={h > 2 ? 'red' : 'orange'}>{h}h</Tag>;
},
render: (d: number, c: ClienteNaoPositivado) =>
c.comprouAntes ? (
<Tag color={d > 90 ? 'red' : 'orange'} className="tabular-nums">
{d} dias
</Tag>
) : (
'—'
),
},
{
title: '',
width: 90,
render: (_: unknown, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
<Tag color="blue" style={{ cursor: 'pointer' }}>
Analisar
</Tag>
</Link>
key: 'acoes',
width: 120,
render: (_: unknown, c: ClienteNaoPositivado) => (
<Space>
<Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
<Button size="small">Ficha</Button>
</Link>
{c.whatsapp && (
<Button
size="small"
type="primary"
style={{ borderRadius: 6, background: '#25d366', borderColor: '#25d366' }}
icon={<WhatsAppOutlined />}
href={waLink(c.whatsapp)}
target="_blank"
rel="noopener noreferrer"
/>
)}
</Space>
),
},
];
function InativosRepModal({
rep,
onClose,
}: {
rep: RepInativosSummary | null;
onClose: () => void;
}) {
const { data, isLoading } = useRepInativos(rep?.codVendedor ?? null);
return (
<Modal
open={rep !== null}
onCancel={onClose}
footer={null}
width={900}
centered
title={rep ? `Clientes Inativos — ${rep.nomeVendedor ?? `Rep cód. ${rep.codVendedor}`}` : ''}
>
{isLoading ? (
<Spin style={{ display: 'block', margin: '32px auto' }} />
) : (
<Table<ClienteNaoPositivado>
rowKey="idCliente"
columns={inativosColumns}
dataSource={data?.clientes ?? []}
size="small"
pagination={{ pageSize: 10, hideOnSinglePage: true }}
locale={{ emptyText: 'Nenhum cliente inativo.' }}
/>
)}
</Modal>
);
}
export function SupervisorPainel() {
const { data, isLoading } = useSupervisorDashboard();
const { data: user } = useCurrentUser();
const [inativosRep, setInativosRep] = useState<RepInativosSummary | null>(null);
if (isLoading || !data) {
return (
@@ -110,8 +186,7 @@ export function SupervisorPainel() {
);
}
const { approvalQueue, pedidosDia, inativosPorRep, syncedAt } = data;
const urgentCount = approvalQueue.filter((o) => hoursWaiting(o.createdAt) > 2).length;
const { pedidosDia, inativosPorRep, equipeMes, rankingReps, positivacaoReps, syncedAt } = data;
const countDelta = delta(pedidosDia.count, pedidosDia.countSemanaAnterior);
const totalDelta = delta(pedidosDia.total, pedidosDia.totalSemanaAnterior);
@@ -124,49 +199,12 @@ export function SupervisorPainel() {
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
{today()}
{urgentCount > 0 && (
<>
{' '}
·{' '}
<span style={{ color: '#cf1322' }}>
{urgentCount} aprovação{urgentCount > 1 ? 'ões' : ''} urgente
{urgentCount > 1 ? 's' : ''}
</span>
</>
)}
</Text>
</Flex>
{/* KPIs */}
<Row gutter={[24, 24]}>
<Col xs={24} md={8}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
APROVAÇÕES PENDENTES
</Text>
<Flex align="center" gap={8}>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{approvalQueue.length}
</Title>
{urgentCount > 0 && (
<Badge
count={urgentCount}
style={{ backgroundColor: '#cf1322' }}
title={`${urgentCount} urgente(s) — mais de 2h`}
/>
)}
</Flex>
<Link to="/aprovacoes">
<Text style={{ fontSize: 'var(--text-sm)', color: 'var(--jcs-blue)' }}>
Ver fila completa
</Text>
</Link>
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Col xs={24} md={12}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
@@ -189,7 +227,7 @@ export function SupervisorPainel() {
</Card>
</Col>
<Col xs={24} md={8}>
<Col xs={24} md={12}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
@@ -213,38 +251,9 @@ export function SupervisorPainel() {
</Col>
</Row>
{/* Fila de aprovações + Inativos por rep */}
{/* Inativos por rep + Pedidos de hoje */}
<Row gutter={[24, 24]}>
<Col xs={24} lg={16}>
<Card
title={
<Space>
<FontAwesomeIcon icon={faCheckCircle} style={{ color: 'var(--jcs-blue)' }} />
Fila de Aprovações
{approvalQueue.length > 0 && (
<Badge
count={approvalQueue.length}
style={{ backgroundColor: 'var(--jcs-blue)' }}
/>
)}
</Space>
}
extra={<Link to="/aprovacoes">Ver todas</Link>}
>
<Table<PedidoSummary>
rowKey="id"
columns={queueColumns}
dataSource={approvalQueue.slice(0, 8)}
pagination={false}
size="small"
rowClassName={(row) => (hoursWaiting(row.createdAt) > 2 ? 'row-urgent' : '')}
locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }}
/>
<style>{`.row-urgent td { background: #fff1f0 !important; }`}</style>
</Card>
</Col>
<Col xs={24} lg={8}>
<Col xs={24} lg={12}>
<Card
title={
<Space>
@@ -281,20 +290,26 @@ export function SupervisorPainel() {
</Text>
)}
</Space>
<Tag
color={r.inativosCount >= 3 ? 'orange' : 'default'}
className="tabular-nums"
>
{r.inativosCount} cliente{r.inativosCount > 1 ? 's' : ''}
</Tag>
<Space>
<Tag
color={r.inativosCount >= 3 ? 'orange' : 'default'}
className="tabular-nums"
>
{r.inativosCount} cliente{r.inativosCount > 1 ? 's' : ''}
</Tag>
<Button size="small" onClick={() => setInativosRep(r)}>
Detalhar
</Button>
</Space>
</Flex>
))}
</Flex>
)}
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
style={{ marginTop: 24 }}
title={
<Space>
<FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} />
@@ -328,6 +343,130 @@ export function SupervisorPainel() {
</Col>
</Row>
{/* Métricas da equipe no mês */}
<Row gutter={[24, 24]}>
<Col xs={24} md={8}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
FATURAMENTO NO MÊS
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(equipeMes.faturamento)}
</Title>
<Text
type="secondary"
style={{ fontSize: 'var(--text-sm)' }}
className="tabular-nums"
>
{equipeMes.pedidos} pedido{equipeMes.pedidos !== 1 ? 's' : ''}
</Text>
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Space orientation="vertical" size={4} style={{ width: '100%' }}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
META DA EQUIPE
</Text>
{equipeMes.metaTotal > 0 ? (
<>
<Flex align="baseline" gap={8}>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{equipeMes.pctMeta}%
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
de {fmt(equipeMes.metaTotal)}
</Text>
</Flex>
<Progress
percent={Math.min(equipeMes.pctMeta, 100)}
size="small"
strokeColor={
equipeMes.pctMeta >= 100
? 'var(--green)'
: equipeMes.pctMeta >= 70
? 'var(--jcs-blue)'
: '#faad14'
}
showInfo={false}
/>
<Text
type="secondary"
style={{ fontSize: 'var(--text-sm)' }}
className="tabular-nums"
>
{equipeMes.metaTotal > equipeMes.faturamento
? `Faltam ${fmt(equipeMes.metaTotal - equipeMes.faturamento)}`
: 'Meta atingida 🎉'}
</Text>
</>
) : (
<Text type="secondary">Sem meta cadastrada para o mês.</Text>
)}
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
TICKET MÉDIO
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(equipeMes.ticketMedio)}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
por pedido no mês
</Text>
</Space>
</Card>
</Col>
</Row>
{/* Ranking da equipe */}
<Card
title={
<Space>
<FontAwesomeIcon icon={faRankingStar} style={{ color: 'var(--jcs-blue)' }} />
Ranking da Equipe &mdash; {mesLabel()}
</Space>
}
>
<Table<RankingRep>
rowKey="codVendedor"
columns={rankingRepColumns}
dataSource={rankingReps}
pagination={false}
size="small"
scroll={{ x: 700 }}
locale={{ emptyText: 'Nenhum pedido da equipe no mês.' }}
/>
</Card>
{/* Positivação por representante */}
<Card
title={
<Space>
<FontAwesomeIcon icon={faAddressCard} style={{ color: 'var(--jcs-blue)' }} />
Positivação de Clientes &mdash; {mesLabel()}
</Space>
}
>
<Table<PositivacaoRep>
rowKey="codVendedor"
columns={positivacaoRepColumns}
dataSource={positivacaoReps}
pagination={false}
size="small"
scroll={{ x: 700 }}
locale={{ emptyText: 'Nenhum representante com carteira na equipe.' }}
/>
</Card>
<Flex justify="space-between" style={{ paddingTop: 8 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
SAR · Força de Vendas · Powered by JCS Sistemas
@@ -336,6 +475,8 @@ export function SupervisorPainel() {
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')} · atualiza a cada 30s
</Text>
</Flex>
<InativosRepModal rep={inativosRep} onClose={() => setInativosRep(null)} />
</Flex>
);
}

View File

@@ -0,0 +1,114 @@
import { Flex, Progress, Tag, Typography } from 'antd';
import type { TableColumnsType } from 'antd';
import type { PositivacaoRep, RankingRep } from '@sar/api-interface';
const { Text } = Typography;
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
// Colunas compartilhadas entre o painel gerencial e o do supervisor —
// mesmas métricas, mudando só o escopo (empresa toda vs equipe).
export const positivacaoRepColumns: TableColumnsType<PositivacaoRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: PositivacaoRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
ellipsis: true,
},
{
title: 'Positivados / Carteira',
key: 'pos',
width: 160,
align: 'center' as const,
render: (_: unknown, row: PositivacaoRep) => (
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
{row.clientesPositivados} / {row.totalClientes}
</Text>
),
},
{
title: 'Novos no mês',
key: 'novos',
width: 120,
align: 'center' as const,
sorter: (a: PositivacaoRep, b: PositivacaoRep) => a.novosClientes - b.novosClientes,
render: (_: unknown, row: PositivacaoRep) =>
row.novosClientes > 0 ? (
<Tag color="green" style={{ margin: 0, fontWeight: 600 }}>
+{row.novosClientes}
</Tag>
) : (
<Text type="secondary"></Text>
),
},
{
title: '% Positivação',
dataIndex: 'pctPositivacao',
width: 170,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 30 ? 'var(--green)' : pct >= 15 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex>
),
},
];
export const rankingRepColumns: TableColumnsType<RankingRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: RankingRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Pedidos',
dataIndex: 'pedidos',
width: 80,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Clientes',
dataIndex: 'clientesAtendidos',
width: 80,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Faturamento',
dataIndex: 'faturamento',
width: 150,
align: 'right',
render: (v: number) => fmt(v),
className: 'tabular-nums',
},
{
title: '% Meta',
dataIndex: 'pctMeta',
width: 160,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex>
),
},
];

View File

@@ -3,7 +3,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faChartLine,
faClipboardList,
faClipboardCheck,
faUsers,
faAddressCard,
faBoxesStacked,
@@ -40,7 +39,6 @@ const REP_ITEMS: NavItem[] = [
const SUPERVISOR_ITEMS: NavItem[] = [
{ key: '/', icon: faChartLine, label: 'Painel' },
{ key: '/aprovacoes', icon: faClipboardCheck, label: 'Aprovações' },
{ key: '/pedidos', icon: faClipboardList, label: 'Pedidos' },
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },

View File

@@ -12,6 +12,7 @@ import {
faFileInvoiceDollar,
faTags,
faHeadset,
faGear,
} from '@fortawesome/free-solid-svg-icons';
import type { ItemType } from 'antd/es/menu/interface';
import { authStore } from '../../lib/auth-store';
@@ -60,11 +61,6 @@ const REP_ITEMS: ItemType[] = [
const SUPERVISOR_ITEMS: ItemType[] = [
{ key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
{
key: '/aprovacoes',
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
label: 'Aprovações',
},
{ key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
{
key: '/pedidos',
@@ -91,6 +87,11 @@ const GERENTE_ITEMS: ItemType[] = [
icon: <FontAwesomeIcon icon={faAddressCard} fixedWidth />,
label: 'Clientes',
},
{
key: '/catalogo',
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
label: 'Catálogo',
},
{
key: '/relatorios',
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
@@ -102,6 +103,11 @@ const GERENTE_ITEMS: ItemType[] = [
label: 'Políticas Comerciais',
},
{ key: '/ger/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
{
key: '/ger/config',
icon: <FontAwesomeIcon icon={faGear} fixedWidth />,
label: 'Configurações',
},
];
function getItems(role: string): ItemType[] {
@@ -116,10 +122,12 @@ export function Sidebar() {
const role = getRole();
const items = getItems(role);
const selectedKey = items.find((item) => {
const selectedKey = items.reduce<string | undefined>((best, item) => {
const key = (item as { key?: string }).key ?? '';
return key === '/' ? location.pathname === '/' : location.pathname.startsWith(key);
})?.key as string | undefined;
const matches = key === '/' ? location.pathname === '/' : location.pathname.startsWith(key);
if (!matches) return best;
return !best || key.length > best.length ? key : best;
}, undefined);
return (
<aside

View File

@@ -1,5 +1,5 @@
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
import { usePromocoesVigentes, useRegrasVigentes } from './queries/politicas';
import { usePromocoesVigentes, useRegrasVigentes, useMinhaAlcada } from './queries/politicas';
// Condição comercial vigente que alcança um produto: promoção (por produto ou
// grupo) ou regra de desconto (por grupo/subgrupo/rep). Usada para sinalizar no
@@ -62,3 +62,17 @@ export function useCondicoesComerciais(): (p: ProdutoRef) => CondicaoComercial[]
const { data: regras } = useRegrasVigentes();
return (p) => condicoesDoProduto(p, promos?.promocoes, regras?.regras);
}
// Teto de desconto do item — espelha assertAlcadaItens do backend: vale o MAIOR
// entre a alçada do grupo (codGrupo 0 = default; sem linha, 5%) e a melhor
// promoção/regra vigente que alcança o produto.
export function useTetoDesconto(): (p: ProdutoRef) => number {
const condicoesDe = useCondicoesComerciais();
const { data: alcada } = useMinhaAlcada();
return (p) => {
const limites = new Map((alcada?.limites ?? []).map((l) => [l.codGrupo, l.limitePerc]));
const base = (p.codGrupo != null ? limites.get(p.codGrupo) : undefined) ?? limites.get(0) ?? 5;
const cond = condicoesDe(p).reduce((max, c) => Math.max(max, c.descPct), 0);
return Math.max(base, cond);
};
}

View File

@@ -5,6 +5,7 @@
import type { CreatePedido } from '@sar/api-interface';
import { idbGetAll, idbPut, idbDelete, STORE_PENDING_ORDERS } from './idb';
import { randomUUID } from '../uuid';
export interface PendingOrder {
idempotencyKey: string; // keyPath do IndexedDB
@@ -23,7 +24,7 @@ export async function enqueueOrder(
payload: CreatePedido,
clienteNome: string,
): Promise<PendingOrder> {
const key = payload.idempotencyKey ?? crypto.randomUUID();
const key = payload.idempotencyKey ?? randomUUID();
const order: PendingOrder = {
idempotencyKey: key,
payload: { ...payload, idempotencyKey: key },

View File

@@ -1,8 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import {
RepDashboardSchema,
RepInativosDetailSchema,
SupervisorDashboardSchema,
type RepDashboard,
type RepInativosDetail,
type SupervisorDashboard,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
@@ -29,3 +31,30 @@ export function useSupervisorDashboard() {
refetchInterval: 30 * 1000,
});
}
// 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>({
queryKey: ['dashboard', 'supervisor', 'inativos', codVendedor],
queryFn: async () => {
const raw = await apiFetch(`/dashboard/supervisor/inativos?codVendedor=${codVendedor}`);
return RepInativosDetailSchema.parse(raw);
},
enabled: codVendedor != null,
staleTime: 5 * 60 * 1000,
});
}

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

@@ -2,13 +2,16 @@ import { useQuery } from '@tanstack/react-query';
import {
RegrasVigentesResponseSchema,
PromocoesVigentesResponseSchema,
AlcadaRepResponseSchema,
type RegrasVigentesResponse,
type PromocoesVigentesResponse,
type AlcadaRepResponse,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
// Regras de desconto vigentes para o usuário logado (rep recebe só as suas).
// Usadas na montagem do pedido para pré-aplicar o desconto liberado pelo gerente.
// Refetch curto: regra encerrada/inativada some da tela do rep em até 1 minuto.
export function useRegrasVigentes() {
return useQuery<RegrasVigentesResponse>({
queryKey: ['politicas', 'regras-vigentes'],
@@ -16,11 +19,25 @@ export function useRegrasVigentes() {
const raw = await apiFetch('/politicas/regras/vigentes');
return RegrasVigentesResponseSchema.parse(raw);
},
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
});
}
// Alçada do usuário logado — teto de desconto por item no carrinho
export function useMinhaAlcada() {
return useQuery<AlcadaRepResponse>({
queryKey: ['politicas', 'minha-alcada'],
queryFn: async () => {
const raw = await apiFetch('/politicas/alcada');
return AlcadaRepResponseSchema.parse(raw);
},
staleTime: 5 * 60 * 1000,
});
}
// Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep
// Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep.
// Refetch curto: promoção encerrada/inativada some da tela do rep em até 1 minuto.
export function usePromocoesVigentes() {
return useQuery<PromocoesVigentesResponse>({
queryKey: ['politicas', 'promocoes-vigentes'],
@@ -28,6 +45,7 @@ export function usePromocoesVigentes() {
const raw = await apiFetch('/politicas/promocoes/vigentes');
return PromocoesVigentesResponseSchema.parse(raw);
},
staleTime: 5 * 60 * 1000,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
});
}

View File

@@ -1,12 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import {
ReportMetaResponseSchema,
ReportCarteiraResponseSchema,
ReportAbcResponseSchema,
type ReportMetaQuery,
type ReportAbcQuery,
type ReportMetaResponse,
type ReportCarteiraResponse,
type ReportAbcResponse,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
@@ -19,13 +17,6 @@ export function useReportMeta(params: ReportMetaQuery) {
});
}
export function useReportCarteira() {
return useQuery<ReportCarteiraResponse>({
queryKey: ['reports', 'carteira'],
queryFn: async () => ReportCarteiraResponseSchema.parse(await apiFetch('/reports/carteira')),
});
}
export function useReportAbc(params: ReportAbcQuery) {
const qs = new URLSearchParams();
if (params.mes != null) qs.set('mes', String(params.mes));

View File

@@ -18,15 +18,14 @@ import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
import { CatalogPage } from '../cockpits/rep/CatalogPage';
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
import { CarteirePage } from '../cockpits/rep/CarteirePage';
import { FunilPage } from '../cockpits/rep/FunilPage';
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
import { GerPainel } from '../cockpits/ger/GerPainel';
import { EquipePage } from '../cockpits/ger/EquipePage';
import { PoliticasPage } from '../cockpits/ger/PoliticasPage';
import { ChamadosPage } from '../cockpits/rep/ChamadosPage';
import { GerChamadosPage } from '../cockpits/ger/GerChamadosPage';
import { GerConfigPage } from '../cockpits/ger/GerConfigPage';
import { authStore } from './auth-store';
function getRoleFromToken(): string {
@@ -159,24 +158,12 @@ const relatoriosRoute = createRoute({
component: RelatoriosPage,
});
const carteiraRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/clientes/carteira',
component: CarteirePage,
});
const funilRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/funil',
component: FunilPage,
});
const aprovacoes = createRoute({
getParentRoute: () => rootRoute,
path: '/aprovacoes',
component: ApprovalQueuePage,
});
const gerPainelRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ger',
@@ -207,6 +194,12 @@ const gerChamadosRoute = createRoute({
component: GerChamadosPage,
});
const gerConfigRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ger/config',
component: GerConfigPage,
});
const routeTree = rootRoute.addChildren([
indexRoute,
rafaelRoute,
@@ -220,14 +213,13 @@ const routeTree = rootRoute.addChildren([
catalogoRoute,
pedidosErpRoute,
relatoriosRoute,
carteiraRoute,
funilRoute,
aprovacoes,
gerPainelRoute,
equipeRoute,
politicasRoute,
chamadosRoute,
gerChamadosRoute,
gerConfigRoute,
]);
export const router = createRouter({

12
apps/web/src/lib/uuid.ts Normal file
View File

@@ -0,0 +1,12 @@
// crypto.randomUUID só existe em contexto seguro (HTTPS/localhost). Acessando o
// dev server pelo IP da rede (http://192.168...) ela é undefined — fallback gera
// UUID v4 com crypto.getRandomValues, disponível em qualquer contexto.
export function randomUUID(): string {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40; // versão 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variante RFC 4122
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}

14
apps/web/src/svg-maps.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
declare module '@svg-maps/brazil' {
interface SvgMapLocation {
id: string; // sigla da UF em minúsculas (sp, pr, …)
name: string;
path: string;
}
interface SvgMap {
label: string;
viewBox: string;
locations: SvgMapLocation[];
}
const map: SvgMap;
export default map;
}

View File

@@ -17,5 +17,18 @@ export const EmpresaInfoSchema = z.object({
cep: z.string().nullable(),
telefone: z.string().nullable(),
email: z.string().nullable(),
// Logo (data URL) exibido no cabeçalho da impressão — sar.config_empresa
logoBase64: z.string().nullable().optional(),
});
export type EmpresaInfo = z.infer<typeof EmpresaInfoSchema>;
// Upload do logo pelo gerente — data URL de imagem; null remove o logo.
// ~700KB de base64 = imagem de ~500KB, suficiente para logo de cabeçalho.
export const UpdateLogoBodySchema = z.object({
logoBase64: z
.string()
.max(700_000)
.regex(/^data:image\/(png|jpeg|jpg|webp);base64,[A-Za-z0-9+/=]+$/)
.nullable(),
});
export type UpdateLogoBody = z.infer<typeof UpdateLogoBodySchema>;

View File

@@ -72,20 +72,18 @@ export const RepInativosSummarySchema = z.object({
});
export type RepInativosSummary = z.infer<typeof RepInativosSummarySchema>;
export const SupervisorDashboardSchema = z.object({
approvalQueue: z.array(PedidoSummarySchema),
pedidosDia: z.object({
count: z.number().int(),
total: z.number(),
countSemanaAnterior: z.number().int(),
totalSemanaAnterior: z.number(),
}),
inativosPorRep: z.array(RepInativosSummarySchema),
syncedAt: z.iso.datetime(),
// Detalhe dos clientes inativos (>30 dias sem compra) de um representante
export const RepInativosDetailSchema = z.object({
codVendedor: z.number().int(),
nomeVendedor: z.string().nullable(),
clientes: z.array(ClienteNaoPositivadoSchema),
});
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;
export type RepInativosDetail = z.infer<typeof RepInativosDetailSchema>;
// novosClientes: clientes da carteira cuja PRIMEIRA compra (historico todo)
// caiu no periodo selecionado
export const PositivacaoRepSchema = z.object({
novosClientes: z.number().int().default(0),
codVendedor: z.number().int(),
nomeVendedor: z.string().nullable(),
totalClientes: z.number().int(),
@@ -104,6 +102,30 @@ export const RankingRepSchema = z.object({
});
export type RankingRep = z.infer<typeof RankingRepSchema>;
export const SupervisorDashboardSchema = z.object({
pedidosDia: z.object({
count: z.number().int(),
total: z.number(),
countSemanaAnterior: z.number().int(),
totalSemanaAnterior: z.number(),
}),
inativosPorRep: z.array(RepInativosSummarySchema),
// Métricas do mês corrente, escopadas à equipe do supervisor
equipeMes: z
.object({
faturamento: z.number(),
pedidos: z.number().int(),
ticketMedio: z.number(),
metaTotal: z.number(),
pctMeta: z.number(),
})
.default({ faturamento: 0, pedidos: 0, ticketMedio: 0, metaTotal: 0, pctMeta: 0 }),
rankingReps: z.array(RankingRepSchema).default([]),
positivacaoReps: z.array(PositivacaoRepSchema).default([]),
syncedAt: z.iso.datetime(),
});
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;
// Clientes distintos positivados por dia do período (YYYY-MM-DD)
export const PositivacaoDiaSchema = z.object({
dia: z.string(),
@@ -111,6 +133,26 @@ export const PositivacaoDiaSchema = z.object({
});
export type PositivacaoDia = z.infer<typeof PositivacaoDiaSchema>;
// Faturamento por estado (UF do município do cliente), com detalhe por representante.
export const UfRepSchema = z.object({
codVendedor: z.number().int(),
nomeVendedor: z.string().nullable(),
faturamento: z.number(),
pedidos: z.number().int(),
clientes: z.number().int(),
});
export type UfRep = z.infer<typeof UfRepSchema>;
export const UfFaturamentoSchema = z.object({
uf: z.string(), // sigla (SP, PR…); 'ND' = cliente sem município/UF
faturamento: z.number(),
pedidos: z.number().int(),
clientes: z.number().int(),
pct: z.number(), // participação no faturamento do período
reps: z.array(UfRepSchema),
});
export type UfFaturamento = z.infer<typeof UfFaturamentoSchema>;
export const ManagerDashboardSchema = z.object({
faturamentoMes: z.number(),
pedidosMes: z.number().int(),
@@ -122,6 +164,15 @@ export const ManagerDashboardSchema = z.object({
rankingReps: z.array(RankingRepSchema),
positivacaoReps: z.array(PositivacaoRepSchema),
positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]),
faturamentoPorUf: z.array(UfFaturamentoSchema).default([]),
// Meta diária de positivação salva pelo gerente (config_empresa)
metaPositivacaoDia: z.number().int().nullable().optional(),
syncedAt: z.iso.datetime(),
});
export type ManagerDashboard = z.infer<typeof ManagerDashboardSchema>;
// Salvar a meta diária de positivação (0 limpa a meta)
export const SaveMetaPositivacaoBodySchema = z.object({
meta: z.number().int().min(0).max(100000),
});
export type SaveMetaPositivacaoBody = z.infer<typeof SaveMetaPositivacaoBodySchema>;

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

@@ -6,9 +6,9 @@ import { z } from 'zod';
// ─── Situa ────────────────────────────────────────────────────────────────────
// Ciclo de vida do pedido SAR:
// 0=Orçamento → 1=Ag. Aprovação (se desconto > alçada) → 2=Transmitido
// Estados que o SAR controla: Orçamento e Transmitido (1 é o gate de desconto).
// Ciclo de vida do pedido SAR: 0=Orçamento → 2=Transmitido.
// Desconto acima da alçada é bloqueio duro na transmissão — não há fila de
// aprovação. O valor 1 (Ag. Aprovação) é legado, mantido só para dados antigos.
// Após Transmitido, o status passa a refletir o ERP (Emitido/Cancelado/Aguardando…)
// — espelhado quando a integração existir.
export const SituaPedidoSchema = z.union([
@@ -40,6 +40,10 @@ export const PedidoItemSchema = z.object({
precoUnitario: z.string(),
descontoPerc: z.string(),
total: z.string(),
// Grupo/subgrupo do produto (vw_produtos) — edição do orçamento usa para o
// teto de desconto. Ausente em pedidos ERP.
codGrupo: z.number().int().nullable().optional(),
codSubgrupo: z.number().int().nullable().optional(),
});
export type PedidoItem = z.infer<typeof PedidoItemSchema>;
@@ -89,6 +93,12 @@ export const PedidoDetailSchema = PedidoSummarySchema.extend({
pedFlex: z.string(),
formaPagamento: z.string().nullable().optional(),
endEntrega: z.string().nullable().optional(),
// Pauta/forma de pagamento do pedido SAR — edição do orçamento pré-carrega
idPauta: z.number().int().nullable().optional(),
codFormapag: z.number().int().nullable().optional(),
// Peso total (kg) e cubagem (m3) — qtd x peso_liquido/vol_m3 de vw_produtos
pesoTotal: z.string().optional(),
m3Total: z.string().optional(),
aprovadoPor: z.number().int().nullable(),
aprovadoEm: z.iso.datetime().nullable(),
motivoRecusa: z.string().nullable(),
@@ -149,16 +159,16 @@ export const CreatePedidoSchema = z.object({
});
export type CreatePedido = z.infer<typeof CreatePedidoSchema>;
export const AprovarPedidoSchema = z.object({
descontoPerc: z.number().min(0).max(100).optional(),
nota: z.string().optional(),
});
export type AprovarPedido = z.infer<typeof AprovarPedidoSchema>;
// Edição de orçamento (situa 0): mesmo shape da criação, sem idempotencyKey
export const UpdatePedidoSchema = CreatePedidoSchema.omit({ idempotencyKey: true });
export type UpdatePedido = z.infer<typeof UpdatePedidoSchema>;
export const RecusarPedidoSchema = z.object({
motivo: z.string().min(1),
// Cancelamento com motivo obrigatório + descrição livre opcional
export const CancelPedidoSchema = z.object({
motivo: z.string().min(1).max(60),
descricao: z.string().max(500).optional(),
});
export type RecusarPedido = z.infer<typeof RecusarPedidoSchema>;
export type CancelPedido = z.infer<typeof CancelPedidoSchema>;
// ─── ERP Consulta ─────────────────────────────────────────────────────────────

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(),
@@ -39,24 +19,39 @@ export const PromocoesResponseSchema = z.object({
});
export type PromocoesResponse = z.infer<typeof PromocoesResponseSchema>;
// Criação aceita VÁRIOS produtos e/ou grupos — o backend cria uma promoção
// por alvo (mesma descrição/%/vigência), mantendo o motor de alçada intacto.
export const CreatePromocaoBodySchema = z.object({
descricao: z.string().min(1).max(255),
codProduto: z.string().optional(),
grpProd: z.string().optional(),
codProdutos: z.array(z.string().min(1)).max(100).optional(),
grpProds: z.array(z.string().min(1)).max(100).optional(),
descPct: z.number().min(0).max(100),
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
dataFim: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
});
export type CreatePromocaoBody = z.infer<typeof CreatePromocaoBodySchema>;
export const UpdatePromocaoBodySchema = CreatePromocaoBodySchema.partial().extend({
// Edição continua por linha (um alvo por promoção)
export const UpdatePromocaoBodySchema = z.object({
descricao: z.string().min(1).max(255).optional(),
codProduto: z.string().nullable().optional(),
grpProd: z.string().nullable().optional(),
descPct: z.number().min(0).max(100).optional(),
dataInicio: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
dataFim: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
ativa: z.boolean().optional(),
});
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(),
@@ -65,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(),
@@ -81,10 +78,14 @@ export const RegrasDescontoResponseSchema = z.object({
});
export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>;
// 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),
codGrupo: z.number().int().nullable().optional(),
codSubgrupo: z.number().int().nullable().optional(),
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}$/),
@@ -92,17 +93,46 @@ export const CreateRegraDescontoBodySchema = z.object({
});
export type CreateRegraDescontoBody = z.infer<typeof CreateRegraDescontoBodySchema>;
export const UpdateRegraDescontoBodySchema = CreateRegraDescontoBodySchema.partial().extend({
// Edição continua por linha (um alvo por regra)
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
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
dataFim: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
ativa: z.boolean().optional(),
});
export type UpdateRegraDescontoBody = z.infer<typeof UpdateRegraDescontoBodySchema>;
// Alçada do usuário logado — usada pelo carrinho para mostrar o teto por item
export const AlcadaRepItemSchema = z.object({
codGrupo: z.number().int(),
limitePerc: z.number(),
});
export type AlcadaRepItem = z.infer<typeof AlcadaRepItemSchema>;
export const AlcadaRepResponseSchema = z.object({
limites: z.array(AlcadaRepItemSchema),
});
export type AlcadaRepResponse = z.infer<typeof AlcadaRepResponseSchema>;
// Promoções vigentes hoje — visíveis ao rep para sinalizar condição no catálogo
export const PromocaoVigenteSchema = z.object({
id: z.number().int(),
descricao: z.string(),
codProduto: z.string().nullable(),
nomeProduto: z.string().nullable(),
grpProd: z.string().nullable(),
grupo: z.string().nullable(),
descPct: z.number(),
dataFim: z.string(),
});
@@ -118,7 +148,11 @@ export const RegraVigenteSchema = z.object({
id: z.number().int(),
descricao: z.string(),
codGrupo: z.number().int().nullable(),
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

@@ -27,6 +27,8 @@ export const PautaSchema = z.object({
idPauta: z.number().int(),
codigo: z.number().int(),
descricao: z.string(),
// Presente só para gestor: quantos representantes usam esta pauta
qtdReps: z.number().int().optional(),
});
export type Pauta = z.infer<typeof PautaSchema>;

8
pnpm-lock.yaml generated
View File

@@ -319,6 +319,9 @@ importers:
'@hookform/resolvers':
specifier: ^5.4.0
version: 5.4.0(react-hook-form@7.76.1(react@19.2.6))
'@svg-maps/brazil':
specifier: ^2.0.0
version: 2.0.0
'@tanstack/react-query':
specifier: ^5.100.14
version: 5.100.14(react@19.2.6)
@@ -3380,6 +3383,9 @@ packages:
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@svg-maps/brazil@2.0.0':
resolution: {integrity: sha512-6le594K1tO6c8Y/0KgWyurksyLnSU/8mTSnr48cKH0eaiG153UQP8aadQ/pKoeCBVd8khLVdmV7wKbkfIAbqBA==}
'@svgr/babel-plugin-add-jsx-attribute@8.0.0':
resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==}
engines: {node: '>=14'}
@@ -13385,6 +13391,8 @@ snapshots:
'@standard-schema/utils@0.3.0': {}
'@svg-maps/brazil@2.0.0': {}
'@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7

View File

@@ -96,6 +96,7 @@ async function applyGrants(client: pg.Client, appRole: string): Promise<void> {
sar.push_subscription,
sar.promocoes,
sar.regras_desconto,
sar.config_empresa,
sar.clientes_novos,
sar.contatos_novos
TO ${appRole};

View File

@@ -289,7 +289,9 @@ SELECT
COALESCE(p.preco_promocional, 0) AS preco_promocional,
COALESCE(p.tx_desc_lote, 0) AS tx_desc_lote,
p.lista_pauta,
CASE WHEN p.dt_atual > sub.da THEN p.dt_atual ELSE sub.da END AS dt_atual
CASE WHEN p.dt_atual > sub.da THEN p.dt_atual ELSE sub.da END AS dt_atual,
-- vol_m3 no FIM: CREATE OR REPLACE VIEW so aceita coluna nova apos as existentes
COALESCE(p.vol_m3, 0) AS vol_m3
FROM gestao.produto p
LEFT JOIN gestao.grupo grp ON grp.codigo = p.cod_grupo AND grp.id_empresa = p.id_empresa
LEFT JOIN gestao.grupo sub ON sub.codigo = p.cod_subgrupo AND sub.id_empresa = p.id_empresa
@@ -718,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,
@@ -728,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,
@@ -736,6 +740,17 @@ CREATE TABLE IF NOT EXISTS sar.regras_desconto (
created_by VARCHAR(100) NOT NULL
);
-- -----------------------------------------------------------------------------
-- Configuracoes por empresa (logo da impressao do pedido, etc.)
-- logo_base64: data URL (data:image/png;base64,...)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sar.config_empresa (
id_empresa INTEGER PRIMARY KEY,
logo_base64 TEXT,
meta_positivacao_dia INTEGER,
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- -----------------------------------------------------------------------------
-- Oportunidades (Funil de Vendas)
-- -----------------------------------------------------------------------------
@@ -820,6 +835,6 @@ CREATE INDEX IF NOT EXISTS idx_chamado_mensagens_chamado ON sar.chamado_mensagen
-- GRANT INSERT, UPDATE, DELETE ON
-- sar.pedidos, sar.pedido_itens, sar.historico_pedido,
-- sar.alcada_desconto, sar.meta_representante, sar.push_subscription,
-- sar.promocoes, sar.regras_desconto, sar.clientes_novos, sar.contatos_novos
-- sar.promocoes, sar.regras_desconto, sar.config_empresa, sar.clientes_novos, sar.contatos_novos
-- TO <role>;
-- =============================================================================