Compare commits

..

31 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
996eff8e65 feat(auth,ger): usuarios dev por papel, escopo do supervisor por equipe e positivacao diaria
- DevLogin com 3 usuarios reais: Pavei (rep 29), Sidnei (supervisor 191)
  e Lucas (gerente 156); guard deixa de forcar DEV_REP_CODE em dev e usa
  sub/id_empresa do JWT (fallback para os valores do .env); idEmpresa do
  token dev opcional, default DEV_EMPRESA_ID
- getTeamCodes (vw_representantes.cod_supervisor): supervisor ve so a
  equipe em pedidos SAR/ERP, detalhe, aprovar/recusar, clientes (lista e
  carteira), dashboard supervisor, chamados SAC e relatorios
  carteira/curva ABC; gerente/admin seguem vendo a empresa toda
- Painel gerencial: card "Positivacao Diaria de Clientes" (clientes
  distintos por dia via vw_pedidos_erp) com campo de meta/dia
  persistido em localStorage, linha de meta tracejada e contador de
  dias na meta

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:24:54 +00:00
f543b8ac7f feat(politicas): regras de desconto por grupo/subgrupo/rep e condicoes comerciais no catalogo
- Nova tabela sar.regras_desconto (grupo, subgrupo, representante, % e
  vigencia; campos nulos = todos) com migration idempotente, espelho no
  sar-erp-schema.sql e GRANT no provision-client
- CRUD manager-only em /politicas/regras + aba "Regras de Desconto" na
  tela de Politicas Comerciais (selects com nomes de rep/grupo/subgrupo
  via /politicas/grupos-produto)
- GET /politicas/regras/vigentes (rep ve so as que o alcancam) e
  GET /politicas/promocoes/vigentes para o catalogo
- Regra vigente pre-aplica o desconto no carrinho do novo pedido
- Selos "Promocao"/"Cond. especial" na busca de produto, catalogo do
  pedido e pagina de catalogo; secao "Condicoes Comerciais" no modal de
  detalhe do produto (descricao, % e validade)
- Empty imageStyle deprecado -> styles.image no carrinho

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:24:21 +00:00
518769218f fix(api,web): corrige autorização da área do rep e falhas silenciosas no front
Auditoria de segurança + robustez da área do representante antes de produção.

API (autorização e injeção):
- IDOR em clients: endpoints de detalhe (findOne, notas, ctr, top-produtos,
  histórico, contatos) validam carteira do rep via assertClienteDaCarteira;
  cliente alheio retorna 404. orders.create idem antes de aceitar idCliente.
- Role guard em dashboards supervisor/manager e ger/chamados (rep -> 403).
- Datas em $queryRawUnsafe agora exigem YYYY-MM-DD (DateOnlySchema + revalidação);
  dtAniversario de contato validado e escapado.
- id_empresa de CTR/NF usa matrizEmpresa() em vez de 1/9001 hardcoded.
- Alçada de desconto: transmit valida desconto efetivo por item (preço vs tabela
  pauta>promo>base + promoção ativa), não só o desconto global.
- Idempotency-Key escopado a empresa+vendedor; unsubscribe com DTO e dono.

Web (erros nunca silenciosos):
- Remove dupla serialização (apiFetch já faz JSON.stringify) em funil, sac e
  politicas — corrige criar oportunidade, abrir chamado e editar políticas.
- Handler global de erro no QueryClient (query e mutation viram toast).
- Error boundary por rota (payload divergente não derruba o app em branco).
- Alert usa message= em vez de title= (AntD 6); sw.js usa globalThis.

Qualidade:
- Corrige ping.contract.spec (idEmpresa) e zera erros de lint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:40:19 +00:00
2a33a5aa4b chore(web): APIs renomeadas do AntD 6 e allowedHosts no dev server
- `<Space direction>` -> `orientation` e `children` -> `content` no
  Popconfirm, acompanhando o rename da API no Ant Design 6.
- `allowedHosts: true` no Vite para permitir acesso ao dev server por
  hostname da rede local.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:17:30 +00:00
8838db16ae fix(web): tabela larga em grid/flex engolia o card vizinho
Na ficha do cliente, o card de titulos em aberto se esticava para o lado e
empurrava a secao de NF-e para fora da tela.

Causa: um track `1fr` de CSS Grid carrega `min-width: auto` implicito e se
recusa a encolher abaixo da largura intrinseca do conteudo. Como as tabelas
usam `scroll={{ x: 'max-content' }}`, a largura intrinseca delas e a soma de
todas as colunas — com titulos suficientes, a coluna estourava os 50% e
comia a vizinha. O `maxWidth: '100%'` ja presente nas tabelas nao ajudava,
porque quem crescia era o track, nao a tabela.

`minWidth: 0` nos filhos do grid desarma o `min-width: auto` e devolve o
scroll horizontal para dentro da tabela, que era o comportamento desejado.

Aplicado tambem em ClientsPage: o `<Col>` que hospeda a tabela de clientes
e estruturalmente identico (Col e flex item, mesmo `min-width: auto`) e so
nao quebrava porque a tabela tem `overflow: 'hidden'` no style — uma linha
que se le como puramente cosmetica (arredondar canto) e que, se removida
num refactor, reintroduziria o bug. A protecao vai para o lugar canonico.

Inclui ainda o span responsivo do campo Endereco na ficha.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:17:23 +00:00
a614147fc4 fix(api): GROUP BY invalido em top-produtos e endereco da empresa
- `listTopProdutos` selecionava `TRIM(p.codigo)` sem incluir `p.codigo` no
  GROUP BY, entao GET /clients/:id/top-produtos respondia 500 com
  "column p.codigo must appear in the GROUP BY clause". Um produto tem um
  unico codigo, entao agrupar por ele nao altera o resultado.
- `/catalog/company` passa a ler `num_endereco` em vez de `numero`, que nao
  correspondia a coluna real do ERP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:17:03 +00:00
26857643fb chore(deps): nestjs-zod 4.3 -> 5.4 corrige 500 em toda falha de validacao
`nestjs-zod@4.3.1` arrasta `@nest-zod/z@2.0.0`, que registra um error map
GLOBAL no Zod chamando `zod.defaultErrorMap` — API removida no Zod 4 (o
projeto usa 4.4.3). O efeito nao era local: qualquer falha de validacao,
em qualquer endpoint, estourava durante a formatacao do erro e virava
500 em vez de resposta de validacao.

Reproduzido em GET /reports/meta sem query params; apos o upgrade retorna
422 com os campos invalidos discriminados.

A v5 nao depende mais de `@nest-zod/z` e e nativa Zod 4. So
`ZodValidationPipe` e `createZodDto` sao usados, ambos estaveis na v5.

Renomeia tambem `pnpm workspace:provision` -> `pnpm client:provision`,
completando o commit de infraestrutura (package.json e pnpm-lock.yaml
precisam viajar juntos, senao o pnpm tenta recriar o node_modules).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:16:57 +00:00
51602dd47e feat(api,web): detalhamento da carteira do representante
Tela dedicada que abre pelo botao "Detalhar carteira" na listagem de
clientes, com leitura acionavel da carteira em vez de so uma tabela.

- `/reports/carteira` passa a devolver `faturamentoTotal`,
  `participacaoPct` e `faturamentoRepTotal`, permitindo medir o peso de
  cada cliente no faturamento do rep.
- `CarteirePage`: cards de resumo (ativos / em risco / inativos / sem
  pedido), faturamento total e InsightCards que apontam concentracao em
  poucos clientes, maior cliente em risco, maior inativo e clientes nunca
  positivados.
- Clientes classificados por dias sem comprar (30d em risco, 60d inativo),
  com navegacao direta para a ficha.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:16:47 +00:00
2649bc9e94 feat(api,web): SAC de chamados sobre pedidos
Representante abre chamado a partir de um pedido e conversa com a empresa
por thread de mensagens; o gerente responde e resolve.

- API: `SacModule` com 5 rotas do rep (listar, detalhe, criar, responder,
  cancelar) e 4 da empresa (/ger/chamados: listar, detalhe, responder,
  resolver). Escopo do rep e por cod_vendedor; o da empresa, por id_empresa.
- Modelos `Chamado` + `ChamadoMensagem` e migrations correspondentes.
- O chamado pode referenciar tanto um pedido nascido no SAR (id_pedido +
  num_ped_sar) quanto um pedido historico do ERP (num_ped_erp) — dai
  id_pedido e num_ped_sar serem nullable. `nome_cliente` fica
  desnormalizado para evitar join em toda listagem.
- Web: `ChamadosPage` (rep), `GerChamadosPage` (gerente) e
  `AbrirChamadoModal`, acessivel tambem pelo detalhe do pedido. Entradas
  "SAC" no menu do rep e do gerente.
- `useOrderErpConsulta` ganha flag `enabled` para a busca de pedido ERP so
  disparar quando o modal esta aberto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:16:16 +00:00
264305386d feat(api,web): funil de vendas do representante
Kanban de oportunidades por etapa (lead, proposta, negociacao, ganho,
perdido) com CRUD completo.

- API: `FunilModule` com GET/POST/PATCH/DELETE em /funil, escopado por
  id_empresa + cod_vendedor.
- Modelo `Oportunidade` + migration `sar.oportunidades`. Uma oportunidade
  referencia um cliente do ERP (id_cliente) ou e um prospect livre
  (nome_prospect / empresa_prospect).
- Contrato Zod compartilhado em `funil.contract.ts`.
- Web: `FunilPage` com colunas por etapa, entrada no menu do rep e rota
  /funil.

Nota: a UI ainda nao expoe seletor de cliente, entao toda oportunidade
criada pela tela nasce como prospect livre; `idCliente` e `idPedido` ja
sao suportados no backend, mas ficam inalcancaveis pela interface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:15:40 +00:00
99 changed files with 8019 additions and 1901 deletions

View File

@@ -0,0 +1,22 @@
-- CreateTable: sar.oportunidades (Funil de Vendas)
-- 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.oportunidades (
id SERIAL PRIMARY KEY,
id_empresa INTEGER NOT NULL,
cod_vendedor INTEGER NOT NULL,
id_cliente INTEGER,
nome_prospect VARCHAR(200),
empresa_prospect VARCHAR(200),
titulo VARCHAR(200) NOT NULL,
etapa VARCHAR(20) NOT NULL DEFAULT 'lead',
valor_estimado NUMERIC(15,2),
observacoes TEXT,
id_pedido UUID REFERENCES sar.pedidos(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sar_oport_vend ON sar.oportunidades(id_empresa, cod_vendedor);
CREATE INDEX IF NOT EXISTS idx_sar_oport_etapa ON sar.oportunidades(etapa);

View File

@@ -0,0 +1,33 @@
-- CreateTable chamados
CREATE TABLE IF NOT EXISTS sar.chamados (
id SERIAL PRIMARY KEY,
id_empresa INTEGER NOT NULL,
cod_vendedor INTEGER NOT NULL,
id_pedido UUID NOT NULL,
num_ped_sar INTEGER NOT NULL,
id_cliente INTEGER NOT NULL,
tipo VARCHAR(50) NOT NULL,
assunto VARCHAR(200) NOT NULL,
descricao TEXT NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'aberto',
resolucao VARCHAR(50),
nota_resolucao TEXT,
itens_afetados JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_chamados_empresa_vendedor ON sar.chamados(id_empresa, cod_vendedor);
CREATE INDEX IF NOT EXISTS idx_chamados_empresa_status ON sar.chamados(id_empresa, status);
CREATE INDEX IF NOT EXISTS idx_chamados_pedido ON sar.chamados(id_pedido);
-- CreateTable chamado_mensagens
CREATE TABLE IF NOT EXISTS sar.chamado_mensagens (
id SERIAL PRIMARY KEY,
id_chamado INTEGER NOT NULL REFERENCES sar.chamados(id) ON DELETE CASCADE,
autor VARCHAR(10) NOT NULL,
texto TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_chamado_mensagens_chamado ON sar.chamado_mensagens(id_chamado);

View File

@@ -0,0 +1,12 @@
-- Torna id_pedido e num_ped_sar opcionais (chamados podem referenciar pedidos ERP)
ALTER TABLE sar.chamados ALTER COLUMN id_pedido DROP NOT NULL;
ALTER TABLE sar.chamados ALTER COLUMN num_ped_sar DROP NOT NULL;
-- Referência ao pedido ERP (numero inteiro do SIG)
ALTER TABLE sar.chamados ADD COLUMN IF NOT EXISTS num_ped_erp INTEGER;
-- Nome do cliente desnormalizado (evita join em toda consulta)
ALTER TABLE sar.chamados ADD COLUMN IF NOT EXISTS nome_cliente VARCHAR(200);
-- Remove index por id_pedido (agora nullable, menos útil como index único)
DROP INDEX IF EXISTS sar.idx_chamados_pedido;

View File

@@ -0,0 +1,23 @@
-- CreateTable: sar.regras_desconto (Regras de Desconto - Politicas Comerciais)
-- Desconto liberado pelo gerente por grupo/subgrupo de produto e/ou
-- representante, com vigencia. Campos nulos = "todos".
-- 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.regras_desconto (
id SERIAL PRIMARY KEY,
id_empresa INTEGER NOT NULL,
descricao VARCHAR(200) NOT NULL,
cod_grupo INTEGER,
cod_subgrupo INTEGER,
cod_vendedor INTEGER,
desc_pct NUMERIC(5,2) NOT NULL,
data_inicio DATE NOT NULL,
data_fim DATE NOT NULL,
ativa BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by VARCHAR(100) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sar_regras_empresa ON sar.regras_desconto(id_empresa);
CREATE INDEX IF NOT EXISTS idx_sar_regras_vigente ON sar.regras_desconto(id_empresa, data_fim) WHERE ativa;

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

@@ -171,6 +171,116 @@ model Promocao {
@@map("promocoes") @@map("promocoes")
} }
// ─── RegraDesconto (Políticas Comerciais) ────────────────────────────────────
//
// Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou
// 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())
idEmpresa Int @map("id_empresa")
descricao String
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")
ativa Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
createdBy String @map("created_by")
@@index([idEmpresa])
@@index([idEmpresa, dataFim])
@@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)
// ou ser um prospect livre (nome_prospect / empresa_prospect).
// etapa: lead | proposta | negociacao | ganho | perdido
model Oportunidade {
id Int @id @default(autoincrement())
idEmpresa Int @map("id_empresa")
codVendedor Int @map("cod_vendedor")
idCliente Int? @map("id_cliente")
nomeProspect String? @map("nome_prospect") @db.VarChar(200)
empresaProspect String? @map("empresa_prospect") @db.VarChar(200)
titulo String @db.VarChar(200)
etapa String @default("lead") @db.VarChar(20)
valorEstimado Decimal? @db.Decimal(15, 2) @map("valor_estimado")
observacoes String?
idPedido String? @db.Uuid @map("id_pedido")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([idEmpresa, codVendedor])
@@index([etapa])
@@map("oportunidades")
}
// ─── Chamados SAC ────────────────────────────────────────────────────────────
//
// Chamado aberto pelo representante vinculado a um pedido SAR.
// tipo: avaria | quantidade_divergente | produto_errado | prazo_entrega | outro
// status: aberto | em_analise | aguardando_rep | resolvido | cancelado
// resolucao: reposicao | desconto | cancelamento_item | sem_acao | outro
model Chamado {
id Int @id @default(autoincrement())
idEmpresa Int @map("id_empresa")
codVendedor Int @map("cod_vendedor")
idPedido String? @db.Uuid @map("id_pedido")
numPedSar Int? @map("num_ped_sar")
numPedErp Int? @map("num_ped_erp")
idCliente Int @map("id_cliente")
nomeCliente String? @db.VarChar(200) @map("nome_cliente")
tipo String @db.VarChar(50)
assunto String @db.VarChar(200)
descricao String
status String @default("aberto") @db.VarChar(30)
resolucao String? @db.VarChar(50)
notaResolucao String? @map("nota_resolucao")
itensAfetados Json? @map("itens_afetados")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
mensagens ChamadoMensagem[]
@@index([idEmpresa, codVendedor])
@@index([idEmpresa, status])
@@map("chamados")
}
model ChamadoMensagem {
id Int @id @default(autoincrement())
idChamado Int @map("id_chamado")
autor String @db.VarChar(10)
texto String
createdAt DateTime @default(now()) @map("created_at")
chamado Chamado @relation(fields: [idChamado], references: [id], onDelete: Cascade)
@@index([idChamado])
@@map("chamado_mensagens")
}
// ─── PushSubscription (C6) ─────────────────────────────────────────────────── // ─── PushSubscription (C6) ───────────────────────────────────────────────────
// //
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser. // Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.

View File

@@ -16,6 +16,8 @@ import { NotificationsModule } from './notifications/notifications.module';
import { ReportsModule } from './reports/reports.module'; import { ReportsModule } from './reports/reports.module';
import { PoliticasModule } from './politicas/politicas.module'; import { PoliticasModule } from './politicas/politicas.module';
import { EquipeModule } from './equipe/equipe.module'; import { EquipeModule } from './equipe/equipe.module';
import { FunilModule } from './funil/funil.module';
import { SacModule } from './sac/sac.module';
import { ProblemDetailsFilter } from './filters/problem-details.filter'; import { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({ @Module({
@@ -35,6 +37,8 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
ReportsModule, ReportsModule,
PoliticasModule, PoliticasModule,
EquipeModule, EquipeModule,
FunilModule,
SacModule,
], ],
providers: [ providers: [
{ provide: APP_PIPE, useClass: ZodValidationPipe }, { provide: APP_PIPE, useClass: ZodValidationPipe },

View File

@@ -20,11 +20,13 @@ export class DevAuthController {
private readonly secret: Uint8Array; private readonly secret: Uint8Array;
private readonly expiresIn: number; private readonly expiresIn: number;
private readonly isProd: boolean; private readonly isProd: boolean;
private readonly devEmpresaId: number;
constructor(config: ConfigService<Env, true>) { constructor(config: ConfigService<Env, true>) {
this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true })); this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true }));
this.expiresIn = config.get('JWT_ACCESS_EXPIRATION', { infer: true }); this.expiresIn = config.get('JWT_ACCESS_EXPIRATION', { infer: true });
this.isProd = config.get('NODE_ENV', { infer: true }) === 'production'; this.isProd = config.get('NODE_ENV', { infer: true }) === 'production';
this.devEmpresaId = config.get('DEV_EMPRESA_ID', { infer: true });
} }
@Post('token') @Post('token')
@@ -33,7 +35,7 @@ export class DevAuthController {
if (this.isProd) throw new NotFoundException(); if (this.isProd) throw new NotFoundException();
const accessToken = await new SignJWT({ const accessToken = await new SignJWT({
id_empresa: dto.idEmpresa, id_empresa: dto.idEmpresa ?? this.devEmpresaId,
role: dto.role, role: dto.role,
}) })
.setProtectedHeader({ alg: 'HS256' }) .setProtectedHeader({ alg: 'HS256' })

View File

@@ -51,10 +51,15 @@ export class JwtAuthGuard implements CanActivate {
(req as Request & { user: JwtPayload }).user = payload as JwtPayload; (req as Request & { user: JwtPayload }).user = payload as JwtPayload;
// Em dev: força representante fixo (DEV_REP_CODE / DEV_EMPRESA_ID) ignorando o JWT. // Em dev: usa sub/id_empresa do próprio JWT (emitido pelo /auth/dev/token,
// Em prod: usa os valores reais do JWT. // que permite logar como Pavei/Sidnei/Lucas) e cai para DEV_REP_CODE /
const idEmpresa = this.isProd ? payload.id_empresa : this.devEmpresaId; // DEV_EMPRESA_ID apenas se o token não trouxer os campos.
const userId = this.isProd ? payload.sub : this.devRepCode; // Em prod: usa os valores reais do JWT do master-login.
const idEmpresa = payload.id_empresa ?? (this.isProd ? undefined : this.devEmpresaId);
const userId = payload.sub ?? (this.isProd ? undefined : this.devRepCode);
if (idEmpresa == null || userId == null) {
throw new UnauthorizedException('token sem sub/id_empresa');
}
this.cls.set('idEmpresa', idEmpresa); this.cls.set('idEmpresa', idEmpresa);
this.cls.set('userId', userId); this.cls.set('userId', userId);
this.cls.set('role', payload.role); this.cls.set('role', payload.role);

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 { createZodDto } from 'nestjs-zod';
import { import {
ProdutoListQuerySchema, ProdutoListQuerySchema,
UpdateLogoBodySchema,
type EmpresaInfo, type EmpresaInfo,
type UpdateLogoBody,
type FormaPagamento, type FormaPagamento,
type Municipio, type Municipio,
type Pauta, type Pauta,
@@ -13,6 +25,7 @@ import {
import { CatalogService } from './catalog.service'; import { CatalogService } from './catalog.service';
class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {} 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). // 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(); 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() @Get()
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> { list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery; 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 { ClsService } from 'nestjs-cls';
import type { import type {
EmpresaInfo, EmpresaInfo,
UpdateLogoBody,
FormaPagamento, FormaPagamento,
Municipio, Municipio,
Pauta, Pauta,
@@ -85,15 +86,15 @@ export class CatalogService {
TRIM(e.cnpj) AS cnpj, TRIM(e.cnpj) AS cnpj,
TRIM(e.inscr_estadual) AS inscr_estadual, TRIM(e.inscr_estadual) AS inscr_estadual,
TRIM(e.endereco) AS endereco, TRIM(e.endereco) AS endereco,
NULLIF(e.numero, 0)::text AS numero, e.num_endereco::text AS numero,
NULLIF(TRIM(e.complemento), '.') AS complemento, e.complemento,
TRIM(e.bairro) AS bairro, TRIM(e.bairro) AS bairro,
TRIM(m.nome) AS cidade, TRIM(m.nome) AS cidade,
TRIM(e.estado::text) AS uf, TRIM(e.uf) AS uf,
TRIM(e.cep::text) AS cep, e.cep,
TRIM(e.telefone::text) AS telefone, e.telefone,
TRIM(e.email) AS email TRIM(e.email) AS email
FROM gestao.empresa e FROM sar.vw_empresas e
LEFT JOIN sar.vw_municipios m ON m.id_municipio = e.id_municipio LEFT JOIN sar.vw_municipios m ON m.id_municipio = e.id_municipio
WHERE e.id_empresa = ${idEmpresa} WHERE e.id_empresa = ${idEmpresa}
LIMIT 1 LIMIT 1
@@ -101,6 +102,8 @@ export class CatalogService {
const r = rows[0]; const r = rows[0];
if (!r) throw new Error(`Empresa matriz ${idEmpresa} não encontrada`); 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 clean = (v: string | null) => {
const t = (v ?? '').trim(); const t = (v ?? '').trim();
return t === '' ? null : t; return t === '' ? null : t;
@@ -120,9 +123,28 @@ export class CatalogService {
cep: clean(r.cep), cep: clean(r.cep),
telefone: clean(r.telefone), telefone: clean(r.telefone),
email: clean(r.email), 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[]> { async municipios(q?: string): Promise<Municipio[]> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -158,13 +180,19 @@ export class CatalogService {
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa')); const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
const userId = this.cls.get('userId'); const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0; const codVendedor = userId ? parseInt(userId, 10) : 0;
const role = this.cls.get('role') ?? 'rep';
interface PautaRow { interface PautaRow {
id_pauta: number; id_pauta: number;
codigo: number; codigo: number;
descricao: string; descricao: string;
qtd_reps?: string;
} }
const rows = await prisma.$queryRawUnsafe<PautaRow[]>(` // 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 SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao
FROM vw_pautas pa FROM vw_pautas pa
JOIN vw_representantes r ON pa.codigo IN ( JOIN vw_representantes r ON pa.codigo IN (
@@ -175,12 +203,26 @@ export class CatalogService {
AND pa.ativo = 1 AND pa.ativo = 1
AND r.codigo = ${codVendedor} AND r.codigo = ${codVendedor}
ORDER BY pa.codigo 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) => ({ return rows.map((r) => ({
idPauta: Number(r.id_pauta), idPauta: Number(r.id_pauta),
codigo: Number(r.codigo), codigo: Number(r.codigo),
descricao: r.descricao, 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'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa')); 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 { q, codGrupo, idPauta, page, limit } = query;
const offset = (page - 1) * limit; 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 grupoFilter = codGrupo != null ? `AND p.cod_grupo = ${codGrupo}` : '';
const searchFilter = q const searchFilter = q
? `AND (p.descricao ILIKE '%${escSql(q)}%' OR p.codigo ILIKE '%${escSql(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 }; 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 { private mapRow(p: ProdutoRow, preco: string): ProdutoSummary {
return { return {
idErp: Number(p.id_erp), idErp: Number(p.id_erp),
@@ -337,12 +418,16 @@ export class CatalogService {
JOIN vw_pautas pa ON pa.id_pauta = pp.id_pauta JOIN vw_pautas pa ON pa.id_pauta = pp.id_pauta
AND pa.id_empresa = ${idEmpresa} AND pa.id_empresa = ${idEmpresa}
AND pa.ativo = 1 AND pa.ativo = 1
JOIN vw_representantes r ON pa.codigo IN ( ${
// 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_pauta1, r.cod_pauta2, r.cod_pauta3,
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6 r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
) ) AND r.codigo = ${codVendedor}`
: ''
}
WHERE pp.id_produto = ${idErp} WHERE pp.id_produto = ${idErp}
AND r.codigo = ${codVendedor}
AND pp.preco1 > 0 AND pp.preco1 > 0
ORDER BY pa.codigo ORDER BY pa.codigo
`), `),

View File

@@ -18,6 +18,7 @@ import type {
TopProduto, TopProduto,
} from '@sar/api-interface'; } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util';
// Thresholds de atividade (FR-2.3). Configuráveis por empresa futuramente. // Thresholds de atividade (FR-2.3). Configuráveis por empresa futuramente.
const ALERT_DAYS = 30; const ALERT_DAYS = 30;
@@ -36,6 +37,12 @@ function escSql(s: string): string {
return s.replace(/'/g, "''"); return s.replace(/'/g, "''");
} }
// Financeiro (CTR) e NF vivem na empresa MATRIZ. O ERP usa códigos > 9000 para
// origem de pedido (ex.: 9001 → matriz 1), espelhando catalog/dashboard/equipe.
function matrizEmpresa(idEmpresa: number): number {
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
}
// Row bruta do $queryRawUnsafe // Row bruta do $queryRawUnsafe
interface ClientRow { interface ClientRow {
id_cliente: number; id_cliente: number;
@@ -108,6 +115,29 @@ const ACTIVITY_CASE = (alias_erp = 'erp_ped', alias_sar = 'sar_ped') => `
export class ClientsService { export class ClientsService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {} constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
// PGD-AUTHZ: rep só acessa clientes da própria carteira; supervisor acessa as
// carteiras da sua equipe (cod_supervisor); gerente/admin passam direto.
// NotFound (não Forbidden) para não revelar a existência de clientes de terceiros.
private async assertClienteDaCarteira(idCliente: number): Promise<void> {
const role = this.cls.get('role') ?? 'rep';
if (role !== 'rep' && 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');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const rows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
idCliente,
);
const dono = rows[0] ? Number(rows[0].cod_vendedor) : null;
const permitidos =
role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : [codVendedor];
if (dono == null || !permitidos.includes(dono)) {
throw new NotFoundException(`Cliente ${idCliente} não encontrado`);
}
}
async list(query: ClientListQuery): Promise<ClientListResponse> { async list(query: ClientListQuery): Promise<ClientListResponse> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -119,8 +149,13 @@ export class ClientsService {
const { q, status, page, limit } = query; const { q, status, page, limit } = query;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
// Rep vê apenas sua carteira (cod_vendedor = seu código) // Rep vê apenas sua carteira; supervisor vê as carteiras da equipe
const vendedorFilter = role === 'rep' ? `AND c.cod_vendedor = ${codVendedor}` : ''; const vendedorFilter =
role === 'rep'
? `AND c.cod_vendedor = ${codVendedor}`
: role === 'supervisor'
? `AND c.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})`
: '';
const searchFilter = q const searchFilter = q
? `AND (c.nome ILIKE '%${escSql(q)}%' OR c.cgcpf LIKE '%${escSql(q)}%')` ? `AND (c.nome ILIKE '%${escSql(q)}%' OR c.cgcpf LIKE '%${escSql(q)}%')`
: ''; : '';
@@ -257,6 +292,7 @@ export class ClientsService {
} }
async listContacts(idCorrent: number): Promise<Contato[]> { async listContacts(idCorrent: number): Promise<Contato[]> {
await this.assertClienteDaCarteira(idCorrent);
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -316,6 +352,7 @@ export class ClientsService {
} }
async createContato(idCorrent: number, dto: CreateContato): Promise<ContatoResult> { async createContato(idCorrent: number, dto: CreateContato): Promise<ContatoResult> {
await this.assertClienteDaCarteira(idCorrent);
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
@@ -337,7 +374,7 @@ export class ClientsService {
${idEmpresa}, ${codVendedor}, ${idCorrent}, ${idEmpresa}, ${codVendedor}, ${idCorrent},
'${esc(dto.nome)}', '${esc(dto.nome)}',
${opt(dto.empresa)}, ${opt(dto.cargo)}, ${opt(dto.departamento)}, ${opt(dto.empresa)}, ${opt(dto.cargo)}, ${opt(dto.departamento)},
${dto.dtAniversario ? `'${dto.dtAniversario}'` : 'NULL'}, ${dto.dtAniversario ? `'${esc(dto.dtAniversario)}'` : 'NULL'},
${opt(dto.telefone)}, ${opt(dto.ramal)}, ${opt(dto.celular)}, ${opt(dto.telefone)}, ${opt(dto.ramal)}, ${opt(dto.celular)},
${opt(dto.whatsapp)}, ${opt(dto.email)}, ${opt(dto.anotacoes)} ${opt(dto.whatsapp)}, ${opt(dto.email)}, ${opt(dto.anotacoes)}
) )
@@ -370,6 +407,7 @@ export class ClientsService {
} }
async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> { async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
@@ -430,12 +468,13 @@ export class ClientsService {
} }
async listTopProdutos(idCliente: number, limit: number): Promise<TopProduto[]> { async listTopProdutos(idCliente: number, limit: number): Promise<TopProduto[]> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
// Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos // Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos
const idEmpresaMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa; const idEmpresaMatriz = matrizEmpresa(idEmpresa);
interface Row { interface Row {
id_produto: number; id_produto: number;
@@ -475,7 +514,7 @@ export class ClientsService {
WHERE ped.id_cliente = ${idCliente} WHERE ped.id_cliente = ${idCliente}
AND ped.id_empresa = ${idEmpresa} AND ped.id_empresa = ${idEmpresa}
AND ped.situa NOT IN (5) AND ped.situa NOT IN (5)
GROUP BY i.id_produto, p.descricao, p.unidade, ped.id_cliente GROUP BY i.id_produto, p.codigo, p.descricao, p.unidade, ped.id_cliente
ORDER BY SUM(i.qtd) DESC ORDER BY SUM(i.qtd) DESC
LIMIT ${limit} LIMIT ${limit}
`); `);
@@ -493,8 +532,10 @@ export class ClientsService {
} }
async getCtrSummary(idCliente: number): Promise<CtrSummary> { async getCtrSummary(idCliente: number): Promise<CtrSummary> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
interface Row { interface Row {
qtd_aberto: string; qtd_aberto: string;
@@ -513,6 +554,7 @@ export class ClientsService {
COALESCE(MAX(CURRENT_DATE - dt_vencimento) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::text AS maior_atraso_dias COALESCE(MAX(CURRENT_DATE - dt_vencimento) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::text AS maior_atraso_dias
FROM sar.vw_ctr FROM sar.vw_ctr
WHERE id_cliente = ${idCliente} WHERE id_cliente = ${idCliente}
AND id_empresa = ${idEmpresaMatriz}
AND situacao = 'A' AND situacao = 'A'
AND saldo > 0 AND saldo > 0
`); `);
@@ -528,6 +570,7 @@ export class ClientsService {
} }
async findOne(idCliente: number): Promise<ClientDetail> { async findOne(idCliente: number): Promise<ClientDetail> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
@@ -579,8 +622,10 @@ export class ClientsService {
} }
async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> { async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
type CtrRow = { type CtrRow = {
id_ctr: number; id_ctr: number;
@@ -602,7 +647,7 @@ export class ClientsService {
saldo::text AS saldo saldo::text AS saldo
FROM sar.vw_ctr FROM sar.vw_ctr
WHERE id_cliente = $1 WHERE id_cliente = $1
AND id_empresa = 1 AND id_empresa = $3
AND situacao = 'A' AND situacao = 'A'
AND saldo > 0 AND saldo > 0
ORDER BY dt_vencimento ASC ORDER BY dt_vencimento ASC
@@ -610,6 +655,7 @@ export class ClientsService {
`, `,
idCliente, idCliente,
limit, limit,
idEmpresaMatriz,
); );
const toDate = (d: Date | string): string => const toDate = (d: Date | string): string =>
@@ -626,8 +672,12 @@ export class ClientsService {
} }
async listNotasFiscais(idCliente: number, limit: number): Promise<NotaFiscal[]> { async listNotasFiscais(idCliente: number, limit: number): Promise<NotaFiscal[]> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
// NF fica na matriz; pedidos podem ter origem na matriz ou na empresa fiscal (matriz+9000)
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
const idEmpresaFiscal = idEmpresaMatriz + 9000;
type NfRow = { type NfRow = {
id_nf: number; id_nf: number;
@@ -653,9 +703,9 @@ export class ClientsService {
JOIN sar.vw_pedidos_erp p JOIN sar.vw_pedidos_erp p
ON p.numero = nf.num_entrega ON p.numero = nf.num_entrega
AND p.tipo = 'E' AND p.tipo = 'E'
AND p.id_empresa IN (1, 9001) AND p.id_empresa IN ($3, $4)
WHERE p.id_cliente = $1 WHERE p.id_cliente = $1
AND nf.id_empresa = 1 AND nf.id_empresa = $3
AND nf.status = 'E' AND nf.status = 'E'
AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != '' AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != ''
ORDER BY nf.id_nf ORDER BY nf.id_nf
@@ -665,6 +715,8 @@ export class ClientsService {
`, `,
idCliente, idCliente,
limit, limit,
idEmpresaMatriz,
idEmpresaFiscal,
); );
return rows.map((r: NfRow) => ({ return rows.map((r: NfRow) => ({

View File

@@ -1,9 +1,28 @@
import { Controller, Get, Query } from '@nestjs/common'; import {
Body,
Controller,
ForbiddenException,
Get,
HttpCode,
ParseIntPipe,
Put,
Query,
} from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; 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 type { WorkspaceClsStore } from '../workspace/workspace.types';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
class SaveMetaPositivacaoDto extends createZodDto(SaveMetaPositivacaoBodySchema) {}
@Controller({ path: 'dashboard' }) @Controller({ path: 'dashboard' })
export class DashboardController { export class DashboardController {
constructor( constructor(
@@ -11,14 +30,44 @@ export class DashboardController {
private readonly cls: ClsService<WorkspaceClsStore>, private readonly cls: ClsService<WorkspaceClsStore>,
) {} ) {}
// PGD-AUTHZ: dashboards gerenciais agregam dados da empresa toda (faturamento,
// ranking de todos os reps) — rep não pode acessá-los.
private assertGestor(): void {
const role = this.cls.get('role') ?? 'rep';
if (role === 'rep') {
throw new ForbiddenException('Apenas supervisores e gerentes podem acessar este painel');
}
}
@Get('rep') @Get('rep')
repDashboard(): Promise<RepDashboard> { repDashboard(): Promise<RepDashboard> {
return this.dashboard.repDashboard(this.cls.get('userId') ?? ''); return this.dashboard.repDashboard(this.cls.get('userId') ?? '');
} }
@Get('supervisor') @Get('supervisor')
supervisorDashboard(): Promise<SupervisorDashboard> { supervisorDashboard(
return this.dashboard.supervisorDashboard(); @Query('codSupervisor') codSupervisor?: string,
): Promise<SupervisorDashboard> {
this.assertGestor();
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') @Get('manager')
@@ -26,6 +75,7 @@ export class DashboardController {
@Query('mes') mes?: string, @Query('mes') mes?: string,
@Query('ano') ano?: string, @Query('ano') ano?: string,
): Promise<ManagerDashboard> { ): Promise<ManagerDashboard> {
this.assertGestor();
return this.dashboard.managerDashboard( return this.dashboard.managerDashboard(
mes ? parseInt(mes, 10) : undefined, mes ? parseInt(mes, 10) : undefined,
ano ? parseInt(ano, 10) : undefined, ano ? parseInt(ano, 10) : undefined,

View File

@@ -1,17 +1,17 @@
import { Injectable } from '@nestjs/common'; import { ForbiddenException, Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import type { import type {
RepDashboard, RepDashboard,
RepInativosDetail,
SupervisorDashboard, SupervisorDashboard,
ManagerDashboard, ManagerDashboard,
RankingRep, RankingRep,
PositivacaoRep, PositivacaoRep,
} from '@sar/api-interface'; } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util';
// Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado // 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. // vw_metas.tipo (gestao.metavenda): GL = meta global, GR = meta por grupo.
const TIPO_META_GLOBAL = 'GL'; const TIPO_META_GLOBAL = 'GL';
@@ -413,6 +413,22 @@ export class DashboardService {
total_clientes: string; total_clientes: string;
clientes_positivados: string; clientes_positivados: string;
} }
interface PositivacaoDiaRow {
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 [ const [
statsRows, statsRows,
@@ -422,6 +438,9 @@ export class DashboardService {
positivacaoRows, positivacaoRows,
metaGrupoRows, metaGrupoRows,
realizadoGrupoRows, realizadoGrupoRows,
positivacaoDiariaRows,
novosClientesRows,
ufRepRows,
] = await Promise.all([ ] = await Promise.all([
prisma.$queryRawUnsafe<TotalRow[]>(` prisma.$queryRawUnsafe<TotalRow[]>(`
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
@@ -518,6 +537,57 @@ export class DashboardService {
AND e.dt_pedido <= '${monthEndStr}' AND e.dt_pedido <= '${monthEndStr}'
GROUP BY p.cod_grupo, grupo GROUP BY p.cod_grupo, grupo
`), `),
prisma.$queryRawUnsafe<PositivacaoDiaRow[]>(`
SELECT dt_pedido::date::text AS dia,
COUNT(DISTINCT id_cliente)::text AS clientes
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
AND dt_pedido >= '${monthStartStr}'
AND dt_pedido <= '${monthEndStr}'
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); const pedidosMes = Number(statsRows[0]?.count ?? 0);
@@ -568,6 +638,14 @@ export class DashboardService {
const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size; const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size;
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0); 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 positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => {
const total = Number(r.total_clientes); const total = Number(r.total_clientes);
const positivados = Number(r.clientes_positivados); const positivados = Number(r.clientes_positivados);
@@ -577,9 +655,42 @@ export class DashboardService {
totalClientes: total, totalClientes: total,
clientesPositivados: positivados, clientesPositivados: positivados,
pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0, 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 { return {
faturamentoMes, faturamentoMes,
pedidosMes, pedidosMes,
@@ -590,22 +701,54 @@ export class DashboardService {
metasPorGrupo, metasPorGrupo,
rankingReps, rankingReps,
positivacaoReps, positivacaoReps,
positivacaoDiaria: positivacaoDiariaRows.map((r) => ({
dia: r.dia,
clientes: Number(r.clientes),
})),
faturamentoPorUf,
metaPositivacaoDia: config?.metaPositivacaoDia ?? null,
syncedAt: now.toISOString(), 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'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
const now = new Date(); const now = new Date();
// Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP) // Supervisor vê só a própria equipe (cod_supervisor em vw_representantes).
const approvalQueue = await prisma.pedido.findMany({ // Gerente/admin: sem codSupervisor veem a empresa toda; com codSupervisor
where: { idEmpresa, situa: SITUA_PENDENTE }, // (Detalhar da aba Equipe) veem o time daquele supervisor. codSupervisor é
orderBy: { dtPedido: 'asc' }, // ignorado para role 'supervisor' — não pode espiar outra equipe.
take: 50, const role = this.cls.get('role');
}); const userId = this.cls.get('userId');
let teamCode: number | null = null;
if (role === 'supervisor') {
teamCode = userId ? parseInt(userId, 10) : 0;
} else if (codSupervisor && codSupervisor > 0) {
teamCode = codSupervisor;
}
const team = teamCode ? await getTeamCodes(prisma, teamCode) : null;
const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : '');
// Pedidos do dia — lê do ERP (situa != 5=Cancelado) // Pedidos do dia — lê do ERP (situa != 5=Cancelado)
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
@@ -626,16 +769,163 @@ export class DashboardService {
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
FROM vw_pedidos_erp FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa} AND situa != 5 AND dt_pedido >= '${todayStr}' WHERE id_empresa = ${idEmpresa} AND situa != 5 AND dt_pedido >= '${todayStr}'
${teamSqlFilter('cod_vendedor')}
`), `),
prisma.$queryRawUnsafe<DayRow[]>(` prisma.$queryRawUnsafe<DayRow[]>(`
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
FROM vw_pedidos_erp FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa} AND situa != 5 WHERE id_empresa = ${idEmpresa} AND situa != 5
AND dt_pedido >= '${lastWeekStr}' AND dt_pedido < '${lastWeekEndStr}' AND dt_pedido >= '${lastWeekStr}' AND dt_pedido < '${lastWeekEndStr}'
${teamSqlFilter('cod_vendedor')}
`), `),
]); ]);
// 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 thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(` const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(`
SELECT inativos.cod_vendedor, SELECT inativos.cod_vendedor,
@@ -651,56 +941,17 @@ export class DashboardService {
AND p.id_empresa = ${idEmpresa} AND p.id_empresa = ${idEmpresa}
AND p.situa != 5 AND p.situa != 5
WHERE c.ativo = 1 WHERE c.ativo = 1
${teamSqlFilter('c.cod_vendedor')}
GROUP BY c.id_cliente, c.cod_vendedor GROUP BY c.id_cliente, c.cod_vendedor
HAVING MAX(p.dt_pedido) IS NULL HAVING MAX(p.dt_pedido) IS NULL
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}' OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
) inativos ) inativos
GROUP BY inativos.cod_vendedor GROUP BY inativos.cod_vendedor
ORDER BY COUNT(*) DESC 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 { return {
approvalQueue: approvalQueue.map(mapPedido),
pedidosDia: { pedidosDia: {
count: Number(todayRows[0]?.count ?? 0), count: Number(todayRows[0]?.count ?? 0),
total: Number(todayRows[0]?.total ?? 0), total: Number(todayRows[0]?.total ?? 0),
@@ -712,7 +963,93 @@ export class DashboardService {
nomeVendedor: r.nome_vendedor ?? null, nomeVendedor: r.nome_vendedor ?? null,
inativosCount: parseInt(r.inativos_count, 10), 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(), 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 { Controller, Get } from '@nestjs/common';
import type { EquipeResponse } from '@sar/api-interface'; import type { EquipeResponse, SupervisoresResponse } from '@sar/api-interface';
import { EquipeService } from './equipe.service'; import { EquipeService } from './equipe.service';
@Controller({ path: 'equipe' }) @Controller({ path: 'equipe' })
@@ -10,4 +10,9 @@ export class EquipeController {
listEquipe(): Promise<EquipeResponse> { listEquipe(): Promise<EquipeResponse> {
return this.equipe.listEquipe(); return this.equipe.listEquipe();
} }
@Get('supervisores')
listSupervisores(): Promise<SupervisoresResponse> {
return this.equipe.listSupervisores();
}
} }

View File

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

View File

@@ -0,0 +1,48 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseIntPipe,
Patch,
Post,
} from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import {
CreateOportunidadeSchema,
UpdateOportunidadeSchema,
type FunilResponse,
type Oportunidade,
} from '@sar/api-interface';
import { FunilService } from './funil.service';
class CreateDto extends createZodDto(CreateOportunidadeSchema) {}
class UpdateDto extends createZodDto(UpdateOportunidadeSchema) {}
@Controller('funil')
export class FunilController {
constructor(private readonly funil: FunilService) {}
@Get()
listar(): Promise<FunilResponse> {
return this.funil.listar();
}
@Post()
criar(@Body() dto: CreateDto): Promise<Oportunidade> {
return this.funil.criar(CreateOportunidadeSchema.parse(dto));
}
@Patch(':id')
atualizar(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateDto): Promise<Oportunidade> {
return this.funil.atualizar(id, UpdateOportunidadeSchema.parse(dto));
}
@Delete(':id')
@HttpCode(204)
deletar(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.funil.deletar(id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { FunilController } from './funil.controller';
import { FunilService } from './funil.service';
@Module({
controllers: [FunilController],
providers: [FunilService],
})
export class FunilModule {}

View File

@@ -0,0 +1,141 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
CreateOportunidadeDto,
UpdateOportunidadeDto,
FunilResponse,
EtapaFunil,
Oportunidade,
} from '@sar/api-interface';
type OportRow = {
id: number;
idCliente: number | null;
nomeProspect: string | null;
empresaProspect: string | null;
titulo: string;
etapa: string;
valorEstimado: unknown;
observacoes: string | null;
idPedido: string | null;
createdAt: Date;
updatedAt: Date;
};
type PrismaCtx = {
oportunidade: {
findMany: (args: object) => Promise<OportRow[]>;
create: (args: object) => Promise<OportRow>;
update: (args: object) => Promise<OportRow>;
delete: (args: object) => Promise<void>;
};
$queryRawUnsafe: <T>(sql: string, ...args: unknown[]) => Promise<T[]>;
};
type ClienteRow = { id_cliente: number; nome: string | null; razao: string | null };
@Injectable()
export class FunilService {
constructor(private readonly cls: ClsService) {}
private ctx(): { prisma: PrismaCtx; idEmpresa: number; codVendedor: number } {
const prisma = this.cls.get('prisma') as PrismaCtx;
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa') as number;
const userId = this.cls.get('userId') as string | undefined;
const codVendedor = userId ? parseInt(userId, 10) : 0;
return { prisma, idEmpresa, codVendedor };
}
private toOp(r: OportRow, nomeMap: Map<number, string>): Oportunidade {
return {
id: r.id,
idCliente: r.idCliente,
nomeProspect: r.nomeProspect,
empresaProspect: r.empresaProspect,
titulo: r.titulo,
etapa: r.etapa as EtapaFunil,
valorEstimado: r.valorEstimado != null ? String(r.valorEstimado) : null,
observacoes: r.observacoes,
idPedido: r.idPedido,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
nomeCliente: r.idCliente ? (nomeMap.get(r.idCliente) ?? null) : null,
};
}
async listar(): Promise<FunilResponse> {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const rows = await prisma.oportunidade.findMany({
where: { idEmpresa, codVendedor },
orderBy: { updatedAt: 'desc' },
});
const idClientes = [
...new Set(rows.filter((r) => r.idCliente).map((r) => r.idCliente as number)),
];
const nomeMap = new Map<number, string>();
if (idClientes.length > 0) {
const ids = idClientes.join(',');
const clientes = await prisma.$queryRawUnsafe<ClienteRow>(
`SELECT id_cliente, TRIM(nome) AS nome, TRIM(razao) AS razao FROM vw_clientes WHERE id_cliente IN (${ids}) AND id_empresa = ${idEmpresa}`,
);
for (const c of clientes) {
nomeMap.set(c.id_cliente, c.razao ?? c.nome ?? `Cód. ${c.id_cliente}`);
}
}
const byEtapa = (etapa: EtapaFunil) =>
rows.filter((r) => r.etapa === etapa).map((r) => this.toOp(r, nomeMap));
return {
lead: byEtapa('lead'),
proposta: byEtapa('proposta'),
negociacao: byEtapa('negociacao'),
ganho: byEtapa('ganho'),
perdido: byEtapa('perdido'),
};
}
async criar(dto: CreateOportunidadeDto): Promise<Oportunidade> {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const row = await prisma.oportunidade.create({
data: {
idEmpresa,
codVendedor,
idCliente: dto.idCliente ?? null,
nomeProspect: dto.nomeProspect ?? null,
empresaProspect: dto.empresaProspect ?? null,
titulo: dto.titulo,
etapa: dto.etapa ?? 'lead',
valorEstimado: dto.valorEstimado ?? null,
observacoes: dto.observacoes ?? null,
},
});
return this.toOp(row, new Map());
}
async atualizar(id: number, dto: UpdateOportunidadeDto): Promise<Oportunidade> {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const row = await prisma.oportunidade.update({
where: { id, idEmpresa, codVendedor },
data: {
...(dto.idCliente !== undefined && { idCliente: dto.idCliente }),
...(dto.nomeProspect !== undefined && { nomeProspect: dto.nomeProspect }),
...(dto.empresaProspect !== undefined && { empresaProspect: dto.empresaProspect }),
...(dto.titulo !== undefined && { titulo: dto.titulo }),
...(dto.etapa !== undefined && { etapa: dto.etapa }),
...(dto.valorEstimado !== undefined && { valorEstimado: dto.valorEstimado }),
...(dto.observacoes !== undefined && { observacoes: dto.observacoes }),
...(dto.idPedido !== undefined && { idPedido: dto.idPedido }),
},
});
return this.toOp(row, new Map());
}
async deletar(id: number): Promise<void> {
const { prisma, idEmpresa, codVendedor } = this.ctx();
await prisma.oportunidade.delete({ where: { id, idEmpresa, codVendedor } });
}
}

View File

@@ -1,10 +1,15 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Req } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Req } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { SubscribePayloadSchema, type SubscribePayload } from '@sar/api-interface'; import {
SubscribePayloadSchema,
UnsubscribePayloadSchema,
type SubscribePayload,
} from '@sar/api-interface';
import type { AuthenticatedRequest } from '../auth/jwt.types'; import type { AuthenticatedRequest } from '../auth/jwt.types';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
class SubscribeDto extends createZodDto(SubscribePayloadSchema) {} class SubscribeDto extends createZodDto(SubscribePayloadSchema) {}
class UnsubscribeDto extends createZodDto(UnsubscribePayloadSchema) {}
@Controller('notifications') @Controller('notifications')
export class NotificationsController { export class NotificationsController {
@@ -18,8 +23,8 @@ export class NotificationsController {
@Delete('unsubscribe') @Delete('unsubscribe')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async unsubscribe(@Body() body: { endpoint: string }): Promise<void> { async unsubscribe(@Req() req: AuthenticatedRequest, @Body() body: UnsubscribeDto): Promise<void> {
await this.svc.unsubscribe(body.endpoint); await this.svc.unsubscribe(req.user.sub, body.endpoint);
} }
@Get('pending-count') @Get('pending-count')

View File

@@ -40,11 +40,15 @@ export class NotificationsService {
}); });
} }
async unsubscribe(endpoint: string): Promise<void> { // Escopado ao dono: só remove subscription do próprio usuário — quem souber o
// endpoint de terceiro não consegue desregistrá-lo.
async unsubscribe(userId: string, endpoint: string): Promise<void> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const codVendedor = userId ? parseInt(userId, 10) : null;
await prisma.pushSubscription.deleteMany({ where: { endpoint } }); await prisma.pushSubscription.deleteMany({ where: { endpoint, idEmpresa, codVendedor } });
} }
async pendingCount(userId: string, role: string): Promise<PendingCountResponse> { async pendingCount(userId: string, role: string): Promise<PendingCountResponse> {

View File

@@ -1,7 +1,6 @@
import { import {
Body, Body,
Controller, Controller,
ForbiddenException,
Get, Get,
HttpCode, HttpCode,
Param, Param,
@@ -11,38 +10,33 @@ import {
Post, Post,
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { import {
AprovarPedidoSchema, CancelPedidoSchema,
CreatePedidoSchema, CreatePedidoSchema,
UpdatePedidoSchema,
PedidoErpConsultaQuerySchema, PedidoErpConsultaQuerySchema,
PedidoListQuerySchema, PedidoListQuerySchema,
RecusarPedidoSchema, type CancelPedido,
type AprovarPedido,
type CreatePedido, type CreatePedido,
type UpdatePedido,
type PedidoDetail, type PedidoDetail,
type PedidoErpConsultaQuery, type PedidoErpConsultaQuery,
type PedidoErpConsultaResponse, type PedidoErpConsultaResponse,
type PedidoListQuery, type PedidoListQuery,
type PedidoListResponse, type PedidoListResponse,
type RecusarPedido,
} from '@sar/api-interface'; } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { OrdersService } from './orders.service'; import { OrdersService } from './orders.service';
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {} class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {} class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {} class UpdatePedidoDto extends createZodDto(UpdatePedidoSchema) {}
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {} class CancelPedidoDto extends createZodDto(CancelPedidoSchema) {}
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {} class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
@Controller({ path: 'orders' }) @Controller({ path: 'orders' })
export class OrdersController { export class OrdersController {
constructor( constructor(private readonly orders: OrdersService) {}
private readonly orders: OrdersService,
private readonly cls: ClsService<WorkspaceClsStore>,
) {}
@Get() @Get()
list(@Query() query: PedidoListQueryDto): Promise<PedidoListResponse> { list(@Query() query: PedidoListQueryDto): Promise<PedidoListResponse> {
@@ -62,31 +56,23 @@ export class OrdersController {
return this.orders.transmit(id); 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') @Patch(':id/cancel')
cancel(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> { cancel(
return this.orders.cancel(id); @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') @Get('erp/:idPedido')

View File

@@ -2,8 +2,9 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import type { import type {
AprovarPedido, CancelPedido,
CreatePedido, CreatePedido,
UpdatePedido,
PedidoDetail, PedidoDetail,
PedidoErpConsultaItem, PedidoErpConsultaItem,
PedidoErpConsultaQuery, PedidoErpConsultaQuery,
@@ -11,15 +12,14 @@ import type {
PedidoListQuery, PedidoListQuery,
PedidoListResponse, PedidoListResponse,
PedidoSummary, PedidoSummary,
RecusarPedido,
} from '@sar/api-interface'; } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
// Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado // Situa SAR: 0=Orçamento, 2=Confirmado, 3=Cancelado, 4=Faturado
// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado // Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado
const SITUA_ORCAMENTO = 0; const SITUA_ORCAMENTO = 0;
const SITUA_PENDENTE = 1;
const SITUA_APROVADO = 2; const SITUA_APROVADO = 2;
const SITUA_CANCELADO = 3; const SITUA_CANCELADO = 3;
@@ -51,9 +51,13 @@ export class OrdersService {
const { idCliente, situa, numPedSar, from, to, page, limit } = query; const { idCliente, situa, numPedSar, from, to, page, limit } = query;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
// Rep vê só os próprios pedidos; supervisor vê os da sua equipe
// (cod_supervisor em vw_representantes); gerente/admin veem tudo.
const team = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : [];
const where: Prisma.PedidoWhereInput = { const where: Prisma.PedidoWhereInput = {
idEmpresa, idEmpresa,
...(role === 'rep' ? { codVendedor } : {}), ...(role === 'rep' ? { codVendedor } : {}),
...(role === 'supervisor' ? { codVendedor: { in: team } } : {}),
...(idCliente != null ? { idCliente } : {}), ...(idCliente != null ? { idCliente } : {}),
...(situa != null ? { situa } : {}), ...(situa != null ? { situa } : {}),
...(numPedSar ? { numPedSar: { contains: numPedSar, mode: 'insensitive' as const } } : {}), ...(numPedSar ? { numPedSar: { contains: numPedSar, mode: 'insensitive' as const } } : {}),
@@ -127,7 +131,19 @@ export class OrdersService {
dataHistorico.setDate(dataHistorico.getDate() - 90); dataHistorico.setDate(dataHistorico.getDate() - 90);
const dataHistoricoStr = dataHistorico.toISOString().split('T')[0]; const dataHistoricoStr = dataHistorico.toISOString().split('T')[0];
const vendedorFilter = role === 'rep' ? `AND a.cod_vendedor = ${codVendedor}` : ''; // Defesa em profundidade: o contrato já exige YYYY-MM-DD, mas como as datas
// são interpoladas no SQL abaixo, revalidamos antes de montar a query.
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
if ((from && !DATE_RE.test(from)) || (to && !DATE_RE.test(to))) {
throw new BadRequestException('Datas devem estar no formato YYYY-MM-DD');
}
const vendedorFilter =
role === 'rep'
? `AND a.cod_vendedor = ${codVendedor}`
: role === 'supervisor'
? `AND a.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})`
: '';
const fromFilterE = from ? `AND COALESCE(a.dt_pedido, b.dt_pedido) >= '${from}'` : ''; const fromFilterE = from ? `AND COALESCE(a.dt_pedido, b.dt_pedido) >= '${from}'` : '';
const toFilterE = to ? `AND COALESCE(a.dt_pedido, b.dt_pedido) <= '${to}'` : ''; const toFilterE = to ? `AND COALESCE(a.dt_pedido, b.dt_pedido) <= '${to}'` : '';
const fromFilterP = from ? `AND a.dt_pedido >= '${from}'` : ''; const fromFilterP = from ? `AND a.dt_pedido >= '${from}'` : '';
@@ -279,7 +295,12 @@ export class OrdersService {
const userId = this.cls.get('userId'); const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0; const codVendedor = userId ? parseInt(userId, 10) : 0;
const repFilter = role === 'rep' ? { codVendedor } : {}; const repFilter =
role === 'rep'
? { codVendedor }
: role === 'supervisor'
? { codVendedor: { in: await getTeamCodes(prisma, codVendedor) } }
: {};
const o = await prisma.pedido.findFirst({ const o = await prisma.pedido.findFirst({
where: { id, idEmpresa, ...repFilter }, where: { id, idEmpresa, ...repFilter },
@@ -294,20 +315,144 @@ export class OrdersService {
return this.mapDetail(o); return this.mapDetail(o);
} }
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A validação de alçada e a // PGD-AUTHZ: o rep só vende produtos/preços das pautas vinculadas ao cadastro
// notificação ao supervisor acontecem no transmit(), não aqui. // 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). // Idempotency-Key: retorna pedido existente se já processado (FR-4.3).
async create(dto: CreatePedido): Promise<PedidoDetail> { async create(dto: CreatePedido): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
const role = this.cls.get('role');
const userId = this.cls.get('userId') ?? '0'; const userId = this.cls.get('userId') ?? '0';
const codVendedor = parseInt(userId, 10); const codVendedor = parseInt(userId, 10);
// Idempotency-Key: retorna pedido existente sem re-processar // PGD-AUTHZ: rep só lança pedido para cliente da própria carteira.
if (role === 'rep') {
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`);
}
}
// Idempotency-Key: retorna pedido existente sem re-processar.
// Escopado a empresa + vendedor — chave de terceiro não vaza pedido alheio.
if (dto.idempotencyKey) { if (dto.idempotencyKey) {
const existing = await prisma.pedido.findUnique({ const existing = await prisma.pedido.findFirst({
where: { idempotencyKey: dto.idempotencyKey }, where: { idempotencyKey: dto.idempotencyKey, idEmpresa, codVendedor },
include: { include: {
itens: { orderBy: { ordem: 'asc' } }, itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } }, historico: { orderBy: { changedAt: 'asc' } },
@@ -316,6 +461,14 @@ export class OrdersService {
if (existing) return this.mapDetail(existing); 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 itemsData = dto.itens.map((it) => {
const descontoValor = const descontoValor =
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100; Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
@@ -399,7 +552,10 @@ export class OrdersService {
// Rep só transmite o próprio orçamento // Rep só transmite o próprio orçamento
const repFilter = role === 'rep' ? { codVendedor } : {}; const repFilter = role === 'rep' ? { codVendedor } : {};
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } }); const pedido = await prisma.pedido.findFirst({
where: { id, idEmpresa, ...repFilter },
include: { itens: { orderBy: { ordem: 'asc' } } },
});
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`); if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (pedido.situa !== SITUA_ORCAMENTO) if (pedido.situa !== SITUA_ORCAMENTO)
throw new BadRequestException( throw new BadRequestException(
@@ -417,6 +573,8 @@ export class OrdersService {
); );
} }
await this.assertAlcadaItens(pedido, limitMap, limiteMax);
const now = new Date(); const now = new Date();
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } }); await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } });
await prisma.historicoPedido.create({ await prisma.historicoPedido.create({
@@ -440,114 +598,248 @@ export class OrdersService {
return this.mapDetail(final); return this.mapDetail(final);
} }
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> { // 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 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 | number;
idPauta: number | null;
itens: {
ordem: number;
idProduto: number;
codProduto: string | null;
precoUnitario: Prisma.Decimal | number;
descontoPerc: Prisma.Decimal | number;
}[];
},
limitMap: Map<number, number>,
limiteDefault: number,
): Promise<void> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
if (pedido.itens.length === 0) return;
const prodIds = [...new Set(pedido.itens.map((i) => i.idProduto))];
interface ProdRow {
id_erp: number;
codigo: string | null;
cod_grupo: number | null;
cod_subgrupo: number | null;
vl_preco1: string | null;
preco_promocional: string | null;
preco_pauta: string | null;
}
// prodIds/idPauta vêm do banco (Int), não do payload — interpolação segura.
const prodRows = await prisma.$queryRawUnsafe<ProdRow[]>(`
SELECT p.id_erp, TRIM(p.codigo) AS codigo, p.cod_grupo, p.cod_subgrupo,
p.vl_preco1::text, p.preco_promocional::text,
${
pedido.idPauta != null
? `(SELECT pp.preco1::text FROM vw_pauta_produtos pp
WHERE pp.id_pauta = ${Number(pedido.idPauta)}
AND pp.id_produto = p.id_erp LIMIT 1)`
: 'NULL'
} AS preco_pauta
FROM vw_produtos p
WHERE p.id_empresa = ${idMatriz}
AND p.id_erp IN (${prodIds.join(',')})
`);
const prodMap = new Map(prodRows.map((p) => [Number(p.id_erp), p]));
const hoje = new Date();
const promos = await prisma.promocao.findMany({
where: {
idEmpresa: { in: [idEmpresa, idMatriz] },
ativa: true,
dataInicio: { lte: hoje },
dataFim: { gte: hoje },
},
});
// Regras de desconto vigentes que alcançam o rep dono do pedido
// (cod_vendedor nulo = todos os reps).
const regras = await prisma.regraDesconto.findMany({
where: {
idEmpresa: { in: [idEmpresa, idMatriz] },
ativa: true,
dataInicio: { lte: hoje },
dataFim: { gte: hoje },
OR: [{ codVendedor: null }, { codVendedor: pedido.codVendedor }],
},
});
const descGlobal = Number(pedido.descontoPerc);
const problemas: string[] = [];
for (const it of pedido.itens) {
const prod = prodMap.get(it.idProduto);
const codGrupo = prod?.cod_grupo != null ? Number(prod.cod_grupo) : null;
const codigo = prod?.codigo?.trim() || it.codProduto || String(it.idProduto);
const promoPct = promos
.filter(
(p) =>
(p.codProduto && prod?.codigo && p.codProduto.trim() === prod.codigo.trim()) ||
(p.grpProd && codGrupo != null && p.grpProd.trim() === String(codGrupo)),
)
.reduce((max, p) => Math.max(max, Number(p.descPct)), 0);
// 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.idPauta == null || (pedido.idPauta != null && r.idPauta === pedido.idPauta)),
)
.reduce((max, r) => Math.max(max, Number(r.descPct)), 0);
// 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;
const precoBase = precoPauta > 0 ? precoPauta : Number(prod?.vl_preco1 ?? 0);
const precoPromo = Number(prod?.preco_promocional ?? 0);
const tabela = precoPromo > 0 && precoPromo < precoBase ? precoPromo : precoBase;
const descItem = Number(it.descontoPerc);
const precoLiquido = Number(it.precoUnitario) * (1 - descItem / 100) * (1 - descGlobal / 100);
// Desconto efetivo: vs tabela quando há referência; senão só os percentuais
const descEfetivo =
tabela > 0
? (1 - precoLiquido / tabela) * 100
: (1 - (1 - descItem / 100) * (1 - descGlobal / 100)) * 100;
if (descEfetivo > limiteItem + 0.01) {
problemas.push(
`item ${it.ordem} (${codigo}) com desconto efetivo de ${descEfetivo.toFixed(1)}% — máximo permitido ${limiteItem}%`,
);
}
}
if (problemas.length > 0) {
throw new BadRequestException(
`Desconto acima da sua alçada: ${problemas.join('; ')}. Ajuste preço/desconto para continuar.`,
);
}
}
// 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 userId = this.cls.get('userId') ?? '0';
const codVendedor = parseInt(userId, 10); 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) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (pedido.situa !== SITUA_PENDENTE) if (pedido.situa !== SITUA_ORCAMENTO)
throw new BadRequestException( throw new BadRequestException(
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`, 'Apenas orçamentos podem ser editados — este pedido já foi transmitido',
); );
const now = new Date(); // PGD-AUTHZ: rep só aponta o pedido para cliente da própria carteira.
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc); if (role === 'rep' && dto.idCliente !== pedido.idCliente) {
const newTotal = const cliRows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
Math.round(Number(pedido.totalProdutos) * (1 - newDescontoPerc / 100) * 100) / 100; `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 }, where: { id },
data: { data: {
situa: SITUA_APROVADO, idCliente: dto.idCliente,
descontoPerc: newDescontoPerc, idPauta: dto.idPauta ?? null,
total: newTotal, codFormapag: dto.codFormapag ?? null,
aprovadoPor: codVendedor, totalProdutos,
aprovadoEm: now, total,
}, descontoPerc: dto.descontoPerc,
}); descontoValor: descontoValorGlobal,
obs: dto.obs ?? null,
await prisma.historicoPedido.create({ endEntrega: dto.endEntrega ?? null,
data: { itens: { deleteMany: {}, create: itemsData },
idPedido: id, historico: {
situaAnterior: SITUA_PENDENTE, create: [
situaNova: SITUA_APROVADO, {
situaAnterior: SITUA_ORCAMENTO,
situaNova: SITUA_ORCAMENTO,
changedBy: codVendedor, changedBy: codVendedor,
changedAt: now, changedAt: new Date(),
nota: dto.nota ?? null, nota: 'Orçamento editado',
},
],
},
}, },
});
const final = await prisma.pedido.findUniqueOrThrow({
where: { id },
include: { include: {
itens: { orderBy: { ordem: 'asc' } }, itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: '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); return this.mapDetail(final);
} }
async reject(id: string, dto: RecusarPedido): 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');
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})`,
);
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> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
@@ -561,8 +853,15 @@ export class OrdersService {
throw new BadRequestException('Apenas orçamentos podem ser cancelados pelo representante'); throw new BadRequestException('Apenas orçamentos podem ser cancelados pelo representante');
const now = new Date(); 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({ await prisma.historicoPedido.create({
data: { data: {
idPedido: id, idPedido: id,
@@ -570,7 +869,7 @@ export class OrdersService {
situaNova: SITUA_CANCELADO, situaNova: SITUA_CANCELADO,
changedBy: codVendedor, changedBy: codVendedor,
changedAt: now, changedAt: now,
nota: 'Cancelado pelo representante', nota: `Cancelado pelo representante - ${motivoCompleto}`,
}, },
}); });
@@ -622,6 +921,8 @@ export class OrdersService {
codVendedor: number; codVendedor: number;
situa: number; situa: number;
dtPedido: Date; dtPedido: Date;
idPauta: number | null;
codFormapag: number | null;
total: Prisma.Decimal; total: Prisma.Decimal;
descontoPerc: Prisma.Decimal; descontoPerc: Prisma.Decimal;
descontoValor: Prisma.Decimal; descontoValor: Prisma.Decimal;
@@ -660,6 +961,41 @@ export class OrdersService {
}[]; }[];
}): Promise<PedidoDetail> { }): Promise<PedidoDetail> {
const names = await this.lookupNames(o.idCliente, o.codVendedor); 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 { return {
id: o.id, id: o.id,
numPedSar: o.numPedSar, numPedSar: o.numPedSar,
@@ -670,6 +1006,10 @@ export class OrdersService {
nomeVendedor: names.nomeVendedor, nomeVendedor: names.nomeVendedor,
situa: o.situa, situa: o.situa,
dtPedido: o.dtPedido.toISOString(), dtPedido: o.dtPedido.toISOString(),
idPauta: o.idPauta,
codFormapag: o.codFormapag,
pesoTotal: pesoTotal.toFixed(3),
m3Total: m3Total.toFixed(4),
total: decimalToString(o.total), total: decimalToString(o.total),
descontoPerc: decimalToString(o.descontoPerc), descontoPerc: decimalToString(o.descontoPerc),
obs: o.obs, obs: o.obs,
@@ -698,6 +1038,14 @@ export class OrdersService {
precoUnitario: decimalToString(it.precoUnitario), precoUnitario: decimalToString(it.precoUnitario),
descontoPerc: decimalToString(it.descontoPerc), descontoPerc: decimalToString(it.descontoPerc),
total: decimalToString(it.total), 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) => ({ historico: o.historico.map((h) => ({
id: h.id, id: h.id,
@@ -752,9 +1100,16 @@ export class OrdersService {
preco_unitario: string; preco_unitario: string;
desconto_perc: string; desconto_perc: string;
total: string; total: string;
peso_liquido: string | null;
vol_m3: string | null;
} }
const vendedorFilter = role === 'rep' ? `AND e.cod_vendedor = ${codVendedor}` : ''; const vendedorFilter =
role === 'rep'
? `AND e.cod_vendedor = ${codVendedor}`
: role === 'supervisor'
? `AND e.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})`
: '';
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa; const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
const [headerRows, itemRows] = await Promise.all([ const [headerRows, itemRows] = await Promise.all([
@@ -779,7 +1134,8 @@ export class OrdersService {
SELECT ei.ordem, ei.id_produto, SELECT ei.ordem, ei.id_produto,
TRIM(p.codigo) AS codigo, TRIM(p.descricao) AS descricao, TRIM(p.codigo) AS codigo, TRIM(p.descricao) AS descricao,
ei.qtd::text, ei.preco_unitario::text, 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 FROM vw_peditens_erp ei
LEFT JOIN vw_produtos p LEFT JOIN vw_produtos p
ON p.id_erp = ei.id_produto ON p.id_erp = ei.id_produto
@@ -792,6 +1148,15 @@ export class OrdersService {
if (!headerRows[0]) throw new NotFoundException(`Pedido ERP ${idPedido} não encontrado`); if (!headerRows[0]) throw new NotFoundException(`Pedido ERP ${idPedido} não encontrado`);
const h = headerRows[0]; 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 { return {
id: `erp-${h.id_pedido}`, id: `erp-${h.id_pedido}`,
numPedSar: (h.num_ped_sar ?? '').trim(), numPedSar: (h.num_ped_sar ?? '').trim(),
@@ -822,6 +1187,8 @@ export class OrdersService {
aprovadoEm: null, aprovadoEm: null,
motivoRecusa: null, motivoRecusa: null,
idempotencyKey: null, idempotencyKey: null,
pesoTotal: pesoTotalErp.toFixed(3),
m3Total: m3TotalErp.toFixed(4),
itens: itemRows.map((it) => ({ itens: itemRows.map((it) => ({
id: `${idPedido}-${it.ordem}`, id: `${idPedido}-${it.ordem}`,
idProduto: Number(it.id_produto), idProduto: Number(it.id_produto),

View File

@@ -11,43 +11,49 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { import {
UpsertDescontoBodySchema,
CreatePromocaoBodySchema, CreatePromocaoBodySchema,
UpdatePromocaoBodySchema, UpdatePromocaoBodySchema,
type UpsertDescontoBody, CreateRegraDescontoBodySchema,
UpdateRegraDescontoBodySchema,
type CreatePromocaoBody, type CreatePromocaoBody,
type UpdatePromocaoBody, type UpdatePromocaoBody,
type AlcadaDescontosResponse, type CreateRegraDescontoBody,
type UpdateRegraDescontoBody,
type PromocoesResponse, type PromocoesResponse,
type PromocoesVigentesResponse,
type AlcadaRepResponse,
type RegrasDescontoResponse,
type RegrasVigentesResponse,
type GruposProdutoResponse,
} from '@sar/api-interface'; } from '@sar/api-interface';
import { PoliticasService } from './politicas.service'; import { PoliticasService } from './politicas.service';
class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {}
class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {} class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {}
class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {} class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {}
class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {}
class UpdateRegraDescontoDto extends createZodDto(UpdateRegraDescontoBodySchema) {}
@Controller({ path: 'politicas' }) @Controller({ path: 'politicas' })
export class PoliticasController { export class PoliticasController {
constructor(private readonly politicas: PoliticasService) {} constructor(private readonly politicas: PoliticasService) {}
@Get('descontos')
listDescontos(): Promise<AlcadaDescontosResponse> {
return this.politicas.listDescontos();
}
@Post('descontos')
@HttpCode(200)
upsertDesconto(@Body() body: UpsertDescontoDto): Promise<void> {
return this.politicas.upsertDesconto(body as unknown as UpsertDescontoBody);
}
@Get('promocoes') @Get('promocoes')
listPromocoes(): Promise<PromocoesResponse> { listPromocoes(): Promise<PromocoesResponse> {
return this.politicas.listPromocoes(); return this.politicas.listPromocoes();
} }
@Get('promocoes/vigentes')
listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
return this.politicas.listPromocoesVigentes();
}
@Get('alcada')
minhaAlcada(): Promise<AlcadaRepResponse> {
return this.politicas.minhaAlcada();
}
@Post('promocoes') @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); return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
} }
@@ -64,4 +70,38 @@ export class PoliticasController {
deletePromocao(@Param('id', ParseIntPipe) id: number): Promise<void> { deletePromocao(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.politicas.deletePromocao(id); return this.politicas.deletePromocao(id);
} }
@Get('regras')
listRegras(): Promise<RegrasDescontoResponse> {
return this.politicas.listRegras();
}
@Get('regras/vigentes')
listRegrasVigentes(): Promise<RegrasVigentesResponse> {
return this.politicas.listRegrasVigentes();
}
@Post('regras')
createRegra(@Body() body: CreateRegraDescontoDto): Promise<{ ids: number[] }> {
return this.politicas.createRegra(body as unknown as CreateRegraDescontoBody);
}
@Patch('regras/:id')
updateRegra(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdateRegraDescontoDto,
): Promise<void> {
return this.politicas.updateRegra(id, body as unknown as UpdateRegraDescontoBody);
}
@Delete('regras/:id')
@HttpCode(204)
deleteRegra(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.politicas.deleteRegra(id);
}
@Get('grupos-produto')
listGruposProduto(): Promise<GruposProdutoResponse> {
return this.politicas.listGruposProduto();
}
} }

View File

@@ -1,11 +1,16 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import type { import type {
AlcadaDescontosResponse,
UpsertDescontoBody,
PromocoesResponse, PromocoesResponse,
CreatePromocaoBody, CreatePromocaoBody,
UpdatePromocaoBody, UpdatePromocaoBody,
RegrasDescontoResponse,
CreateRegraDescontoBody,
UpdateRegraDescontoBody,
RegrasVigentesResponse,
PromocoesVigentesResponse,
AlcadaRepResponse,
GruposProdutoResponse,
} from '@sar/api-interface'; } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
@@ -20,61 +25,6 @@ export class PoliticasService {
} }
} }
async listDescontos(): Promise<AlcadaDescontosResponse> {
this.assertManager();
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const rows = await prisma.alcadaDesconto.findMany({
where: { idEmpresa },
orderBy: [{ codVendedor: 'asc' }, { codGrupo: 'asc' }],
});
// Busca nomes dos representantes
const codigos = [...new Set(rows.map((r) => r.codVendedor))];
const repRows =
codigos.length > 0
? await prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${codigos.join(',')})`,
)
: [];
const repNameMap = new Map(repRows.map((r) => [Number(r.codigo), r.nome]));
return {
descontos: rows.map((r) => ({
codVendedor: r.codVendedor,
codGrupo: r.codGrupo,
limitePerc: Number(r.limitePerc),
nomeVendedor: repNameMap.get(r.codVendedor) ?? null,
})),
};
}
async upsertDesconto(body: UpsertDescontoBody): Promise<void> {
this.assertManager();
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
await prisma.alcadaDesconto.upsert({
where: {
codVendedor_idEmpresa_codGrupo: {
codVendedor: body.codVendedor,
idEmpresa,
codGrupo: body.codGrupo ?? 0,
},
},
create: {
codVendedor: body.codVendedor,
idEmpresa,
codGrupo: body.codGrupo ?? 0,
limitePerc: body.limitePerc,
},
update: { limitePerc: body.limitePerc },
});
}
async listPromocoes(): Promise<PromocoesResponse> { async listPromocoes(): Promise<PromocoesResponse> {
this.assertManager(); this.assertManager();
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
@@ -102,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(); this.assertManager();
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId') ?? 'system'; const userId = this.cls.get('userId') ?? 'system';
const p = await prisma.promocao.create({ 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: { data: {
idEmpresa, idEmpresa,
descricao: body.descricao, descricao: body.descricao,
codProduto: body.codProduto ?? null, codProduto: alvo.codProduto,
grpProd: body.grpProd ?? null, grpProd: alvo.grpProd,
descPct: body.descPct, descPct: body.descPct,
dataInicio: new Date(body.dataInicio), dataInicio: new Date(body.dataInicio),
dataFim: new Date(body.dataFim), dataFim: new Date(body.dataFim),
createdBy: userId, createdBy: userId,
}, },
}); });
return { id: p.id }; created.push(p.id);
}
return created;
});
return { ids };
} }
async updatePromocao(id: number, body: UpdatePromocaoBody): Promise<void> { async updatePromocao(id: number, body: UpdatePromocaoBody): Promise<void> {
@@ -158,4 +123,326 @@ export class PoliticasService {
await prisma.promocao.delete({ where: { id } }); await prisma.promocao.delete({ where: { id } });
} }
// ─── Regras de Desconto ─────────────────────────────────────────────────────
// Grupos/subgrupos distintos de vw_produtos (empresa matriz), para combos e
// resolução de nomes na listagem — UI sempre mostra o nome, nunca só o código.
private async loadGruposProduto(): Promise<
{
cod_grupo: number | null;
grupo: string | null;
cod_subgrupo: number | null;
subgrupo: string | null;
}[]
> {
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;
return prisma.$queryRawUnsafe(
`SELECT DISTINCT cod_grupo, TRIM(grupo) AS grupo, cod_subgrupo, TRIM(subgrupo) AS subgrupo
FROM vw_produtos
WHERE id_empresa = ${idMatriz}
AND (cod_grupo IS NOT NULL OR cod_subgrupo IS NOT NULL)
ORDER BY grupo, subgrupo`,
);
}
async listGruposProduto(): Promise<GruposProdutoResponse> {
this.assertManager();
const rows = await this.loadGruposProduto();
return {
grupos: rows.map((r) => ({
codGrupo: r.cod_grupo != null ? Number(r.cod_grupo) : null,
grupo: r.grupo,
codSubgrupo: r.cod_subgrupo != null ? Number(r.cod_subgrupo) : null,
subgrupo: r.subgrupo,
})),
};
}
// 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');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const rows = await prisma.regraDesconto.findMany({
where: { idEmpresa },
orderBy: [{ dataFim: 'desc' }, { id: 'desc' }],
});
const codigos = [
...new Set(rows.map((r) => r.codVendedor).filter((c): c is number => c != null)),
];
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]));
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,
codVendedor: r.codVendedor,
nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null,
descPct: Number(r.descPct),
dataInicio: r.dataInicio.toISOString().slice(0, 10),
dataFim: r.dataFim.toISOString().slice(0, 10),
ativa: r.ativa,
createdAt: r.createdAt.toISOString(),
createdBy: r.createdBy,
})),
};
}
// 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 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 { ids };
}
async updateRegra(id: number, body: UpdateRegraDescontoBody): 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');
const exists = await prisma.regraDesconto.findFirst({ where: { id, idEmpresa } });
if (!exists) throw new NotFoundException('Regra de desconto não encontrada');
await prisma.regraDesconto.update({
where: { id },
data: {
...(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) }),
...(body.dataFim !== undefined && { dataFim: new Date(body.dataFim) }),
...(body.ativa !== undefined && { ativa: body.ativa }),
},
});
}
async deleteRegra(id: number): 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');
const exists = await prisma.regraDesconto.findFirst({ where: { id, idEmpresa } });
if (!exists) throw new NotFoundException('Regra de desconto não encontrada');
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> {
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 hoje = new Date();
const rows = await prisma.promocao.findMany({
where: {
idEmpresa: { in: [idEmpresa, idMatriz] },
ativa: true,
dataInicio: { lte: hoje },
dataFim: { gte: hoje },
},
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),
})),
};
}
// Regras vigentes hoje para o usuário logado. Rep recebe só as regras que o
// alcançam (cod_vendedor nulo = todos, ou o próprio código); gerente vê todas.
async listRegrasVigentes(): Promise<RegrasVigentesResponse> {
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 role = this.cls.get('role') ?? 'rep';
const codVendedor = parseInt(this.cls.get('userId') ?? '0', 10);
const hoje = new Date();
const rows = await prisma.regraDesconto.findMany({
where: {
idEmpresa: { in: [idEmpresa, idMatriz] },
ativa: true,
dataInicio: { lte: hoje },
dataFim: { gte: hoje },
...(role === 'rep' ? { OR: [{ codVendedor: null }, { codVendedor }] } : {}),
},
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

@@ -9,6 +9,7 @@ import type {
AbcProduto, AbcProduto,
} from '@sar/api-interface'; } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util';
function d(v: unknown): string { function d(v: unknown): string {
return v != null ? String(v) : '0'; return v != null ? String(v) : '0';
@@ -77,6 +78,12 @@ export class ReportsService {
const userId = this.cls.get('userId'); const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0; const codVendedor = userId ? parseInt(userId, 10) : 0;
// Rep vê a própria carteira; supervisor vê as carteiras da equipe
const role = this.cls.get('role');
const team = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : null;
const vendCond = (col: string) =>
team ? `${col} IN (${team.join(',')})` : `${col} = ${codVendedor}`;
interface CarteiraRow { interface CarteiraRow {
id_cliente: unknown; id_cliente: unknown;
nome: string | null; nome: string | null;
@@ -86,6 +93,7 @@ export class ReportsService {
dias_sem_pedido: unknown; dias_sem_pedido: unknown;
total_pedidos: unknown; total_pedidos: unknown;
ticket_medio: unknown; ticket_medio: unknown;
faturamento_total: unknown;
} }
const rows = await prisma.$queryRawUnsafe<CarteiraRow[]>(` const rows = await prisma.$queryRawUnsafe<CarteiraRow[]>(`
@@ -93,15 +101,16 @@ export class ReportsService {
SELECT SELECT
id_cliente, id_cliente,
MAX(dt_pedido) AS ultimo_pedido, MAX(dt_pedido) AS ultimo_pedido,
COUNT(id) AS total_pedidos, COUNT(*) AS total_pedidos,
CASE WHEN COUNT(id) > 0 CASE WHEN COUNT(*) > 0
THEN SUM(total)::numeric / COUNT(id) THEN SUM(total)::numeric / COUNT(*)
ELSE 0 ELSE 0
END AS ticket_medio END AS ticket_medio,
FROM sar.pedidos COALESCE(SUM(total), 0)::numeric AS faturamento_total
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa} WHERE id_empresa = ${idEmpresa}
AND cod_vendedor = ${codVendedor} AND ${vendCond('cod_vendedor')}
AND situa <> 3 AND situa NOT IN (1, 5)
GROUP BY id_cliente GROUP BY id_cliente
) )
SELECT SELECT
@@ -112,15 +121,29 @@ export class ReportsService {
TO_CHAR(u.ultimo_pedido, 'YYYY-MM-DD') AS ultimo_pedido, TO_CHAR(u.ultimo_pedido, 'YYYY-MM-DD') AS ultimo_pedido,
(CURRENT_DATE - u.ultimo_pedido::date) AS dias_sem_pedido, (CURRENT_DATE - u.ultimo_pedido::date) AS dias_sem_pedido,
COALESCE(u.total_pedidos, 0)::int AS total_pedidos, COALESCE(u.total_pedidos, 0)::int AS total_pedidos,
COALESCE(u.ticket_medio, 0)::numeric AS ticket_medio COALESCE(u.ticket_medio, 0)::numeric AS ticket_medio,
FROM sar.vw_clientes c COALESCE(u.faturamento_total, 0)::numeric AS faturamento_total
FROM vw_clientes c
LEFT JOIN ultimos u ON u.id_cliente = c.id_cliente LEFT JOIN ultimos u ON u.id_cliente = c.id_cliente
WHERE c.id_empresa = ${idEmpresa} WHERE ${vendCond('c.cod_vendedor')}
AND c.cod_vendedor = ${codVendedor} AND c.ativo = 1
ORDER BY u.ultimo_pedido ASC NULLS FIRST, c.nome ASC ORDER BY u.faturamento_total DESC NULLS LAST, c.nome ASC
`); `);
const data = rows.map((r) => ({ // Deduplica por id_cliente — vw_clientes pode retornar o mesmo cliente em mais de uma empresa.
const seen = new Set<number>();
const unique = rows.filter((r) => {
const id = n(r.id_cliente);
if (seen.has(id)) return false;
seen.add(id);
return true;
});
const faturamentoRepTotal = unique.reduce((acc, r) => acc + n(r.faturamento_total), 0);
const data = unique.map((r) => {
const fat = n(r.faturamento_total);
return {
idCliente: n(r.id_cliente), idCliente: n(r.id_cliente),
nome: r.nome ?? null, nome: r.nome ?? null,
razao: r.razao ?? null, razao: r.razao ?? null,
@@ -129,13 +152,24 @@ export class ReportsService {
diasSemPedido: r.dias_sem_pedido != null ? n(r.dias_sem_pedido) : null, diasSemPedido: r.dias_sem_pedido != null ? n(r.dias_sem_pedido) : null,
totalPedidos: n(r.total_pedidos), totalPedidos: n(r.total_pedidos),
ticketMedio: d(r.ticket_medio), ticketMedio: d(r.ticket_medio),
})); faturamentoTotal: fat.toFixed(2),
participacaoPct:
faturamentoRepTotal > 0 ? Math.round((fat / faturamentoRepTotal) * 1000) / 10 : 0,
};
});
const inativos30 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 30).length; const inativos30 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 30).length;
const inativos60 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60).length; const inativos60 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60).length;
const semPedido = data.filter((c) => c.totalPedidos === 0).length; const semPedido = data.filter((c) => c.totalPedidos === 0).length;
return { data, total: data.length, inativos30, inativos60, semPedido }; return {
data,
total: data.length,
inativos30,
inativos60,
semPedido,
faturamentoRepTotal: faturamentoRepTotal.toFixed(2),
};
} }
async abcProdutos(query: ReportAbcQuery): Promise<ReportAbcResponse> { async abcProdutos(query: ReportAbcQuery): Promise<ReportAbcResponse> {
@@ -149,6 +183,13 @@ export class ReportsService {
const mesFilter = mes != null ? `AND EXTRACT(MONTH FROM p.dt_pedido) = ${mes}` : ''; const mesFilter = mes != null ? `AND EXTRACT(MONTH FROM p.dt_pedido) = ${mes}` : '';
const anoFilter = ano != null ? `AND EXTRACT(YEAR FROM p.dt_pedido) = ${ano}` : ''; const anoFilter = ano != null ? `AND EXTRACT(YEAR FROM p.dt_pedido) = ${ano}` : '';
// Rep vê os próprios pedidos; supervisor vê os da equipe
const role = this.cls.get('role');
const team = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : null;
const vendCond = team
? `p.cod_vendedor IN (${team.join(',')})`
: `p.cod_vendedor = ${codVendedor}`;
interface AbcRow { interface AbcRow {
cod_produto: string | null; cod_produto: string | null;
desc_produto: string | null; desc_produto: string | null;
@@ -169,7 +210,7 @@ export class ReportsService {
FROM sar.pedido_itens pi FROM sar.pedido_itens pi
JOIN sar.pedidos p ON p.id = pi.id_pedido JOIN sar.pedidos p ON p.id = pi.id_pedido
WHERE p.id_empresa = ${idEmpresa} WHERE p.id_empresa = ${idEmpresa}
AND p.cod_vendedor = ${codVendedor} AND ${vendCond}
AND p.situa NOT IN (0, 3) AND p.situa NOT IN (0, 3)
${mesFilter} ${mesFilter}
${anoFilter} ${anoFilter}

View File

@@ -0,0 +1,91 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Param,
ParseIntPipe,
Patch,
Post,
} from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { createZodDto } from 'nestjs-zod';
import { CreateChamadoSchema, AddMensagemSchema, ResolverChamadoSchema } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { SacService } from './sac.service';
class CreateChamadoDto extends createZodDto(CreateChamadoSchema) {}
class AddMensagemDto extends createZodDto(AddMensagemSchema) {}
class ResolverDto extends createZodDto(ResolverChamadoSchema) {}
@Controller('chamados')
export class SacRepController {
constructor(private readonly sac: SacService) {}
@Get()
listar() {
return this.sac.listarRep();
}
@Get(':id')
detalhe(@Param('id', ParseIntPipe) id: number) {
return this.sac.detalhe(id, 'rep');
}
@Post()
criar(@Body() dto: CreateChamadoDto) {
return this.sac.criar(CreateChamadoSchema.parse(dto));
}
@Post(':id/mensagens')
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
return this.sac.addMensagemRep(id, AddMensagemSchema.parse(dto));
}
@Patch(':id/cancelar')
cancelar(@Param('id', ParseIntPipe) id: number) {
return this.sac.cancelarRep(id);
}
}
@Controller('ger/chamados')
export class SacGerController {
constructor(
private readonly sac: SacService,
private readonly cls: ClsService<WorkspaceClsStore>,
) {}
// PGD-AUTHZ: visão da empresa toda (detalhe sem checagem de dono) — só gestores.
private assertGestor(): void {
const role = this.cls.get('role') ?? 'rep';
if (role === 'rep') {
throw new ForbiddenException(
'Apenas supervisores e gerentes podem acessar os chamados da empresa',
);
}
}
@Get()
listar() {
this.assertGestor();
return this.sac.listarEmpresa();
}
@Get(':id')
detalhe(@Param('id', ParseIntPipe) id: number) {
this.assertGestor();
return this.sac.detalhe(id, 'empresa');
}
@Post(':id/mensagens')
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
this.assertGestor();
return this.sac.addMensagemEmpresa(id, AddMensagemSchema.parse(dto));
}
@Patch(':id/resolver')
resolver(@Param('id', ParseIntPipe) id: number, @Body() dto: ResolverDto) {
this.assertGestor();
return this.sac.resolver(id, ResolverChamadoSchema.parse(dto));
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SacService } from './sac.service';
import { SacRepController, SacGerController } from './sac.controller';
@Module({
controllers: [SacRepController, SacGerController],
providers: [SacService],
})
export class SacModule {}

View File

@@ -0,0 +1,195 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { CreateChamadoDto, AddMensagemDto, ResolverChamadoDto } from '@sar/api-interface';
type PrismaCtx = {
chamado: {
findMany: (args: object) => Promise<unknown[]>;
findFirst: (args: object) => Promise<unknown | null>;
findUnique: (args: object) => Promise<unknown | null>;
create: (args: object) => Promise<unknown>;
update: (args: object) => Promise<unknown>;
};
chamadoMensagem: { create: (args: object) => Promise<unknown> };
$queryRawUnsafe: <T>(sql: string, ...args: unknown[]) => Promise<T[]>;
};
type ClienteRow = { id_cliente: number; nome: string | null; razao: string | null };
@Injectable()
export class SacService {
constructor(private readonly cls: ClsService) {}
private ctx(): { prisma: PrismaCtx; idEmpresa: number; codVendedor: number } {
const prisma = this.cls.get('prisma') as PrismaCtx;
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa') as number;
const userId = this.cls.get('userId') as string | undefined;
const codVendedor = userId ? parseInt(userId, 10) : 0;
return { prisma, idEmpresa, codVendedor };
}
private async enrichNomes(
prisma: PrismaCtx,
idEmpresa: number,
rows: { idCliente: number }[],
): Promise<Map<number, string>> {
const ids = [...new Set(rows.map((r) => r.idCliente))];
if (!ids.length) return new Map();
const clientes = await prisma.$queryRawUnsafe<ClienteRow>(
`SELECT id_cliente, TRIM(nome) AS nome, TRIM(razao) AS razao FROM vw_clientes WHERE id_cliente IN (${ids.join(',')}) AND id_empresa = ${idEmpresa}`,
);
const map = new Map<number, string>();
for (const c of clientes) {
map.set(c.id_cliente, c.razao ?? c.nome ?? `Cód. ${c.id_cliente}`);
}
return map;
}
async listarRep() {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const all = (await prisma.chamado.findMany({
where: { idEmpresa, codVendedor },
orderBy: { updatedAt: 'desc' },
})) as { idCliente: number; status: string; [k: string]: unknown }[];
const nomes = await this.enrichNomes(prisma, idEmpresa, all);
const enrich = (row: (typeof all)[0]) => ({
...row,
nomeCliente:
(row as { nomeCliente?: string | null }).nomeCliente ?? nomes.get(row.idCliente) ?? null,
});
return {
abertos: all.filter((c) => !['resolvido', 'cancelado'].includes(c.status)).map(enrich),
fechados: all.filter((c) => ['resolvido', 'cancelado'].includes(c.status)).map(enrich),
};
}
// Códigos da equipe do supervisor (cod_supervisor em vw_representantes),
// incluindo o próprio. Retorna null para gerente/admin (sem filtro).
private async teamCodes(): Promise<number[] | null> {
const role = (this.cls.get('role') as string | undefined) ?? 'rep';
if (role !== 'supervisor') return null;
const { prisma, codVendedor } = this.ctx();
const rows = await prisma.$queryRawUnsafe<{ codigo: number }>(
`SELECT DISTINCT codigo FROM vw_representantes WHERE cod_supervisor = ${codVendedor}`,
);
return [...new Set([codVendedor, ...rows.map((r) => Number(r.codigo))])];
}
async listarEmpresa() {
const { prisma, idEmpresa } = this.ctx();
// Supervisor vê só os chamados da sua equipe; gerente/admin veem a empresa toda
const team = await this.teamCodes();
const all = (await prisma.chamado.findMany({
where: { idEmpresa, ...(team ? { codVendedor: { in: team } } : {}) },
orderBy: { updatedAt: 'desc' },
})) as { idCliente: number; status: string; [k: string]: unknown }[];
const nomes = await this.enrichNomes(prisma, idEmpresa, all);
const enrich = (row: (typeof all)[0]) => ({
...row,
nomeCliente:
(row as { nomeCliente?: string | null }).nomeCliente ?? nomes.get(row.idCliente) ?? null,
});
return {
abertos: all.filter((c) => !['resolvido', 'cancelado'].includes(c.status)).map(enrich),
fechados: all.filter((c) => ['resolvido', 'cancelado'].includes(c.status)).map(enrich),
};
}
async detalhe(id: number, role: 'rep' | 'empresa') {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const chamado = (await prisma.chamado.findFirst({
where: { id, idEmpresa },
include: { mensagens: { orderBy: { createdAt: 'asc' } } },
})) as { idCliente: number; codVendedor: number; [k: string]: unknown } | null;
if (!chamado) throw new NotFoundException('Chamado não encontrado');
if (role === 'rep' && chamado.codVendedor !== codVendedor) throw new ForbiddenException();
if (role === 'empresa') {
const team = await this.teamCodes();
if (team && !team.includes(chamado.codVendedor)) throw new ForbiddenException();
}
const nomes = await this.enrichNomes(prisma, idEmpresa, [chamado]);
return { ...chamado, nomeCliente: nomes.get(chamado.idCliente) ?? null };
}
async criar(dto: CreateChamadoDto) {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const chamado = (await prisma.chamado.create({
data: {
idEmpresa,
codVendedor,
idPedido: dto.idPedido ?? null,
numPedSar: dto.numPedSar ?? null,
numPedErp: dto.numPedErp ?? null,
nomeCliente: dto.nomeCliente ?? null,
idCliente: dto.idCliente,
tipo: dto.tipo,
assunto: dto.assunto,
descricao: dto.descricao,
itensAfetados: dto.itensAfetados ?? [],
},
include: { mensagens: true },
})) as { idCliente: number; nomeCliente: string | null; [k: string]: unknown };
const nomes = await this.enrichNomes(prisma, idEmpresa, [chamado]);
return { ...chamado, nomeCliente: chamado.nomeCliente ?? nomes.get(chamado.idCliente) ?? null };
}
async addMensagemRep(id: number, dto: AddMensagemDto) {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const c = (await prisma.chamado.findFirst({ where: { id, idEmpresa } })) as {
codVendedor: number;
} | null;
if (!c) throw new NotFoundException();
if (c.codVendedor !== codVendedor) throw new ForbiddenException();
await prisma.chamadoMensagem.create({
data: { idChamado: id, autor: 'rep', texto: dto.texto },
});
await prisma.chamado.update({ where: { id }, data: { status: 'em_analise' } });
return this.detalhe(id, 'rep');
}
async addMensagemEmpresa(id: number, dto: AddMensagemDto) {
const { prisma, idEmpresa } = this.ctx();
const c = await prisma.chamado.findFirst({ where: { id, idEmpresa } });
if (!c) throw new NotFoundException();
await prisma.chamadoMensagem.create({
data: { idChamado: id, autor: 'empresa', texto: dto.texto },
});
await prisma.chamado.update({ where: { id }, data: { status: 'aguardando_rep' } });
return this.detalhe(id, 'empresa');
}
async resolver(id: number, dto: ResolverChamadoDto) {
const { prisma, idEmpresa } = this.ctx();
const c = await prisma.chamado.findFirst({ where: { id, idEmpresa } });
if (!c) throw new NotFoundException();
if (dto.mensagemFinal) {
await prisma.chamadoMensagem.create({
data: { idChamado: id, autor: 'empresa', texto: dto.mensagemFinal },
});
}
await prisma.chamado.update({
where: { id },
data: {
status: 'resolvido',
resolucao: dto.resolucao,
notaResolucao: dto.notaResolucao ?? null,
},
});
return this.detalhe(id, 'empresa');
}
async cancelarRep(id: number) {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const c = (await prisma.chamado.findFirst({ where: { id, idEmpresa } })) as {
codVendedor: number;
status: string;
} | null;
if (!c) throw new NotFoundException();
if (c.codVendedor !== codVendedor) throw new ForbiddenException();
if (['resolvido', 'cancelado'].includes(c.status))
throw new ForbiddenException('Chamado já encerrado');
await prisma.chamado.update({ where: { id }, data: { status: 'cancelado' } });
return this.detalhe(id, 'rep');
}
}

View File

@@ -0,0 +1,14 @@
import type { PrismaClient } from '@prisma/client';
// Códigos de vendedor da equipe de um supervisor (vw_representantes.cod_supervisor
// aponta para o código do supervisor), incluindo o próprio supervisor. Usado para
// escopar as telas do supervisor à sua equipe — gerente/admin veem a empresa toda.
// codSupervisor vem do JWT (parseInt) — interpolação segura.
export async function getTeamCodes(prisma: PrismaClient, codSupervisor: number): Promise<number[]> {
const rows = await prisma.$queryRawUnsafe<{ codigo: number }[]>(
`SELECT DISTINCT codigo FROM vw_representantes WHERE cod_supervisor = ${codSupervisor}`,
);
const codes = new Set(rows.map((r) => Number(r.codigo)));
codes.add(codSupervisor);
return [...codes];
}

View File

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

View File

@@ -18,7 +18,7 @@ const CACHEABLE_API = [
// ── Fetch ────────────────────────────────────────────────────────────────────── // ── Fetch ──────────────────────────────────────────────────────────────────────
self.addEventListener('fetch', (event) => { globalThis.addEventListener('fetch', (event) => {
const { request } = event; const { request } = event;
if (request.method !== 'GET') return; if (request.method !== 'GET') return;
@@ -102,10 +102,10 @@ function offlineResponse() {
// ── Push (C6) ───────────────────────────────────────────────────────────────── // ── Push (C6) ─────────────────────────────────────────────────────────────────
self.addEventListener('push', (event) => { globalThis.addEventListener('push', (event) => {
const data = event.data?.json() ?? {}; const data = event.data?.json() ?? {};
event.waitUntil( event.waitUntil(
self.registration.showNotification(data.title ?? 'SAR', { globalThis.registration.showNotification(data.title ?? 'SAR', {
body: data.body ?? '', body: data.body ?? '',
icon: '/sar-icon.png', icon: '/sar-icon.png',
badge: '/sar-icon.png', badge: '/sar-icon.png',
@@ -114,7 +114,7 @@ self.addEventListener('push', (event) => {
); );
}); });
self.addEventListener('notificationclick', (event) => { globalThis.addEventListener('notificationclick', (event) => {
event.notification.close(); event.notification.close();
const url = event.notification.data?.url; const url = event.notification.data?.url;
if (url) { if (url) {

View File

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

View File

@@ -0,0 +1,346 @@
import { useState } from 'react';
import {
Badge,
Button,
Form,
Input,
Modal,
Select,
Space,
Spin,
Table,
Tag,
Typography,
Tabs,
} from 'antd';
import { CustomerServiceOutlined } from '@ant-design/icons';
import type { TableColumnsType } from 'antd';
import {
STATUS_CHAMADO_LABEL,
TIPO_CHAMADO_LABEL,
RESOLUCAO_CHAMADO,
RESOLUCAO_CHAMADO_LABEL,
type Chamado,
type ChamadoMensagem,
type ItemAfetado,
type ResolverChamadoDto,
} from '@sar/api-interface';
import {
useChamadosGer,
useChamadoGer,
useAddMensagemEmpresa,
useResolverChamado,
} from '../../lib/queries/sac';
const { Title, Text } = Typography;
const { TextArea } = Input;
const STATUS_COLOR: Record<string, string> = {
aberto: 'blue',
em_analise: 'orange',
aguardando_rep: 'purple',
resolvido: 'green',
cancelado: 'default',
};
function ResolverModal({ id, open, onClose }: { id: number; open: boolean; onClose: () => void }) {
const [form] = Form.useForm<ResolverChamadoDto>();
const resolver = useResolverChamado(id);
function handleOk() {
form.validateFields().then((values) => {
resolver.mutate(values, {
onSuccess: () => {
form.resetFields();
onClose();
},
});
});
}
return (
<Modal
title="Resolver Chamado"
open={open}
onOk={handleOk}
onCancel={() => {
form.resetFields();
onClose();
}}
okText="Confirmar Resolução"
cancelText="Voltar"
confirmLoading={resolver.isPending}
width={560}
centered
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="resolucao" label="Tipo de Resolução" rules={[{ required: true }]}>
<Select placeholder="Selecione...">
{RESOLUCAO_CHAMADO.map((r) => (
<Select.Option key={r} value={r}>
{RESOLUCAO_CHAMADO_LABEL[r]}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="notaResolucao" label="Nota / Detalhes">
<TextArea rows={3} maxLength={1000} showCount />
</Form.Item>
<Form.Item name="mensagemFinal" label="Mensagem final para o representante (opcional)">
<TextArea rows={2} maxLength={500} showCount />
</Form.Item>
</Form>
</Modal>
);
}
function ChamadoDetalheModal({
id,
open,
onClose,
}: {
id: number;
open: boolean;
onClose: () => void;
}) {
const [texto, setTexto] = useState('');
const [resolverOpen, setResolverOpen] = useState(false);
const { data: chamado, isLoading } = useChamadoGer(id);
const addMsg = useAddMensagemEmpresa(id);
function enviar() {
if (!texto.trim()) return;
addMsg.mutate({ texto: texto.trim() }, { onSuccess: () => setTexto('') });
}
const fechado = chamado && ['resolvido', 'cancelado'].includes(chamado.status);
return (
<>
<Modal
title={chamado ? `Chamado #${chamado.id}${chamado.assunto}` : 'Chamado'}
open={open}
onCancel={onClose}
footer={null}
width={720}
centered
>
{isLoading || !chamado ? (
<Spin />
) : (
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
<Space wrap>
<Tag color={STATUS_COLOR[chamado.status]}>{STATUS_CHAMADO_LABEL[chamado.status]}</Tag>
<Tag>{TIPO_CHAMADO_LABEL[chamado.tipo] ?? chamado.tipo}</Tag>
<Text type="secondary">Rep: {chamado.codVendedor}</Text>
<Text type="secondary">Cliente: {chamado.nomeCliente ?? chamado.idCliente}</Text>
</Space>
<Text type="secondary">{chamado.descricao}</Text>
{chamado.notaResolucao && (
<div
style={{
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 6,
padding: '8px 12px',
}}
>
<Text strong>Resolução: </Text>
<Text>{chamado.notaResolucao}</Text>
</div>
)}
{chamado.itensAfetados && chamado.itensAfetados.length > 0 && (
<div>
<Text strong>Itens afetados: </Text>
{(chamado.itensAfetados as ItemAfetado[]).map((it, i) => (
<Tag key={`${it.codProduto}-${i}`}>
{it.codProduto} {it.nomeProduto} (Qtd: {it.qtd})
</Tag>
))}
</div>
)}
<div
style={{
border: '1px solid #f0f0f0',
borderRadius: 8,
padding: 12,
maxHeight: 320,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{(chamado.mensagens ?? []).length === 0 ? (
<Text
type="secondary"
style={{ textAlign: 'center', display: 'block', padding: '16px 0' }}
>
Sem mensagens ainda.
</Text>
) : (
((chamado.mensagens as ChamadoMensagem[]) ?? []).map((m) => (
<div
key={m.id}
style={{
display: 'flex',
justifyContent: m.autor === 'empresa' ? 'flex-end' : 'flex-start',
}}
>
<div
style={{
maxWidth: '72%',
background: m.autor === 'empresa' ? '#1677ff' : '#f5f5f5',
color: m.autor === 'empresa' ? '#fff' : '#000',
borderRadius: 10,
padding: '8px 12px',
fontSize: 13,
}}
>
<div style={{ fontSize: 11, opacity: 0.7, marginBottom: 2 }}>
{m.autor === 'empresa' ? 'Empresa' : 'Representante'}
</div>
<div>{m.texto}</div>
<div style={{ fontSize: 11, opacity: 0.7, marginTop: 4, textAlign: 'right' }}>
{new Date(m.createdAt).toLocaleString('pt-BR')}
</div>
</div>
</div>
))
)}
</div>
{!fechado && (
<Form layout="vertical">
<Form.Item label="Resposta">
<TextArea
rows={2}
value={texto}
onChange={(e) => setTexto(e.target.value)}
maxLength={2000}
showCount
/>
</Form.Item>
<Space>
<Button
type="primary"
onClick={enviar}
loading={addMsg.isPending}
disabled={!texto.trim()}
>
Responder
</Button>
<Button
type="primary"
style={{ backgroundColor: '#52c41a', borderColor: '#52c41a' }}
onClick={() => setResolverOpen(true)}
>
Resolver
</Button>
</Space>
</Form>
)}
</Space>
)}
</Modal>
<ResolverModal id={id} open={resolverOpen} onClose={() => setResolverOpen(false)} />
</>
);
}
const columns: TableColumnsType<Chamado> = [
{ title: '#', dataIndex: 'id', width: 60 },
{
title: 'Status',
dataIndex: 'status',
width: 140,
render: (s: string) => (
<Tag color={STATUS_COLOR[s] ?? 'default'}>{STATUS_CHAMADO_LABEL[s] ?? s}</Tag>
),
},
{
title: 'Tipo',
dataIndex: 'tipo',
width: 160,
render: (t: string) => TIPO_CHAMADO_LABEL[t] ?? t,
},
{ title: 'Assunto', dataIndex: 'assunto', ellipsis: true },
{ title: 'Rep', dataIndex: 'codVendedor', width: 70 },
{ title: 'Cliente', dataIndex: 'nomeCliente', width: 160 },
{
title: 'Atualizado',
dataIndex: 'updatedAt',
width: 110,
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
];
export function GerChamadosPage() {
const { data, isLoading } = useChamadosGer();
const [detalheId, setDetalheId] = useState<number | null>(null);
if (isLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
const abertos = data?.abertos ?? [];
const fechados = data?.fechados ?? [];
return (
<div style={{ padding: 24, maxWidth: 1200, margin: '0 auto' }}>
<Space align="center" style={{ marginBottom: 24 }}>
<CustomerServiceOutlined style={{ fontSize: 24, color: '#1677ff' }} />
<Title level={3} style={{ margin: 0 }}>
SAC Chamados da Empresa
</Title>
{abertos.length > 0 && <Badge count={abertos.length} />}
</Space>
<Tabs
items={[
{
key: 'abertos',
label: `Abertos (${abertos.length})`,
children: (
<Table<Chamado>
rowKey="id"
columns={columns}
dataSource={abertos}
pagination={false}
size="small"
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
locale={{ emptyText: 'Nenhum chamado aberto.' }}
/>
),
},
{
key: 'fechados',
label: `Fechados (${fechados.length})`,
children: (
<Table<Chamado>
rowKey="id"
columns={columns}
dataSource={fechados}
pagination={{ pageSize: 20 }}
size="small"
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
locale={{ emptyText: 'Nenhum chamado fechado.' }}
/>
),
},
]}
/>
{detalheId !== null && (
<ChamadoDetalheModal
id={detalheId}
open={detalheId !== null}
onClose={() => setDetalheId(null)}
/>
)}
</div>
);
}

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,16 +1,20 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
Button, Button,
Card, Card,
Col, Col,
DatePicker, DatePicker,
Flex, Flex,
InputNumber,
Progress, Progress,
Row, Row,
Skeleton, Skeleton,
Space, Space,
Table, Table,
Tag,
Typography, Typography,
message,
} from 'antd'; } from 'antd';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
@@ -23,12 +27,40 @@ import {
faAddressCard, faAddressCard,
faBullseye, faBullseye,
faArrowTrendUp, faArrowTrendUp,
faCalendarDay,
faLayerGroup, faLayerGroup,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
Tooltip as ChartTooltip,
Legend,
} from 'chart.js';
import { Chart } from 'react-chartjs-2';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import type { RankingRep, PositivacaoRep, MetaItem } from '@sar/api-interface'; import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface';
import { useManagerDashboard } from '../../lib/queries/gerente'; 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,
LinearScale,
BarElement,
LineElement,
PointElement,
ChartTooltip,
Legend,
);
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -40,45 +72,6 @@ function today(): string {
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' }); 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> = [ const metasGrupoColumns: TableColumnsType<MetaItem> = [
{ {
title: 'Grupo', title: 'Grupo',
@@ -136,54 +129,142 @@ const metasGrupoColumns: TableColumnsType<MetaItem> = [
}, },
]; ];
const rankingColumns: TableColumnsType<RankingRep> = [ function PositivacaoDiariaCard({
{ dados,
title: 'Representante', metaSalva,
key: 'rep', periodo,
render: (_: unknown, row: RankingRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`, periodoLabel,
isMesAtual,
}: {
dados: PositivacaoDia[];
metaSalva: number;
periodo: Dayjs;
periodoLabel: string;
isMesAtual: boolean;
}) {
// 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();
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();
const porDia = new Map(dados.map((d) => [dayjs(d.dia).date(), d.clientes]));
const labels = Array.from({ length: ultimoDia }, (_, i) => String(i + 1));
const valores = labels.map((d) => porDia.get(Number(d)) ?? 0);
const diasComMeta = meta > 0 ? valores.filter((v) => v >= meta).length : 0;
const chartData = {
labels,
datasets: [
{ {
title: 'Pedidos', type: 'bar' as const,
dataIndex: 'pedidos', label: 'Clientes positivados',
width: 80, data: valores,
align: 'right', backgroundColor: 'rgba(0, 74, 153, 0.8)',
className: 'tabular-nums', borderRadius: 4,
order: 2,
}, },
...(meta > 0
? [
{ {
title: 'Clientes', type: 'line' as const,
dataIndex: 'clientesAtendidos', label: `Meta (${meta}/dia)`,
width: 80, data: labels.map(() => meta),
align: 'right', borderColor: '#f5222d',
className: 'tabular-nums', backgroundColor: 'transparent',
borderWidth: 2,
borderDash: [5, 4],
pointRadius: 0,
order: 1,
}, },
{ ]
title: 'Faturamento', : []),
dataIndex: 'faturamento', ],
width: 150, };
align: 'right',
render: (v: number) => fmt(v), const options = {
className: 'tabular-nums', responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index' as const, intersect: false },
plugins: {
legend: { position: 'top' as const, labels: { boxWidth: 12, font: { size: 11 } } },
tooltip: {
callbacks: {
title: (items: { label?: string }[]) => `Dia ${items[0]?.label ?? ''}`,
label: (ctx: { dataset: { label?: string }; parsed: { y: number | null } }) => {
const val = ctx.parsed.y;
if (val == null) return '';
return ` ${ctx.dataset.label}: ${val.toLocaleString('pt-BR')}`;
}, },
{ },
title: '% Meta', },
dataIndex: 'pctMeta', },
width: 160, scales: {
render: (pct: number) => ( x: { grid: { display: false } },
y: {
beginAtZero: true,
ticks: { precision: 0, font: { size: 10 } },
grid: { color: '#f0f0f0' },
},
},
};
return (
<Card
title={
<Space>
<FontAwesomeIcon icon={faCalendarDay} style={{ color: 'var(--jcs-blue)' }} />
Positivação Diária de Clientes &mdash; {periodoLabel}
</Space>
}
extra={
<Flex align="center" gap={8}> <Flex align="center" gap={8}>
<Progress {meta > 0 && (
percent={Math.min(pct, 100)} <Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
size="small" {diasComMeta} de {ultimoDia} dia{ultimoDia !== 1 ? 's' : ''} na meta
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text> </Text>
)}
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
Meta/dia:
</Text>
<InputNumber
min={0}
size="small"
value={meta > 0 ? meta : undefined}
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> </Flex>
), }
}, >
]; {msgCtx}
<div style={{ height: 280 }}>
<Chart type="bar" data={chartData} options={options} />
</div>
</Card>
);
}
export function GerPainel() { export function GerPainel() {
const [periodo, setPeriodo] = useState<Dayjs>(dayjs()); const [periodo, setPeriodo] = useState<Dayjs>(dayjs());
@@ -223,6 +304,8 @@ export function GerPainel() {
metasPorGrupo, metasPorGrupo,
rankingReps, rankingReps,
positivacaoReps, positivacaoReps,
positivacaoDiaria,
faturamentoPorUf,
syncedAt, syncedAt,
} = data; } = data;
@@ -271,93 +354,60 @@ export function GerPainel() {
</Flex> </Flex>
</Flex> </Flex>
{/* KPIs linha 1 */} {/* Primeira dobra: visão financeira (½) + mapa do Brasil (½) */}
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={6}> <Col xs={24} xl={12}>
<Card> <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 vertical gap={4}>
<Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()} FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()}
</Text> </Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(faturamentoMes)}
</Title>
<FontAwesomeIcon <FontAwesomeIcon
icon={faFileInvoiceDollar} icon={faFileInvoiceDollar}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/> />
</Flex> </Flex>
</Card>
</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"> <Title level={3} style={{ margin: 0 }} className="tabular-nums">
{pedidosMes} {fmt(faturamentoMes)}
</Title> </Title>
<FontAwesomeIcon <Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
icon={faChartBar} {pedidosMes} pedido{pedidosMes !== 1 ? 's' : ''} no{' '}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} {isMesAtual ? 'mês' : 'período'}
/> </Text>
</Flex> </Flex>
</Card> </Card>
</Col> </Col>
<Col xs={24} sm={12} lg={6}> <Col xs={24} sm={12} style={{ display: 'flex' }}>
<Card> <Card style={{ flex: 1 }}>
<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>
</Row>
{/* KPIs linha 2 — Meta */}
{metaTotal > 0 && (
<Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={8}>
<Card>
<Flex vertical gap={4}> <Flex vertical gap={4}>
<Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'} META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
</Text> </Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(metaTotal)}
</Title>
<FontAwesomeIcon <FontAwesomeIcon
icon={faBullseye} icon={faBullseye}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/> />
</Flex> </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> </Card>
</Col> </Col>
<Col xs={24} sm={12} lg={8}> <Col xs={24} sm={12} style={{ display: 'flex' }}>
<Card> <Card style={{ flex: 1 }}>
<Flex vertical gap={8}> <Flex vertical gap={8}>
<Flex justify="space-between" align="center"> <Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
@@ -370,13 +420,13 @@ export function GerPainel() {
</Flex> </Flex>
<Title <Title
level={3} level={3}
style={{ margin: 0, color: metaCorColor }} style={{ margin: 0, color: metaTotal > 0 ? metaCorColor : undefined }}
className="tabular-nums" className="tabular-nums"
> >
{pctMeta}% {metaTotal > 0 ? `${pctMeta}%` : '—'}
</Title> </Title>
<Progress <Progress
percent={Math.min(pctMeta, 100)} percent={metaTotal > 0 ? Math.min(pctMeta, 100) : 0}
strokeColor={metaCorColor} strokeColor={metaCorColor}
showInfo={false} showInfo={false}
size="small" size="small"
@@ -385,20 +435,33 @@ export function GerPainel() {
</Card> </Card>
</Col> </Col>
<Col xs={24} sm={12} lg={8}> <Col xs={24} sm={12} style={{ display: 'flex' }}>
<Card> <Card style={{ flex: 1 }}>
<Flex vertical gap={4}> <Flex vertical gap={4}>
<Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'} {superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
</Text> </Text>
<FontAwesomeIcon
icon={faChartBar}
style={{
color: superouMeta > 0 ? 'var(--green)' : 'var(--jcs-blue)',
opacity: 0.5,
}}
/>
</Flex>
<Title <Title
level={3} level={3}
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }} style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
className="tabular-nums" className="tabular-nums"
> >
{fmt(superouMeta > 0 ? superouMeta : faltaMeta)} {metaTotal > 0 ? fmt(superouMeta > 0 ? superouMeta : faltaMeta) : '—'}
</Title> </Title>
{superouMeta > 0 ? ( {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)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{pctMeta - 100}% acima da meta {pctMeta - 100}% acima da meta
</Text> </Text>
@@ -418,7 +481,51 @@ export function GerPainel() {
</Card> </Card>
</Col> </Col>
</Row> </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} xl={12}>
<MapaBrasilCard dados={faturamentoPorUf} periodoLabel={periodoLabel} />
</Col>
</Row>
{/* Metas por Grupo */} {/* Metas por Grupo */}
{metasPorGrupo.length > 0 && ( {metasPorGrupo.length > 0 && (
@@ -470,7 +577,7 @@ export function GerPainel() {
> >
<Table<RankingRep> <Table<RankingRep>
rowKey="codVendedor" rowKey="codVendedor"
columns={rankingColumns} columns={rankingRepColumns}
dataSource={rankingReps} dataSource={rankingReps}
pagination={false} pagination={false}
size="small" size="small"
@@ -478,6 +585,15 @@ export function GerPainel() {
/> />
</Card> </Card>
{/* Positivação Diária */}
<PositivacaoDiariaCard
dados={positivacaoDiaria}
metaSalva={data.metaPositivacaoDia ?? 0}
periodo={periodo}
periodoLabel={periodoLabel}
isMesAtual={isMesAtual}
/>
{/* Positivação por Representante */} {/* Positivação por Representante */}
<Card <Card
title={ title={
@@ -487,14 +603,25 @@ export function GerPainel() {
</Space> </Space>
} }
extra={ extra={
<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)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação {positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação
</Text> </Text>
</Flex>
} }
> >
<Table<PositivacaoRep> <Table<PositivacaoRep>
rowKey="codVendedor" rowKey="codVendedor"
columns={positivacaoColumns} columns={positivacaoRepColumns}
dataSource={positivacaoReps} dataSource={positivacaoReps}
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }} pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }}
size="small" 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

@@ -8,8 +8,10 @@ import {
InputNumber, InputNumber,
Modal, Modal,
Popconfirm, Popconfirm,
Select,
Skeleton, Skeleton,
Space, Space,
Switch,
Table, Table,
Tabs, Tabs,
Tag, Tag,
@@ -22,145 +24,94 @@ import type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons'; import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { AlcadaDescontoItem, Promocao } from '@sar/api-interface'; import type { Promocao, RegraDesconto, RepStats, GrupoProdutoItem } from '@sar/api-interface';
import { import {
usePoliticasDescontos,
usePromocoes, usePromocoes,
useUpsertDesconto,
useCreatePromocao, useCreatePromocao,
useUpdatePromocao, useUpdatePromocao,
useDeletePromocao, useDeletePromocao,
useEquipe,
useRegrasDesconto,
useGruposProduto,
useCreateRegraDesconto,
useUpdateRegraDesconto,
useDeleteRegraDesconto,
} from '../../lib/queries/gerente'; } 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 opts = (data?.data ?? []).map((p) => ({
const { data, isLoading } = usePoliticasDescontos(); value: p.codigo.trim(),
const upsert = useUpsertDesconto(); label: `${p.codigo.trim()}${p.descricao.trim()}`,
const [editingKey, setEditingKey] = useState<string | null>(null); }));
const [editValue, setEditValue] = useState<number>(0); const selecionados = multiple
const [msg, msgCtx] = message.useMessage(); ? ((value as string[] | undefined) ?? [])
: value
function startEdit(row: AlcadaDescontoItem) { ? [value as string]
setEditingKey(`${row.codVendedor}-${row.codGrupo}`); : [];
setEditValue(row.limitePerc); 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) { return (
await upsert.mutateAsync({ <Select
codVendedor: row.codVendedor, mode={multiple ? 'multiple' : undefined}
codGrupo: row.codGrupo, showSearch
limitePerc: editValue, 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;
}); });
setEditingKey(null); setQ('');
void msg.success('Limite atualizado'); onChange?.(vals as string[] | string);
} }}
placeholder="Busque por código ou descrição"
const columns: TableColumnsType<AlcadaDescontoItem> = [ notFoundContent={q && !isFetching ? 'Nenhum produto encontrado' : null}
{
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>
</>
);
} }
const { Title, Text } = Typography;
// ─── Tab Promocoes ──────────────────────────────────────────────────────────── // ─── Tab Promocoes ────────────────────────────────────────────────────────────
interface PromocaoForm { interface PromocaoForm {
descricao: string; descricao: string;
codProdutos?: string[];
grpProds?: string[];
codProduto?: string; codProduto?: string;
grpProd?: string; grpProd?: string;
descPct: number; descPct: number;
periodo: [dayjs.Dayjs, dayjs.Dayjs]; periodo: [dayjs.Dayjs, dayjs.Dayjs];
ativa?: boolean;
} }
function TabPromocoes() { function TabPromocoes() {
const { data, isLoading } = usePromocoes(); const { data, isLoading } = usePromocoes();
const { data: gruposData } = useGruposProduto();
const createMutation = useCreatePromocao(); const createMutation = useCreatePromocao();
const updateMutation = useUpdatePromocao(); const updateMutation = useUpdatePromocao();
const deleteMutation = useDeletePromocao(); const deleteMutation = useDeletePromocao();
@@ -169,6 +120,18 @@ function TabPromocoes() {
const [editTarget, setEditTarget] = useState<Promocao | null>(null); const [editTarget, setEditTarget] = useState<Promocao | null>(null);
const [msg, msgCtx] = message.useMessage(); 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() { function openCreate() {
setEditTarget(null); setEditTarget(null);
form.resetFields(); form.resetFields();
@@ -183,32 +146,62 @@ function TabPromocoes() {
grpProd: p.grpProd ?? undefined, grpProd: p.grpProd ?? undefined,
descPct: p.descPct, descPct: p.descPct,
periodo: [dayjs(p.dataInicio), dayjs(p.dataFim)], periodo: [dayjs(p.dataInicio), dayjs(p.dataFim)],
ativa: p.ativa,
}); });
setModalOpen(true); 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) { async function handleSubmit(values: PromocaoForm) {
const [inicio, fim] = values.periodo; const [inicio, fim] = values.periodo;
const body = { try {
if (editTarget) {
await updateMutation.mutateAsync({
id: editTarget.id,
body: {
descricao: values.descricao, descricao: values.descricao,
codProduto: values.codProduto || undefined, codProduto: values.codProduto ?? null,
grpProd: values.grpProd || undefined, grpProd: values.grpProd ?? null,
descPct: values.descPct, descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'), dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'), dataFim: fim.format('YYYY-MM-DD'),
}; ativa: values.ativa ?? true,
if (editTarget) { },
await updateMutation.mutateAsync({ id: editTarget.id, body }); });
void msg.success('Promocao atualizada'); void msg.success('Promocao atualizada');
} else { } else {
await createMutation.mutateAsync(body); const res = (await createMutation.mutateAsync({
void msg.success('Promocao criada'); 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
} }
setModalOpen(false); setModalOpen(false);
} }
async function handleDelete(id: number) { async function handleDelete(id: number) {
try {
await deleteMutation.mutateAsync(id); await deleteMutation.mutateAsync(id);
} catch {
return; // erro já notificado pelo handler global do QueryClient
}
void msg.success('Promocao removida'); void msg.success('Promocao removida');
} }
@@ -255,6 +248,19 @@ function TabPromocoes() {
return <Tag color="green">Ativa</Tag>; 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: '', title: '',
width: 100, width: 100,
@@ -312,7 +318,8 @@ function TabPromocoes() {
onOk={() => void form.validateFields().then(handleSubmit)} onOk={() => void form.validateFields().then(handleSubmit)}
okText={editTarget ? 'Salvar' : 'Criar'} okText={editTarget ? 'Salvar' : 'Criar'}
confirmLoading={createMutation.isPending || updateMutation.isPending} confirmLoading={createMutation.isPending || updateMutation.isPending}
width={520} width={760}
styles={{ body: { minHeight: 380, maxHeight: '72vh', overflowY: 'auto' } }}
centered centered
> >
<Form form={form} layout="vertical" style={{ marginTop: 16 }}> <Form form={form} layout="vertical" style={{ marginTop: 16 }}>
@@ -324,14 +331,468 @@ function TabPromocoes() {
<Input placeholder="Ex.: Promocao de inverno" /> <Input placeholder="Ex.: Promocao de inverno" />
</Form.Item> </Form.Item>
{editTarget ? (
<Flex gap={16}> <Flex gap={16}>
<Form.Item name="codProduto" label="Codigo do produto" style={{ flex: 1 }}> <Form.Item name="codProduto" label="Produto" style={{ flex: 1 }}>
<Input placeholder="Opcional" /> <ProdutoSelect />
</Form.Item> </Form.Item>
<Form.Item name="grpProd" label="Grupo de produto" style={{ flex: 1 }}> <Form.Item name="grpProd" label="Grupo de produto" style={{ flex: 1 }}>
<Input placeholder="Opcional" /> <Select
options={grupoOptions}
placeholder="Opcional"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
<Form.Item name="ativa" label="Ativa" valuePropName="checked">
<Switch />
</Form.Item> </Form.Item>
</Flex> </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"
label="Desconto (%)"
rules={[{ required: true, message: 'Informe o desconto' }]}
>
<InputNumber min={0} max={100} suffix="%" style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="periodo"
label="Periodo de vigencia"
rules={[{ required: true, message: 'Informe o periodo' }]}
>
<DatePicker.RangePicker
format="DD/MM/YYYY"
style={{ width: '100%' }}
placeholder={['Inicio', 'Fim']}
/>
</Form.Item>
</Form>
</Modal>
</>
);
}
// ─── Tab Regras de Desconto ───────────────────────────────────────────────────
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();
const [form] = Form.useForm<RegraForm>();
const [modalOpen, setModalOpen] = useState(false);
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.codGrupo}${g.grupo ?? 'Sem nome'}`,
});
}
if (
g.codSubgrupo != null &&
(filtroGrupos.size === 0 || (g.codGrupo != null && filtroGrupos.has(g.codGrupo))) &&
!subgrupoMap.has(g.codSubgrupo)
) {
subgrupoMap.set(g.codSubgrupo, {
value: g.codSubgrupo,
label: `${g.codSubgrupo}${g.subgrupo ?? 'Sem nome'}`,
});
}
}
const grupoOptions = [...grupoMap.values()];
const subgrupoOptions = [...subgrupoMap.values()];
function openCreate() {
setEditTarget(null);
form.resetFields();
setModalOpen(true);
}
function openEdit(r: RegraDesconto) {
setEditTarget(r);
form.setFieldsValue({
descricao: r.descricao,
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;
try {
if (editTarget) {
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 {
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
}
setModalOpen(false);
}
async function handleDelete(id: number) {
try {
await deleteMutation.mutateAsync(id);
} catch {
return; // erro já notificado pelo handler global do QueryClient
}
void msg.success('Regra removida');
}
const today = dayjs().format('YYYY-MM-DD');
const columns: TableColumnsType<RegraDesconto> = [
{
title: 'Descricao',
dataIndex: 'descricao',
},
{
title: 'Representante',
width: 180,
render: (_: unknown, row: RegraDesconto) =>
row.codVendedor == null ? (
<Tag>Todos</Tag>
) : (
<Text>{row.nomeVendedor ?? `Cód. ${row.codVendedor}`}</Text>
),
},
{
title: 'Grupo / Subgrupo',
width: 200,
render: (_: unknown, row: RegraDesconto) => {
if (row.codGrupo == null && row.codSubgrupo == null) return <Tag>Todos</Tag>;
const partes = [
row.codGrupo != null ? (row.grupo ?? `Grupo ${row.codGrupo}`) : null,
row.codSubgrupo != null ? (row.subgrupo ?? `Subgrupo ${row.codSubgrupo}`) : null,
].filter(Boolean);
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',
width: 100,
align: 'right',
render: (v: number) => <Text className="tabular-nums">{v}%</Text>,
},
{
title: 'Vigencia',
width: 200,
render: (_: unknown, row: RegraDesconto) => (
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
{dayjs(row.dataInicio).format('DD/MM/YY')} a {dayjs(row.dataFim).format('DD/MM/YY')}
</Text>
),
},
{
title: 'Status',
width: 90,
render: (_: unknown, row: RegraDesconto) => {
if (!row.ativa) return <Tag>Inativa</Tag>;
if (row.dataFim < today) return <Tag color="red">Expirada</Tag>;
if (row.dataInicio > today) return <Tag color="blue">Futura</Tag>;
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,
render: (_: unknown, row: RegraDesconto) => (
<Space>
<Tooltip title="Editar">
<Button
size="small"
icon={<FontAwesomeIcon icon={faPen} />}
onClick={() => openEdit(row)}
/>
</Tooltip>
<Popconfirm
title="Remover regra?"
okText="Sim"
cancelText="Nao"
onConfirm={() => void handleDelete(row.id)}
>
<Button
size="small"
danger
icon={<FontAwesomeIcon icon={faTrash} />}
loading={deleteMutation.isPending}
/>
</Popconfirm>
</Space>
),
},
];
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
return (
<>
{msgCtx}
<Flex justify="flex-end" style={{ marginBottom: 16 }}>
<Button type="primary" icon={<FontAwesomeIcon icon={faPlus} />} onClick={openCreate}>
Nova Regra
</Button>
</Flex>
<Table<RegraDesconto>
rowKey="id"
columns={columns}
dataSource={data.regras}
pagination={false}
size="small"
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, subgrupo e pauta informados (em branco = todos) e
aplica o desconto automaticamente nos pedidos durante a vigencia.
</Text>
<Modal
title={editTarget ? 'Editar Regra de Desconto' : 'Nova Regra de Desconto'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void form.validateFields().then(handleSubmit)}
okText={editTarget ? 'Salvar' : 'Criar'}
confirmLoading={createMutation.isPending || updateMutation.isPending}
width={760}
styles={{ body: { minHeight: 380, maxHeight: '72vh', overflowY: 'auto' } }}
centered
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="descricao"
label="Descricao"
rules={[{ required: true, message: 'Informe a descricao' }]}
>
<Input placeholder="Ex.: Campanha linha inverno" />
</Form.Item>
<Form.Item name="codVendedor" label="Representante">
<Select
options={repOptions}
placeholder="Todos os representantes"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
{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 <Form.Item
name="descPct" name="descPct"
@@ -362,16 +823,16 @@ function TabPromocoes() {
export function PoliticasPage() { export function PoliticasPage() {
const items = [ const items = [
{
key: 'descontos',
label: 'Desconto por Representante',
children: <TabDescontos />,
},
{ {
key: 'promocoes', key: 'promocoes',
label: 'Promocoes', label: 'Promocoes',
children: <TabPromocoes />, children: <TabPromocoes />,
}, },
{
key: 'regras',
label: 'Regras de Desconto',
children: <TabRegras />,
},
]; ];
return ( return (
@@ -381,7 +842,7 @@ export function PoliticasPage() {
Politicas Comerciais Politicas Comerciais
</Title> </Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
Limites de desconto e promocoes com vigencia Promocoes e regras de desconto com vigencia
</Text> </Text>
</Flex> </Flex>

View File

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

View File

@@ -0,0 +1,159 @@
import { useState } from 'react';
import { Modal, Form, Select, Input, Checkbox, Typography, Space } from 'antd';
import { TIPOS_CHAMADO, TIPO_CHAMADO_LABEL, type PedidoItem } from '@sar/api-interface';
import { useCreateChamado } from '../../lib/queries/sac';
const { TextArea } = Input;
const { Text } = Typography;
interface Props {
open: boolean;
onClose: () => void;
idPedido?: string;
numPedSar?: number;
numPedErp?: number;
nomeCliente?: string | null;
idCliente: number;
itens: PedidoItem[];
}
export function AbrirChamadoModal({
open,
onClose,
idPedido,
numPedSar,
numPedErp,
nomeCliente,
idCliente,
itens,
}: Props) {
const [form] = Form.useForm();
const [itensSelecionados, setItensSelecionados] = useState<string[]>([]);
const criar = useCreateChamado();
const pedidoLabel = numPedErp
? `ERP #${numPedErp}`
: numPedSar
? `SAR-${String(numPedSar).padStart(5, '0')}`
: 'Pedido';
function handleOk() {
form.validateFields().then((values) => {
const itensAfetados = itens
.filter((it) => itensSelecionados.includes(it.id))
.map((it) => ({
codProduto: it.codProduto ?? '',
nomeProduto: it.descProduto ?? it.codProduto ?? '',
qtd: Number(it.qtd),
}));
criar.mutate(
{
idPedido,
numPedSar,
numPedErp,
nomeCliente: nomeCliente ?? undefined,
idCliente,
tipo: values.tipo,
assunto: values.assunto,
descricao: values.descricao,
itensAfetados,
},
{
onSuccess: () => {
form.resetFields();
setItensSelecionados([]);
onClose();
},
},
);
});
}
function handleCancel() {
form.resetFields();
setItensSelecionados([]);
onClose();
}
return (
<Modal
title={
<span>
Abrir Chamado {pedidoLabel}
{nomeCliente && (
<Text type="secondary" style={{ fontWeight: 400, marginLeft: 8 }}>
· {nomeCliente}
</Text>
)}
</span>
}
open={open}
onOk={handleOk}
onCancel={handleCancel}
okText="Abrir Chamado"
cancelText="Cancelar"
confirmLoading={criar.isPending}
width={640}
centered
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="tipo"
label="Tipo de Ocorrência"
rules={[{ required: true, message: 'Selecione o tipo' }]}
>
<Select placeholder="Selecione...">
{TIPOS_CHAMADO.map((t) => (
<Select.Option key={t} value={t}>
{TIPO_CHAMADO_LABEL[t]}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="assunto"
label="Assunto"
rules={[{ required: true, min: 5, message: 'Mínimo 5 caracteres' }]}
>
<Input maxLength={200} showCount placeholder="Resumo breve do problema..." />
</Form.Item>
<Form.Item
name="descricao"
label="Descrição"
rules={[{ required: true, min: 10, message: 'Mínimo 10 caracteres' }]}
>
<TextArea
rows={4}
maxLength={2000}
showCount
placeholder="Descreva detalhadamente o problema..."
/>
</Form.Item>
{itens.length > 0 && (
<Form.Item label="Produtos Afetados (opcional)">
<Space orientation="vertical" style={{ width: '100%' }}>
{itens.map((it) => (
<Checkbox
key={it.id}
checked={itensSelecionados.includes(it.id)}
onChange={(e) => {
if (e.target.checked) {
setItensSelecionados((prev) => [...prev, it.id]);
} else {
setItensSelecionados((prev) => prev.filter((c) => c !== it.id));
}
}}
>
<Text>
{it.codProduto} {it.descProduto ?? it.codProduto}
</Text>
<Text type="secondary"> (Qtd: {it.qtd})</Text>
</Checkbox>
))}
</Space>
</Form.Item>
)}
</Form>
</Modal>
);
}

View File

@@ -1,13 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import { Grid, Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd'; import { Grid, Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd';
const { useBreakpoint } = Grid;
import { EyeOutlined } from '@ant-design/icons'; import { EyeOutlined } from '@ant-design/icons';
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
import type { ProdutoSummary } from '@sar/api-interface'; import type { ProdutoSummary } from '@sar/api-interface';
import { useCatalog, usePautas } from '../../lib/queries/catalog'; import { useCatalog, usePautas } from '../../lib/queries/catalog';
import { useCondicoesComerciais, type CondicaoComercial } from '../../lib/condicoes-comerciais';
import { CondicaoTag } from '../../components/CondicaoTag';
import { ProductDetailDrawer } from './ProductDetailDrawer'; import { ProductDetailDrawer } from './ProductDetailDrawer';
const { useBreakpoint } = Grid;
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { Search } = Input; const { Search } = Input;
@@ -16,7 +18,10 @@ function fmtPrice(v: string | null | undefined): string {
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—'; return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
} }
function buildColumns(onDetail: (id: number) => void): TableColumnsType<ProdutoSummary> { function buildColumns(
onDetail: (id: number) => void,
condicoesDe: (p: ProdutoSummary) => CondicaoComercial[],
): TableColumnsType<ProdutoSummary> {
return [ return [
{ {
title: 'Código', title: 'Código',
@@ -29,7 +34,18 @@ function buildColumns(onDetail: (id: number) => void): TableColumnsType<ProdutoS
dataIndex: 'descricao', dataIndex: 'descricao',
render: (v: string, row: ProdutoSummary) => ( render: (v: string, row: ProdutoSummary) => (
<div> <div>
<div style={{ fontWeight: 500 }}>{v.trim()}</div> <div
style={{
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: 6,
flexWrap: 'wrap',
}}
>
{v.trim()}
<CondicaoTag conds={condicoesDe(row)} compact />
</div>
{row.grupo && <div style={{ fontSize: 12, color: '#888' }}>{row.grupo.trim()}</div>} {row.grupo && <div style={{ fontSize: 12, color: '#888' }}>{row.grupo.trim()}</div>}
</div> </div>
), ),
@@ -101,8 +117,9 @@ export function CatalogPage() {
const { data: pautas, isLoading: pautasLoading } = usePautas(); const { data: pautas, isLoading: pautasLoading } = usePautas();
const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit }); const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit });
const condicoesDe = useCondicoesComerciais();
const columns = buildColumns((id) => setSelectedIdErp(id)); const columns = buildColumns((id) => setSelectedIdErp(id), condicoesDe);
return ( return (
<div style={{ maxWidth: 1400, margin: '0 auto' }}> <div style={{ maxWidth: 1400, margin: '0 auto' }}>
@@ -144,7 +161,10 @@ export function CatalogPage() {
}} }}
options={pautas?.map((p) => ({ options={pautas?.map((p) => ({
value: p.idPauta, 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> </Space>

View File

@@ -0,0 +1,432 @@
import { useState } from 'react';
import { Badge, Button, Modal, Input, Space, Spin, Table, Tag, Typography, Tabs, Form } from 'antd';
import { CustomerServiceOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
import type { TableColumnsType } from 'antd';
import {
STATUS_CHAMADO_LABEL,
TIPO_CHAMADO_LABEL,
RESOLUCAO_CHAMADO_LABEL,
type Chamado,
type ItemAfetado,
type ChamadoMensagem,
type PedidoErpConsultaItem,
} from '@sar/api-interface';
import {
useChamados,
useAddMensagemRep,
useCancelChamado,
useChamado,
} from '../../lib/queries/sac';
import { useOrderErpConsulta, useOrderDetail } from '../../lib/queries/orders';
import { AbrirChamadoModal } from './AbrirChamadoModal';
const { Title, Text } = Typography;
const { TextArea } = Input;
const STATUS_COLOR: Record<string, string> = {
aberto: 'blue',
em_analise: 'orange',
aguardando_rep: 'purple',
resolvido: 'green',
cancelado: 'default',
};
function pedidoLabel(chamado: Chamado) {
if (chamado.numPedErp) return `ERP #${chamado.numPedErp}`;
if (chamado.numPedSar) return `SAR-${String(chamado.numPedSar).padStart(5, '0')}`;
return '—';
}
// Busca pedido ERP por texto livre (cliente, número, etc.) e abre chamado
function BuscarPedidoModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const [searchInput, setSearchInput] = useState('');
const [searchAtivo, setSearchAtivo] = useState('');
const [pedidoSelecionado, setPedidoSelecionado] = useState<PedidoErpConsultaItem | null>(null);
// idPedido é o ID interno do ERP (usado no endpoint /orders/erp/:id)
// numeroPedido é o número externo visível ao usuário
const erp_id = pedidoSelecionado?.idPedido ?? null;
const consulta = useOrderErpConsulta({ search: searchAtivo, limit: 20 }, !!searchAtivo);
const detalhe = useOrderDetail(erp_id ? `erp-${erp_id}` : undefined);
function buscar() {
const trimmed = searchInput.trim();
if (!trimmed) return;
setSearchAtivo(trimmed);
setPedidoSelecionado(null);
}
function fechar() {
setSearchInput('');
setSearchAtivo('');
setPedidoSelecionado(null);
onClose();
}
// Detalhe carregado — abre o formulário de chamado
if (detalhe.data && pedidoSelecionado) {
return (
<AbrirChamadoModal
open={true}
onClose={fechar}
numPedErp={pedidoSelecionado.numeroPedido}
nomeCliente={pedidoSelecionado.razaoCliente ?? pedidoSelecionado.nomeCliente}
idCliente={pedidoSelecionado.idCliente}
itens={detalhe.data.itens}
/>
);
}
const resultados = consulta.data?.data ?? [];
const carregandoDetalhe = detalhe.isLoading;
const colunas: TableColumnsType<PedidoErpConsultaItem> = [
{ title: 'Pedido', dataIndex: 'numeroPedido', width: 90 },
{
title: 'Cliente',
render: (_: unknown, r: PedidoErpConsultaItem) => r.razaoCliente ?? r.nomeCliente ?? '—',
ellipsis: true,
},
{
title: 'Data',
dataIndex: 'data',
width: 95,
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
{
title: 'Total',
dataIndex: 'total',
width: 100,
render: (v: string) =>
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
];
return (
<Modal
title="Novo Chamado — Selecionar Pedido"
open={open}
onCancel={fechar}
footer={null}
width={680}
centered
>
<Space orientation="vertical" style={{ width: '100%', marginTop: 16 }} size="middle">
<Space.Compact style={{ width: '100%' }}>
<Input
prefix={<SearchOutlined />}
placeholder="Buscar por cliente, número do pedido..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onPressEnter={buscar}
autoFocus
/>
<Button
type="primary"
onClick={buscar}
loading={consulta.isLoading}
disabled={!searchInput.trim()}
>
Buscar
</Button>
</Space.Compact>
{searchAtivo && (
<Table<PedidoErpConsultaItem>
rowKey="numeroPedido"
columns={colunas}
dataSource={resultados}
loading={consulta.isLoading || carregandoDetalhe}
pagination={false}
size="small"
scroll={{ y: 320 }}
onRow={(r) => ({
onClick: () => (r.idPedido ? setPedidoSelecionado(r) : undefined),
style: {
cursor: r.idPedido ? 'pointer' : 'not-allowed',
opacity: r.idPedido ? 1 : 0.4,
},
})}
locale={{ emptyText: 'Nenhum pedido encontrado.' }}
/>
)}
</Space>
</Modal>
);
}
function ChamadoDetalheModal({
id,
open,
onClose,
}: {
id: number;
open: boolean;
onClose: () => void;
}) {
const [texto, setTexto] = useState('');
const { data: chamado, isLoading } = useChamado(id);
const addMsg = useAddMensagemRep(id);
const cancel = useCancelChamado();
function enviar() {
if (!texto.trim()) return;
addMsg.mutate({ texto: texto.trim() }, { onSuccess: () => setTexto('') });
}
const fechado = chamado && ['resolvido', 'cancelado'].includes(chamado.status);
return (
<Modal
title={chamado ? `Chamado #${chamado.id}${chamado.assunto}` : 'Chamado'}
open={open}
onCancel={onClose}
footer={null}
width={700}
centered
>
{isLoading || !chamado ? (
<Spin />
) : (
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
<Space wrap>
<Tag color={STATUS_COLOR[chamado.status]}>{STATUS_CHAMADO_LABEL[chamado.status]}</Tag>
<Tag>{TIPO_CHAMADO_LABEL[chamado.tipo] ?? chamado.tipo}</Tag>
{chamado.resolucao && (
<Tag color="green">
{RESOLUCAO_CHAMADO_LABEL[chamado.resolucao] ?? chamado.resolucao}
</Tag>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
{pedidoLabel(chamado)}
{chamado.nomeCliente ? ` · ${chamado.nomeCliente}` : ''}
</Text>
</Space>
<Text type="secondary">{chamado.descricao}</Text>
{chamado.notaResolucao && (
<div
style={{
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 6,
padding: '8px 12px',
}}
>
<Text strong>Resolução: </Text>
<Text>{chamado.notaResolucao}</Text>
</div>
)}
{chamado.itensAfetados && chamado.itensAfetados.length > 0 && (
<div>
<Text strong style={{ fontSize: 12 }}>
Produtos afetados:{' '}
</Text>
{(chamado.itensAfetados as ItemAfetado[]).map((it, i) => (
<Tag key={`${it.codProduto}-${i}`}>
{it.codProduto} {it.nomeProduto} ({it.qtd} un)
</Tag>
))}
</div>
)}
<div
style={{
border: '1px solid #f0f0f0',
borderRadius: 8,
padding: 12,
maxHeight: 320,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{(chamado.mensagens ?? []).length === 0 ? (
<Text
type="secondary"
style={{ textAlign: 'center', display: 'block', padding: '16px 0' }}
>
Sem mensagens ainda.
</Text>
) : (
((chamado.mensagens as ChamadoMensagem[]) ?? []).map((m) => (
<div
key={m.id}
style={{
display: 'flex',
justifyContent: m.autor === 'rep' ? 'flex-end' : 'flex-start',
}}
>
<div
style={{
maxWidth: '72%',
background: m.autor === 'rep' ? '#1677ff' : '#f5f5f5',
color: m.autor === 'rep' ? '#fff' : '#000',
borderRadius: 10,
padding: '8px 12px',
fontSize: 13,
}}
>
<div style={{ fontSize: 11, opacity: 0.7, marginBottom: 2 }}>
{m.autor === 'rep' ? 'Você' : 'Empresa'}
</div>
<div>{m.texto}</div>
<div style={{ fontSize: 11, opacity: 0.7, marginTop: 4, textAlign: 'right' }}>
{new Date(m.createdAt).toLocaleString('pt-BR')}
</div>
</div>
</div>
))
)}
</div>
{!fechado && (
<Form layout="vertical">
<Form.Item label="Nova mensagem">
<TextArea
rows={2}
value={texto}
onChange={(e) => setTexto(e.target.value)}
maxLength={2000}
showCount
/>
</Form.Item>
<Space>
<Button
type="primary"
onClick={enviar}
loading={addMsg.isPending}
disabled={!texto.trim()}
>
Enviar
</Button>
<Button
danger
onClick={() => cancel.mutate(id, { onSuccess: onClose })}
loading={cancel.isPending}
>
Cancelar Chamado
</Button>
</Space>
</Form>
)}
</Space>
)}
</Modal>
);
}
const columns: TableColumnsType<Chamado> = [
{ title: '#', dataIndex: 'id', width: 60 },
{
title: 'Status',
dataIndex: 'status',
width: 140,
render: (s: string) => (
<Tag color={STATUS_COLOR[s] ?? 'default'}>{STATUS_CHAMADO_LABEL[s] ?? s}</Tag>
),
},
{
title: 'Tipo',
dataIndex: 'tipo',
width: 160,
render: (t: string) => TIPO_CHAMADO_LABEL[t] ?? t,
},
{ title: 'Assunto', dataIndex: 'assunto', ellipsis: true },
{
title: 'Pedido',
width: 100,
render: (_: unknown, r: Chamado) => pedidoLabel(r),
},
{ title: 'Cliente', dataIndex: 'nomeCliente', width: 160, ellipsis: true },
{
title: 'Data',
dataIndex: 'createdAt',
width: 110,
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
];
export function ChamadosPage() {
const { data, isLoading } = useChamados();
const [detalheId, setDetalheId] = useState<number | null>(null);
const [novoOpen, setNovoOpen] = useState(false);
if (isLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
const abertos = data?.abertos ?? [];
const fechados = data?.fechados ?? [];
const totalAbertos = abertos.length;
return (
<div style={{ padding: 24, maxWidth: 1100, margin: '0 auto' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 24,
}}
>
<Space align="center">
<CustomerServiceOutlined style={{ fontSize: 24, color: '#1677ff' }} />
<Title level={3} style={{ margin: 0 }}>
SAC Chamados
</Title>
{totalAbertos > 0 && <Badge count={totalAbertos} />}
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setNovoOpen(true)}>
Novo Chamado
</Button>
</div>
<Tabs
items={[
{
key: 'abertos',
label: `Abertos (${abertos.length})`,
children: (
<Table<Chamado>
rowKey="id"
columns={columns}
dataSource={abertos}
pagination={false}
size="small"
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
locale={{ emptyText: 'Nenhum chamado aberto.' }}
/>
),
},
{
key: 'fechados',
label: `Fechados (${fechados.length})`,
children: (
<Table<Chamado>
rowKey="id"
columns={columns}
dataSource={fechados}
pagination={{ pageSize: 20 }}
size="small"
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
locale={{ emptyText: 'Nenhum chamado fechado.' }}
/>
),
},
]}
/>
{detalheId !== null && (
<ChamadoDetalheModal
id={detalheId}
open={detalheId !== null}
onClose={() => setDetalheId(null)}
/>
)}
<BuscarPedidoModal open={novoOpen} onClose={() => setNovoOpen(false)} />
</div>
);
}

View File

@@ -15,8 +15,6 @@ import {
Badge, Badge,
} from 'antd'; } from 'antd';
import { useState } from 'react'; import { useState } from 'react';
const { useBreakpoint } = Grid;
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons'; import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
import { Link, useNavigate, useParams } from '@tanstack/react-router'; import { Link, useNavigate, useParams } from '@tanstack/react-router';
@@ -32,6 +30,8 @@ import {
} from '../../lib/queries/clients'; } from '../../lib/queries/clients';
import { ClientContacts } from '../../components/contacts/ClientContacts'; import { ClientContacts } from '../../components/contacts/ClientContacts';
const { useBreakpoint } = Grid;
const { Title, Text } = Typography; const { Title, Text } = Typography;
const ACTIVITY_COLOR: Record<string, string> = { const ACTIVITY_COLOR: Record<string, string> = {
@@ -251,7 +251,7 @@ export function ClientDetailPage() {
{client.telefone ?? '—'} {client.telefone ?? '—'}
</Descriptions.Item> </Descriptions.Item>
{client.endereco && ( {client.endereco && (
<Descriptions.Item label="Endereço" span={2}> <Descriptions.Item label="Endereço" span={isMobile ? 1 : 2}>
{client.endereco} {client.endereco}
{client.numEndereco ? `, ${client.numEndereco}` : ''} {client.numEndereco ? `, ${client.numEndereco}` : ''}
{client.bairro ? `${client.bairro}` : ''} {client.bairro ? `${client.bairro}` : ''}
@@ -284,7 +284,9 @@ export function ClientDetailPage() {
}} }}
> >
{/* ── Contas a Receber ── */} {/* ── Contas a Receber ── */}
<div> {/* minWidth: 0 — track 1fr tem min-width auto; sem isto a tabela com
scroll x 'max-content' estoura a coluna e engole a vizinha (NF-e). */}
<div style={{ minWidth: 0 }}>
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@@ -390,7 +392,7 @@ export function ClientDetailPage() {
</div> </div>
{/* ── Notas Fiscais ── */} {/* ── Notas Fiscais ── */}
<div> <div style={{ minWidth: 0 }}>
<div <div
style={{ style={{
display: 'flex', display: 'flex',

View File

@@ -35,16 +35,12 @@ import {
UserOutlined, UserOutlined,
WhatsAppOutlined, WhatsAppOutlined,
} from '@ant-design/icons'; } 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 { useNavigate } from '@tanstack/react-router';
import type { ActivityStatus, ClientSummary } from '@sar/api-interface'; import type { ActivityStatus, ClientSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface'; import { SITUA_LABEL } from '@sar/api-interface';
import { useClientList, useClientDetail } from '../../lib/queries/clients'; import { useClientList, useClientDetail } from '../../lib/queries/clients';
import { useClientOrders } from '../../lib/queries/orders'; import { useClientOrders } from '../../lib/queries/orders';
ChartJS.register(ArcElement, ChartTooltip, Legend);
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
@@ -184,143 +180,6 @@ function CustomerMetrics({ stats }: { stats: PortfolioStats }) {
); );
} }
// ─── CustomerPortfolioCard ────────────────────────────────────────────────────
function CustomerPortfolioCard({ stats }: { stats: PortfolioStats }) {
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
style={{ borderRadius: 8, fontWeight: 600, color: '#003B8E', borderColor: '#003B8E' }}
>
Detalhar carteira
</Button>
</Card>
);
}
// ─── CustomerExpandedDetail ─────────────────────────────────────────────────── // ─── CustomerExpandedDetail ───────────────────────────────────────────────────
function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) { function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) {
@@ -500,7 +359,7 @@ function CustomerDetailsModal({
</Space> </Space>
} }
> >
<Space direction="vertical" size={20} style={{ width: '100%' }}> <Space orientation="vertical" size={20} style={{ width: '100%' }}>
{/* Identificação */} {/* Identificação */}
<Card <Card
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }} style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
@@ -544,7 +403,7 @@ function CustomerDetailsModal({
> >
Dados Cadastrais Dados Cadastrais
</Text> </Text>
<Space direction="vertical" size={10} style={{ width: '100%' }}> <Space orientation="vertical" size={10} style={{ width: '100%' }}>
<div> <div>
<span style={label}>Telefone</span> <span style={label}>Telefone</span>
<Space size={4}> <Space size={4}>
@@ -596,7 +455,7 @@ function CustomerDetailsModal({
> >
Dados Comerciais Dados Comerciais
</Text> </Text>
<Space direction="vertical" size={10} style={{ width: '100%' }}> <Space orientation="vertical" size={10} style={{ width: '100%' }}>
{limiteFormatado !== '—' && ( {limiteFormatado !== '—' && (
<div> <div>
<span style={label}>Limite de Crédito</span> <span style={label}>Limite de Crédito</span>
@@ -657,7 +516,7 @@ function CustomerDetailsModal({
{loadingOrders ? ( {loadingOrders ? (
<Spin size="small" /> <Spin size="small" />
) : ( ) : (
<Space direction="vertical" size={6} style={{ width: '100%' }}> <Space orientation="vertical" size={6} style={{ width: '100%' }}>
{orders.slice(0, 5).map((p) => ( {orders.slice(0, 5).map((p) => (
<div <div
key={p.id} key={p.id}
@@ -811,7 +670,7 @@ function CustomerAnalysisModal({
</Space> </Space>
} }
> >
<Space direction="vertical" size={20} style={{ width: '100%' }}> <Space orientation="vertical" size={20} style={{ width: '100%' }}>
<div> <div>
<Space size={8}> <Space size={8}>
<CustomerStatusBadge status={summary.activityStatus} /> <CustomerStatusBadge status={summary.activityStatus} />
@@ -1080,7 +939,7 @@ export function ClientsPage() {
limit, limit,
}); });
const rows = data?.data ?? []; const rows = useMemo(() => data?.data ?? [], [data]);
const sorted = useMemo(() => { const sorted = useMemo(() => {
const r = [...rows]; const r = [...rows];
@@ -1453,17 +1312,9 @@ export function ClientsPage() {
</Row> </Row>
</Card> </Card>
{/* ── Portfolio mobile (antes da lista) ─────────────────────────── */}
{isMobile && (
<>
<CustomerPortfolioCard stats={stats} />
<div style={{ marginBottom: 16 }} />
</>
)}
{/* ── Área principal ────────────────────────────────────────────── */} {/* ── Área principal ────────────────────────────────────────────── */}
<Row gutter={[20, 20]}> <Row gutter={[20, 20]}>
<Col xs={24} lg={17}> <Col xs={24} style={{ minWidth: 0 }}>
{isLoading ? ( {isLoading ? (
<div style={{ textAlign: 'center', padding: 64 }}> <div style={{ textAlign: 'center', padding: 64 }}>
<Spin size="large" /> <Spin size="large" />
@@ -1536,12 +1387,6 @@ export function ClientsPage() {
</Card> </Card>
)} )}
</Col> </Col>
{!isMobile && (
<Col lg={7}>
<CustomerPortfolioCard stats={stats} />
</Col>
)}
</Row> </Row>
{/* ── Modais ────────────────────────────────────────────────────── */} {/* ── 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

@@ -0,0 +1,552 @@
import { useState } from 'react';
import {
Alert,
Badge,
Button,
Card,
Col,
Collapse,
Flex,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Skeleton,
Space,
Tag,
Tooltip,
Typography,
} from 'antd';
import {
ArrowRightOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
TrophyOutlined,
} from '@ant-design/icons';
import type {
Oportunidade,
EtapaFunil,
CreateOportunidadeDto,
UpdateOportunidadeDto,
} from '@sar/api-interface';
import { ETAPAS_FUNIL } from '@sar/api-interface';
import {
useFunil,
useCreateOportunidade,
useUpdateOportunidade,
useDeleteOportunidade,
} from '../../lib/queries/funil';
const { Title, Text } = Typography;
const ETAPA_CONFIG: Record<EtapaFunil, { label: string; cor: string; corBg: string }> = {
lead: { label: 'Lead', cor: '#64748b', corBg: '#F8FAFC' },
proposta: { label: 'Proposta', cor: '#2563EB', corBg: '#EFF6FF' },
negociacao: { label: 'Negociação', cor: '#D97706', corBg: '#FFFBEB' },
ganho: { label: 'Ganho', cor: '#16A34A', corBg: '#F0FDF4' },
perdido: { label: 'Perdido', cor: '#DC2626', corBg: '#FEF2F2' },
};
const PROXIMA: Partial<Record<EtapaFunil, EtapaFunil>> = {
lead: 'proposta',
proposta: 'negociacao',
negociacao: 'ganho',
};
const ANTERIOR: Partial<Record<EtapaFunil, EtapaFunil>> = {
proposta: 'lead',
negociacao: 'proposta',
ganho: 'negociacao',
};
function fmt(v: string | null | undefined): string {
if (!v) return '';
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function diasDesde(iso: string): number {
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
}
function nomePrincipal(op: Oportunidade): string {
return op.nomeCliente ?? op.nomeProspect ?? op.empresaProspect ?? `Oport. #${op.id}`;
}
// ─── Card ────────────────────────────────────────────────────────────────────
function OportCard({
op,
onEdit,
onMover,
onDeletar,
}: {
op: Oportunidade;
onEdit: () => void;
onMover: (etapa: EtapaFunil) => void;
onDeletar: () => void;
}) {
const dias = diasDesde(op.updatedAt);
const prox = PROXIMA[op.etapa as EtapaFunil];
const ant = ANTERIOR[op.etapa as EtapaFunil];
return (
<Card
size="small"
style={{ marginBottom: 10, borderRadius: 8, border: '1px solid #E2E8F0' }}
styles={{ body: { padding: '10px 12px' } }}
>
<Flex justify="space-between" align="flex-start" gap={8}>
<div style={{ flex: 1, minWidth: 0 }}>
<Text strong style={{ fontSize: 13, color: '#1e293b', display: 'block' }} ellipsis>
{nomePrincipal(op)}
</Text>
{op.titulo && (
<Text type="secondary" style={{ fontSize: 11 }} ellipsis>
{op.titulo}
</Text>
)}
</div>
<Space size={2}>
<Tooltip title="Editar">
<Button size="small" type="text" icon={<EditOutlined />} onClick={onEdit} />
</Tooltip>
<Tooltip title="Excluir">
<Button
size="small"
type="text"
danger
icon={<DeleteOutlined />}
onClick={() =>
Modal.confirm({
title: 'Excluir oportunidade?',
content: 'Esta ação não pode ser desfeita.',
okText: 'Excluir',
okButtonProps: { danger: true },
onOk: onDeletar,
})
}
/>
</Tooltip>
</Space>
</Flex>
<Flex justify="space-between" align="center" style={{ marginTop: 6 }}>
<Text style={{ fontSize: 12, color: '#003B8E', fontWeight: 600 }}>
{op.valorEstimado ? fmt(op.valorEstimado) : '—'}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{dias === 0 ? 'hoje' : `${dias}d`}
</Text>
</Flex>
<Flex gap={4} style={{ marginTop: 8 }}>
{ant && (
<Button
size="small"
style={{ fontSize: 11, padding: '0 6px', color: '#64748b', borderColor: '#CBD5E1' }}
onClick={() => onMover(ant)}
>
{ETAPA_CONFIG[ant].label}
</Button>
)}
<div style={{ flex: 1 }} />
{op.etapa !== 'perdido' && op.etapa !== 'ganho' && (
<Tooltip title="Marcar como perdido">
<Button
size="small"
danger
ghost
style={{ fontSize: 11, padding: '0 6px' }}
onClick={() => onMover('perdido')}
>
Perdido
</Button>
</Tooltip>
)}
{prox && (
<Button
size="small"
type="primary"
style={{
fontSize: 11,
padding: '0 6px',
background: ETAPA_CONFIG[prox].cor,
borderColor: ETAPA_CONFIG[prox].cor,
}}
onClick={() => onMover(prox)}
>
{ETAPA_CONFIG[prox].label} <ArrowRightOutlined />
</Button>
)}
{op.etapa === 'ganho' && (
<Tag color="green" style={{ fontSize: 11, margin: 0 }}>
<TrophyOutlined /> Ganho
</Tag>
)}
</Flex>
</Card>
);
}
// ─── Modal criar/editar ───────────────────────────────────────────────────────
type FormValues = {
titulo: string;
etapa: EtapaFunil;
nomeProspect?: string;
empresaProspect?: string;
valorEstimado?: number;
observacoes?: string;
};
function OportModal({
open,
initial,
etapaInicial,
onClose,
onSave,
}: {
open: boolean;
initial?: Oportunidade | null;
etapaInicial: EtapaFunil;
onClose: () => void;
onSave: (dto: CreateOportunidadeDto | UpdateOportunidadeDto) => Promise<void>;
}) {
const [form] = Form.useForm<FormValues>();
const [saving, setSaving] = useState(false);
const handleOk = async () => {
const values = await form.validateFields();
setSaving(true);
try {
await onSave({
titulo: values.titulo,
etapa: values.etapa,
nomeProspect: values.nomeProspect ?? null,
empresaProspect: values.empresaProspect ?? null,
valorEstimado: values.valorEstimado ?? null,
observacoes: values.observacoes ?? null,
});
onClose();
} catch {
// erro já notificado pelo handler global do QueryClient; modal fica aberto
} finally {
setSaving(false);
}
};
return (
<Modal
open={open}
title={initial ? 'Editar Oportunidade' : 'Nova Oportunidade'}
width={520}
centered
onCancel={onClose}
onOk={handleOk}
okText={initial ? 'Salvar' : 'Criar'}
confirmLoading={saving}
afterOpenChange={(vis) => {
if (vis) {
form.setFieldsValue(
initial
? {
titulo: initial.titulo,
etapa: initial.etapa as EtapaFunil,
nomeProspect: initial.nomeProspect ?? undefined,
empresaProspect: initial.empresaProspect ?? undefined,
valorEstimado: initial.valorEstimado ? Number(initial.valorEstimado) : undefined,
observacoes: initial.observacoes ?? undefined,
}
: { etapa: etapaInicial },
);
} else {
form.resetFields();
}
}}
>
<Form form={form} layout="vertical" style={{ marginTop: 8 }}>
<Form.Item
name="titulo"
label="Título / produto de interesse"
rules={[{ required: true, message: 'Informe o título' }]}
>
<Input placeholder="Ex: Reposição de estoque Q3" />
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="nomeProspect" label="Nome do contato">
<Input placeholder="João Silva" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="empresaProspect" label="Empresa">
<Input placeholder="Empresa XYZ" />
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="valorEstimado" label="Valor estimado (R$)">
<InputNumber
style={{ width: '100%' }}
min={0}
precision={2}
formatter={(v) => String(v).replace(/\B(?=(\d{3})+(?!\d))/g, '.')}
parser={(v) => Number((v ?? '').replace(/\./g, '').replace(',', '.'))}
placeholder="0,00"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="etapa" label="Estágio">
<Select
options={ETAPAS_FUNIL.map((e) => ({
value: e,
label: ETAPA_CONFIG[e].label,
}))}
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="observacoes" label="Observações">
<Input.TextArea rows={3} placeholder="Anotações sobre o lead..." />
</Form.Item>
</Form>
</Modal>
);
}
// ─── Coluna kanban ────────────────────────────────────────────────────────────
function KanbanColuna({
etapa,
ops,
onEdit,
onMover,
onDeletar,
onNovo,
}: {
etapa: EtapaFunil;
ops: Oportunidade[];
onEdit: (op: Oportunidade) => void;
onMover: (id: number, etapa: EtapaFunil) => void;
onDeletar: (id: number) => void;
onNovo: (etapa: EtapaFunil) => void;
}) {
const cfg = ETAPA_CONFIG[etapa];
const total = ops.reduce((s, o) => s + (o.valorEstimado ? Number(o.valorEstimado) : 0), 0);
return (
<div
style={{
minWidth: 240,
flex: 1,
background: cfg.corBg,
borderRadius: 10,
border: `1px solid ${cfg.cor}33`,
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
padding: '10px 14px 8px',
borderBottom: `2px solid ${cfg.cor}`,
borderRadius: '10px 10px 0 0',
}}
>
<Flex justify="space-between" align="center">
<Space size={6}>
<Text strong style={{ color: cfg.cor, fontSize: 13 }}>
{cfg.label}
</Text>
<Badge count={ops.length} color={cfg.cor} style={{ fontSize: 11 }} />
</Space>
{total > 0 && (
<Text style={{ fontSize: 11, color: cfg.cor, fontWeight: 600 }}>
{total.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
</Text>
)}
</Flex>
</div>
<div
style={{
padding: '10px 10px 4px',
flex: 1,
overflowY: 'auto',
maxHeight: 'calc(100vh - 260px)',
}}
>
{ops.map((op) => (
<OportCard
key={op.id}
op={op}
onEdit={() => onEdit(op)}
onMover={(e) => onMover(op.id, e)}
onDeletar={() => onDeletar(op.id)}
/>
))}
</div>
<div style={{ padding: '4px 10px 10px' }}>
<Button
block
type="dashed"
icon={<PlusOutlined />}
style={{ borderColor: cfg.cor, color: cfg.cor, fontSize: 12 }}
onClick={() => onNovo(etapa)}
>
Adicionar
</Button>
</div>
</div>
);
}
// ─── FunilPage ────────────────────────────────────────────────────────────────
export function FunilPage() {
const { data, isLoading, isError, error } = useFunil();
const criar = useCreateOportunidade();
const atualizar = useUpdateOportunidade();
const deletar = useDeleteOportunidade();
const [modalOpen, setModalOpen] = useState(false);
const [editOp, setEditOp] = useState<Oportunidade | null>(null);
const [etapaModal, setEtapaModal] = useState<EtapaFunil>('lead');
const abrirNovo = (etapa: EtapaFunil) => {
setEditOp(null);
setEtapaModal(etapa);
setModalOpen(true);
};
const abrirEditar = (op: Oportunidade) => {
setEditOp(op);
setEtapaModal(op.etapa as EtapaFunil);
setModalOpen(true);
};
const handleSave = async (dto: CreateOportunidadeDto | UpdateOportunidadeDto) => {
if (editOp) {
await atualizar.mutateAsync({ id: editOp.id, dto });
} else {
await criar.mutateAsync(dto as CreateOportunidadeDto);
}
};
const handleMover = (id: number, etapa: EtapaFunil) => {
atualizar.mutate({ id, dto: { etapa } });
};
const handleDeletar = (id: number) => {
deletar.mutate(id);
};
const etapasPrincipais: EtapaFunil[] = ['lead', 'proposta', 'negociacao', 'ganho'];
const perdidos = data?.perdido ?? [];
return (
<div
style={{
height: 'calc(100vh - var(--layout-topbar-height) - 48px)',
display: 'flex',
flexDirection: 'column',
}}
>
<Flex align="center" justify="space-between" style={{ marginBottom: 16, flexShrink: 0 }}>
<div>
<Title level={4} style={{ margin: 0, color: '#003B8E' }}>
Funil de Vendas
</Title>
<Text type="secondary" style={{ fontSize: 13 }}>
Acompanhe seus leads até o fechamento do pedido
</Text>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => abrirNovo('lead')}
style={{ background: '#003B8E' }}
>
Novo Lead
</Button>
</Flex>
{isError && (
<Alert
type="error"
showIcon
message="Erro ao carregar funil"
description={error instanceof Error ? error.message : 'Erro desconhecido'}
style={{ marginBottom: 16, flexShrink: 0 }}
/>
)}
{isLoading ? (
<Skeleton active paragraph={{ rows: 8 }} />
) : (
<>
<div style={{ display: 'flex', gap: 12, flex: 1, overflowX: 'auto', paddingBottom: 8 }}>
{etapasPrincipais.map((etapa) => (
<KanbanColuna
key={etapa}
etapa={etapa}
ops={data?.[etapa] ?? []}
onEdit={abrirEditar}
onMover={handleMover}
onDeletar={handleDeletar}
onNovo={abrirNovo}
/>
))}
</div>
{perdidos.length > 0 && (
<div style={{ marginTop: 12, flexShrink: 0 }}>
<Collapse
ghost
size="small"
items={[
{
key: 'perdidos',
label: (
<Text style={{ color: '#DC2626', fontSize: 13 }}>
Perdidos ({perdidos.length})
</Text>
),
children: (
<Row gutter={[10, 10]}>
{perdidos.map((op) => (
<Col key={op.id} xs={24} sm={12} md={8} lg={6}>
<OportCard
op={op}
onEdit={() => abrirEditar(op)}
onMover={(e) => handleMover(op.id, e)}
onDeletar={() => handleDeletar(op.id)}
/>
</Col>
))}
</Row>
),
},
]}
/>
</div>
)}
</>
)}
<OportModal
open={modalOpen}
initial={editOp}
etapaInicial={etapaModal}
onClose={() => setModalOpen(false)}
onSave={handleSave}
/>
</div>
);
}

View File

@@ -129,7 +129,12 @@ export function NewClientPage() {
codPauta: values.codPauta ? Number(values.codPauta) : undefined, codPauta: values.codPauta ? Number(values.codPauta) : undefined,
}; };
const result = await createMutation.mutateAsync(payload); let result: Awaited<ReturnType<typeof createMutation.mutateAsync>>;
try {
result = await createMutation.mutateAsync(payload);
} catch {
return; // erro já notificado pelo handler global do QueryClient
}
if (!result.sincronizado && result.erroSync) { if (!result.sincronizado && result.erroSync) {
setSyncError(result.erroSync); setSyncError(result.erroSync);

View File

@@ -29,9 +29,12 @@ import {
CheckCircleOutlined, CheckCircleOutlined,
CloseOutlined, CloseOutlined,
DeleteOutlined, DeleteOutlined,
DownOutlined,
HistoryOutlined,
PlusOutlined, PlusOutlined,
SearchOutlined, SearchOutlined,
ShoppingCartOutlined, ShoppingCartOutlined,
UpOutlined,
UserOutlined, UserOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate, useSearch } from '@tanstack/react-router'; import { useNavigate, useSearch } from '@tanstack/react-router';
@@ -44,9 +47,13 @@ import type {
TopProduto, TopProduto,
} from '@sar/api-interface'; } from '@sar/api-interface';
import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients'; import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients';
import { useOrderDetail } from '../../lib/queries/orders';
import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog'; import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog';
import { useCondicoesComerciais, useTetoDesconto } from '../../lib/condicoes-comerciais';
import { CondicaoTag } from '../../components/CondicaoTag';
import { apiFetch } from '../../lib/api-client'; import { apiFetch } from '../../lib/api-client';
import { enqueueOrder } from '../../lib/offline/order-queue'; import { enqueueOrder } from '../../lib/offline/order-queue';
import { randomUUID } from '../../lib/uuid';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
@@ -63,9 +70,12 @@ type CartItem = {
precoUnitario: number; precoUnitario: number;
descontoPerc: number; descontoPerc: number;
loteMulVenda: number | null; 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 ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -193,6 +203,7 @@ function ProductSearch({
const screens = useBreakpoint(); const screens = useBreakpoint();
const isMobile = !screens.md; const isMobile = !screens.md;
const { data, isFetching } = useCatalog({ q: q || undefined, idPauta, limit: 25 }); const { data, isFetching } = useCatalog({ q: q || undefined, idPauta, limit: 25 });
const condicoesDe = useCondicoesComerciais();
const options = (data?.data ?? []).map((p) => ({ const options = (data?.data ?? []).map((p) => ({
value: String(p.idErp), value: String(p.idErp),
@@ -208,8 +219,19 @@ function ProductSearch({
<Space size={6} align="start" style={{ flex: 1, minWidth: 0 }}> <Space size={6} align="start" style={{ flex: 1, minWidth: 0 }}>
<Tag style={{ margin: 0, fontSize: 11, flexShrink: 0 }}>{p.codigo.trim()}</Tag> <Tag style={{ margin: 0, fontSize: 11, flexShrink: 0 }}>{p.codigo.trim()}</Tag>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ color: '#1F2937', fontWeight: 500, lineHeight: 1.3 }}> <div
style={{
color: '#1F2937',
fontWeight: 500,
lineHeight: 1.3,
display: 'flex',
alignItems: 'center',
gap: 6,
flexWrap: 'wrap',
}}
>
{p.descricao.trim()} {p.descricao.trim()}
<CondicaoTag conds={condicoesDe(p)} compact />
</div> </div>
{p.grupo && ( {p.grupo && (
<div style={{ fontSize: 11, color: '#94A3B8', lineHeight: 1.2 }}> <div style={{ fontSize: 11, color: '#94A3B8', lineHeight: 1.2 }}>
@@ -335,6 +357,7 @@ function CatalogBrowserModal({
page, page,
limit: MODAL_LIMIT, limit: MODAL_LIMIT,
}); });
const condicoesDe = useCondicoesComerciais();
const cartMap = new Map(cart.map((it) => [it.idProduto, it.qtd])); const cartMap = new Map(cart.map((it) => [it.idProduto, it.qtd]));
@@ -353,7 +376,10 @@ function CatalogBrowserModal({
title: 'Produto', title: 'Produto',
render: (_: unknown, row: ProdutoSummary) => ( render: (_: unknown, row: ProdutoSummary) => (
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<Text style={{ fontWeight: 500 }}>{row.descricao.trim()}</Text> <Text style={{ fontWeight: 500 }}>{row.descricao.trim()}</Text>
<CondicaoTag conds={condicoesDe(row)} compact />
</div>
{row.grupo && ( {row.grupo && (
<Text type="secondary" style={{ fontSize: 11, display: 'block' }}> <Text type="secondary" style={{ fontSize: 11, display: 'block' }}>
{row.grupo.trim()} {row.grupo.trim()}
@@ -460,7 +486,15 @@ function CatalogBrowserModal({
title: 'Produto', title: 'Produto',
render: (_: unknown, row: ProdutoSummary) => ( render: (_: unknown, row: ProdutoSummary) => (
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}> <div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 2,
flexWrap: 'wrap',
}}
>
<Tag style={{ fontSize: 10, margin: 0, padding: '0 4px' }}>{row.codigo.trim()}</Tag> <Tag style={{ fontSize: 10, margin: 0, padding: '0 4px' }}>{row.codigo.trim()}</Tag>
<div <div
title={stockTitle(row.qtdEstoque)} title={stockTitle(row.qtdEstoque)}
@@ -472,6 +506,7 @@ function CatalogBrowserModal({
flexShrink: 0, flexShrink: 0,
}} }}
/> />
<CondicaoTag conds={condicoesDe(row)} compact />
</div> </div>
<Text style={{ fontWeight: 500, fontSize: 13 }}>{row.descricao.trim()}</Text> <Text style={{ fontWeight: 500, fontSize: 13 }}>{row.descricao.trim()}</Text>
{row.grupo && ( {row.grupo && (
@@ -601,17 +636,20 @@ function CatalogBrowserModal({
function MobileCartItem({ function MobileCartItem({
item, item,
teto,
onQtyChange, onQtyChange,
onDiscChange, onDiscChange,
onRemove, onRemove,
}: { }: {
item: CartItem; item: CartItem;
teto: number;
onQtyChange: (key: string, qty: number) => void; onQtyChange: (key: string, qty: number) => void;
onDiscChange: (key: string, disc: number) => void; onDiscChange: (key: string, disc: number) => void;
onRemove: (key: string) => void; onRemove: (key: string) => void;
}) { }) {
const lote = item.loteMulVenda ?? 1; const lote = item.loteMulVenda ?? 1;
const invalid = lote > 1 && item.qtd % lote !== 0; const invalid = lote > 1 && item.qtd % lote !== 0;
const acimaTeto = item.descontoPerc > teto + 0.001;
return ( return (
<div <div
@@ -683,6 +721,14 @@ function MobileCartItem({
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}> <Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
Desc% Desc%
</Text> </Text>
<Tooltip
title={
acimaTeto
? `Acima do teto de ${teto}% — a transmissão será bloqueada`
: `Teto do item: ${teto}%`
}
color={acimaTeto ? 'red' : 'blue'}
>
<InputNumber <InputNumber
min={0} min={0}
max={100} max={100}
@@ -691,8 +737,13 @@ function MobileCartItem({
size="small" size="small"
style={{ width: 88 }} style={{ width: 88 }}
addonAfter="%" addonAfter="%"
status={acimaTeto ? 'error' : undefined}
onChange={(n) => onDiscChange(item.key, n ?? 0)} 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>
<div style={{ marginLeft: 'auto', textAlign: 'right' }}> <div style={{ marginLeft: 'auto', textAlign: 'right' }}>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}> <Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
@@ -720,7 +771,8 @@ function ClientHistoryPanel({
}) { }) {
const screens = useBreakpoint(); const screens = useBreakpoint();
const isMobile = !screens.md; 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 [qtys, setQtys] = useState<Record<number, number>>({});
const { data: produtos = [], isLoading } = useClientTopProdutos(idCliente, 15); const { data: produtos = [], isLoading } = useClientTopProdutos(idCliente, 15);
@@ -881,40 +933,61 @@ function ClientHistoryPanel({
return ( return (
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
{/* Banner âmbar de destaque — fechado por padrão, convida o rep a abrir */}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', 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', cursor: 'pointer',
userSelect: 'none', userSelect: 'none',
}} }}
onClick={() => setCollapsed((c) => !c)} onClick={() => setCollapsed((c) => !c)}
> >
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<HistoryOutlined style={{ color: '#D46B08', fontSize: 18, flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<Text <Text
style={{ strong
fontSize: 11, style={{ color: '#873800', fontSize: 13, display: 'block', lineHeight: 1.3 }}
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#64748B',
}}
> >
Comprados anteriormente · {produtos.length} produto{produtos.length !== 1 ? 's' : ''} Este cliente comprou {produtos.length} produto{produtos.length !== 1 ? 's' : ''}{' '}
com você
</Text> </Text>
<Button type="link" size="small" style={{ padding: 0, fontSize: 12 }}> <Text style={{ color: '#AD6800', fontSize: 11 }}>
{collapsed ? 'exibir' : 'ocultar'} Veja o histórico e repita itens no pedido com um clique
</Text>
</div>
</div>
<Button
size="small"
style={{
borderColor: '#FFD591',
color: '#D46B08',
background: '#fff',
fontWeight: 600,
flexShrink: 0,
}}
icon={collapsed ? <DownOutlined /> : <UpOutlined />}
iconPosition="end"
>
{collapsed ? 'Ver produtos' : 'Ocultar'}
</Button> </Button>
</div> </div>
{!collapsed && ( {!collapsed && (
<div <div
style={{ style={{
border: '1px solid #EBF0F5', border: '1px solid #FFD591',
borderRadius: 8, borderTop: 'none',
borderRadius: '0 0 8px 8px',
overflow: 'hidden', overflow: 'hidden',
background: '#FAFBFC', background: '#FFFDF7',
}} }}
> >
<Table<TopProduto> <Table<TopProduto>
@@ -941,11 +1014,13 @@ function ClientHistoryPanel({
function OrderItemsTable({ function OrderItemsTable({
items, items,
tetoDe,
onQtyChange, onQtyChange,
onDiscChange, onDiscChange,
onRemove, onRemove,
}: { }: {
items: CartItem[]; items: CartItem[];
tetoDe: (item: CartItem) => number;
onQtyChange: (key: string, qty: number) => void; onQtyChange: (key: string, qty: number) => void;
onDiscChange: (key: string, disc: number) => void; onDiscChange: (key: string, disc: number) => void;
onRemove: (key: string) => void; onRemove: (key: string) => void;
@@ -986,6 +1061,7 @@ function OrderItemsTable({
<MobileCartItem <MobileCartItem
key={item.key} key={item.key}
item={item} item={item}
teto={tetoDe(item)}
onQtyChange={onQtyChange} onQtyChange={onQtyChange}
onDiscChange={onDiscChange} onDiscChange={onDiscChange}
onRemove={onRemove} onRemove={onRemove}
@@ -1062,8 +1138,20 @@ function OrderItemsTable({
{ {
title: 'Desc %', title: 'Desc %',
dataIndex: 'descontoPerc', dataIndex: 'descontoPerc',
width: 100, width: 110,
render: (v: number, row: CartItem) => ( 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 <InputNumber
min={0} min={0}
max={100} max={100}
@@ -1072,9 +1160,16 @@ function OrderItemsTable({
size="small" size="small"
style={{ width: 76 }} style={{ width: 76 }}
addonAfter="%" addonAfter="%"
status={acimaTeto ? 'error' : undefined}
onChange={(n) => onDiscChange(row.key, n ?? 0)} 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', title: 'Total',
@@ -1114,7 +1209,7 @@ function OrderItemsTable({
emptyText: ( emptyText: (
<Empty <Empty
image={<ShoppingCartOutlined style={{ fontSize: 48, color: '#D9E2EC' }} />} image={<ShoppingCartOutlined style={{ fontSize: 48, color: '#D9E2EC' }} />}
imageStyle={{ height: 60 }} styles={{ image: { height: 60 } }}
description={ description={
<Space orientation="vertical" size={2}> <Space orientation="vertical" size={2}>
<Text type="secondary">Nenhum produto adicionado ao pedido ainda.</Text> <Text type="secondary">Nenhum produto adicionado ao pedido ainda.</Text>
@@ -1222,11 +1317,16 @@ function OrderSummaryFooter({
// ─── NewOrderPage ───────────────────────────────────────────────────────────── // ─── NewOrderPage ─────────────────────────────────────────────────────────────
export function 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 navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const { message } = App.useApp(); const { message } = App.useApp();
// ── Modo edição: orçamento (situa 0) carregado para alteração ──
const { data: editOrder } = useOrderDetail(editarId);
// ── Dados do cliente ── // ── Dados do cliente ──
const [clientSearch, setClientSearch] = useState(''); const [clientSearch, setClientSearch] = useState('');
const [selectedClient, setSelectedClient] = useState<ClientSummary | null>(null); const [selectedClient, setSelectedClient] = useState<ClientSummary | null>(null);
@@ -1238,8 +1338,11 @@ export function NewOrderPage() {
const { data: selectedClientDetail } = useClientDetail( const { data: selectedClientDetail } = useClientDetail(
!clientIdParam && selectedClient ? selectedClient.idCliente : undefined, !clientIdParam && selectedClient ? selectedClient.idCliente : undefined,
); );
const clientDetail = selectedClientDetail ?? preloadedClient ?? null; const { data: editClient } = useClientDetail(
const effectiveClient = selectedClient ?? preloadedClient ?? null; editarId && editOrder ? editOrder.idCliente : undefined,
);
const clientDetail = selectedClientDetail ?? preloadedClient ?? editClient ?? null;
const effectiveClient = selectedClient ?? preloadedClient ?? editClient ?? null;
// ── Campos comerciais ── // ── Campos comerciais ──
const { data: pautas = [] } = usePautas(); const { data: pautas = [] } = usePautas();
@@ -1264,6 +1367,39 @@ export function NewOrderPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [catalogOpen, setCatalogOpen] = useState(false); 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. // Endereço cadastrado do cliente — usa Detail (tem endereco/bairro/cep), não Summary.
const clientEndStr = (() => { const clientEndStr = (() => {
if (!clientDetail) return ''; if (!clientDetail) return '';
@@ -1277,18 +1413,29 @@ export function NewOrderPage() {
})(); })();
const hasClientEnd = !!clientEndStr; 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 totalPedido = cart.reduce((acc, it) => acc + itemTotal(it), 0);
const loteErrors = cart.filter((it) => { const loteErrors = cart.filter((it) => {
const lote = it.loteMulVenda ?? 1; const lote = it.loteMulVenda ?? 1;
return lote > 1 && it.qtd % lote !== 0; 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 const missingReason = !effectiveClient
? 'Selecione um cliente para continuar' ? 'Selecione um cliente para continuar'
: cart.length === 0 : cart.length === 0
? 'Adicione ao menos um produto ao pedido' ? 'Adicione ao menos um produto ao pedido'
: loteErrors.length > 0 : loteErrors.length > 0
? 'Corrija as quantidades com lote múltiplo' ? 'Corrija as quantidades com lote múltiplo'
: tetoErrors.length > 0
? `Desconto acima do teto em: ${tetoErrors.map((it) => it.codProduto.trim()).join(', ')}`
: undefined; : undefined;
function clearClient() { function clearClient() {
@@ -1297,6 +1444,9 @@ export function NewOrderPage() {
} }
// ── Handlers do carrinho ── // ── 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 addToCart = (p: ProdutoSummary, qty?: number) => {
const lote = p.loteMulVenda ?? 1; const lote = p.loteMulVenda ?? 1;
const finalQty = qty ?? lote; const finalQty = qty ?? lote;
@@ -1319,6 +1469,8 @@ export function NewOrderPage() {
precoUnitario: Number(p.vlPreco1), precoUnitario: Number(p.vlPreco1),
descontoPerc: 0, descontoPerc: 0,
loteMulVenda: p.loteMulVenda, loteMulVenda: p.loteMulVenda,
codGrupo: p.codGrupo,
codSubgrupo: p.codSubgrupo,
}, },
]; ];
}); });
@@ -1342,6 +1494,9 @@ export function NewOrderPage() {
precoUnitario: p.ultimoPreco, precoUnitario: p.ultimoPreco,
descontoPerc: 0, descontoPerc: 0,
loteMulVenda: null, loteMulVenda: null,
// TopProduto não traz grupo/subgrupo — teto considera só alçada default
codGrupo: null,
codSubgrupo: null,
}, },
]; ];
}); });
@@ -1369,6 +1524,13 @@ export function NewOrderPage() {
`Quantidade inválida: ${invalidos.map((it) => `${it.codProduto.trim()} (lote múltiplo: ${it.loteMulVenda})`).join(', ')}`, `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 = [ const obsCompleta = [
contato ? `Contato: ${contato}` : null, contato ? `Contato: ${contato}` : null,
@@ -1388,7 +1550,7 @@ export function NewOrderPage() {
descontoPerc: 0, descontoPerc: 0,
obs: obsCompleta || undefined, obs: obsCompleta || undefined,
endEntrega: endEntregaValue, endEntrega: endEntregaValue,
idempotencyKey: crypto.randomUUID(), idempotencyKey: editarId ? undefined : randomUUID(),
itens: cart.map((it, idx) => ({ itens: cart.map((it, idx) => ({
idProduto: it.idProduto, idProduto: it.idProduto,
codProduto: it.codProduto, codProduto: it.codProduto,
@@ -1400,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) { if (!navigator.onLine) {
await enqueueOrder(body, effectiveClient.nome); await enqueueOrder(body, effectiveClient.nome);
window.dispatchEvent(new CustomEvent('sar:order-queued')); window.dispatchEvent(new CustomEvent('sar:order-queued'));
@@ -1411,6 +1583,11 @@ export function NewOrderPage() {
onSuccess: (created) => { onSuccess: (created) => {
void qc.invalidateQueries({ queryKey: ['orders'] }); void qc.invalidateQueries({ queryKey: ['orders'] });
void qc.invalidateQueries({ queryKey: ['clients'] }); 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) { if (!created) {
setError(null); setError(null);
setCart([]); setCart([]);
@@ -1428,7 +1605,7 @@ export function NewOrderPage() {
} }
void navigate({ to: '/pedidos/$id', params: { id: created.id } }); 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 ( return (
@@ -1443,7 +1620,9 @@ export function NewOrderPage() {
}} }}
> >
<Title level={3} style={{ margin: 0, color: '#003B8E' }}> <Title level={3} style={{ margin: 0, color: '#003B8E' }}>
Lançamento de Pedido {editarId
? `Editar Orçamento${editOrder ? ` ${editOrder.numPedSar}` : ''}`
: 'Lançamento de Pedido'}
</Title> </Title>
<Button <Button
icon={<ArrowLeftOutlined />} icon={<ArrowLeftOutlined />}
@@ -1653,6 +1832,7 @@ export function NewOrderPage() {
<OrderItemsTable <OrderItemsTable
items={cart} items={cart}
tetoDe={tetoDe}
onQtyChange={setQty} onQtyChange={setQty}
onDiscChange={setDisc} onDiscChange={setDisc}
onRemove={removeItem} onRemove={removeItem}

View File

@@ -6,21 +6,18 @@ import {
Button, Button,
Descriptions, Descriptions,
Divider, Divider,
Form,
InputNumber,
Modal,
Space, Space,
Spin, Spin,
Table, Table,
Tag, Tag,
Timeline, Timeline,
Typography, Typography,
Input,
message, message,
} from 'antd'; } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faShareNodes } from '@fortawesome/free-solid-svg-icons'; import { faShareNodes } from '@fortawesome/free-solid-svg-icons';
import { FilePdfOutlined } from '@ant-design/icons'; import { FilePdfOutlined, CustomerServiceOutlined } from '@ant-design/icons';
import { AbrirChamadoModal } from './AbrirChamadoModal';
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
import { Link, useParams, useNavigate } from '@tanstack/react-router'; import { Link, useParams, useNavigate } from '@tanstack/react-router';
import type { PedidoItem, HistoricoPedido } from '@sar/api-interface'; import type { PedidoItem, HistoricoPedido } from '@sar/api-interface';
@@ -31,7 +28,6 @@ import { apiFetch } from '../../lib/api-client';
import { authStore } from '../../lib/auth-store'; import { authStore } from '../../lib/auth-store';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { TextArea } = Input;
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -118,7 +114,7 @@ function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
: SITUA_COLOR[h.situaNova] === 'error' : SITUA_COLOR[h.situaNova] === 'error'
? 'red' ? 'red'
: 'blue', : 'blue',
children: ( content: (
<div> <div>
<Text strong>{SITUA_LABEL[h.situaNova] ?? String(h.situaNova)}</Text> <Text strong>{SITUA_LABEL[h.situaNova] ?? String(h.situaNova)}</Text>
{h.situaAnterior != null && ( {h.situaAnterior != null && (
@@ -143,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 ────────────────────────────────────────────────────────── // ─── OrderDetailPage ──────────────────────────────────────────────────────────
export function OrderDetailPage() { export function OrderDetailPage() {
@@ -253,7 +150,6 @@ export function OrderDetailPage() {
const role = getRoleFromToken(); const role = getRoleFromToken();
const isErp = order?.fonte === 'erp'; const isErp = order?.fonte === 'erp';
const canAct = !isErp && role !== 'rep' && order?.situa === 1;
const canTransmit = !isErp && role === 'rep' && order?.situa === 0; const canTransmit = !isErp && role === 'rep' && order?.situa === 0;
const canShare = const canShare =
role === 'rep' && role === 'rep' &&
@@ -261,38 +157,9 @@ export function OrderDetailPage() {
typeof navigator !== 'undefined' && typeof navigator !== 'undefined' &&
!!navigator.share; !!navigator.share;
const [approveOpen, setApproveOpen] = useState(false); const [chamadoOpen, setChamadoOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(null); 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({ const transmitMutation = useMutation({
mutationFn: () => apiFetch(`/orders/${id}/transmit`, { method: 'PATCH' }), mutationFn: () => apiFetch(`/orders/${id}/transmit`, { method: 'PATCH' }),
onSuccess: () => { onSuccess: () => {
@@ -307,11 +174,6 @@ export function OrderDetailPage() {
if (error || !order) if (error || !order)
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />; 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. // Orçamento: tela mais larga para consulta/revisão com o cliente.
const isOrcamento = order.situa === 0; const isOrcamento = order.situa === 0;
@@ -337,9 +199,6 @@ export function OrderDetailPage() {
</Tag> </Tag>
} }
/> />
{timeWaiting !== null && timeWaiting > 2 && (
<Tag color="red">Urgente {timeWaiting}h aguardando</Tag>
)}
<Button <Button
icon={<FilePdfOutlined />} icon={<FilePdfOutlined />}
onClick={() => navigate({ to: '/pedidos/$id/imprimir', params: { id } })} onClick={() => navigate({ to: '/pedidos/$id/imprimir', params: { id } })}
@@ -359,16 +218,6 @@ export function OrderDetailPage() {
Transmitir pedido Transmitir pedido
</Button> </Button>
)} )}
{canAct && (
<Space>
<Button type="primary" onClick={() => setApproveOpen(true)}>
Aprovar
</Button>
<Button danger onClick={() => setRejectOpen(true)}>
Recusar
</Button>
</Space>
)}
{canShare && ( {canShare && (
<Button <Button
icon={<FontAwesomeIcon icon={faShareNodes} />} icon={<FontAwesomeIcon icon={faShareNodes} />}
@@ -383,6 +232,11 @@ export function OrderDetailPage() {
Compartilhar Compartilhar
</Button> </Button>
)} )}
{role === 'rep' && !isErp && (order.situa === 2 || order.situa === 4) && (
<Button icon={<CustomerServiceOutlined />} onClick={() => setChamadoOpen(true)}>
Abrir Chamado
</Button>
)}
</Space> </Space>
{actionError && ( {actionError && (
@@ -413,11 +267,6 @@ export function OrderDetailPage() {
<Descriptions.Item label="Data"> <Descriptions.Item label="Data">
{new Date(order.dtPedido).toLocaleDateString('pt-BR')} {new Date(order.dtPedido).toLocaleDateString('pt-BR')}
</Descriptions.Item> </Descriptions.Item>
{order.aprovadoEm && (
<Descriptions.Item label="Aprovado em">
{new Date(order.aprovadoEm).toLocaleString('pt-BR')} cód. {order.aprovadoPor}
</Descriptions.Item>
)}
{order.formaPagamento && ( {order.formaPagamento && (
<Descriptions.Item label="Cond. Pagamento" span={2}> <Descriptions.Item label="Cond. Pagamento" span={2}>
{order.formaPagamento} {order.formaPagamento}
@@ -447,7 +296,7 @@ export function OrderDetailPage() {
)} )}
</Descriptions> </Descriptions>
<Divider orientation="left">Itens ({order.itens.length})</Divider> <Divider titlePlacement="left">Itens ({order.itens.length})</Divider>
<Table<PedidoItem> <Table<PedidoItem>
rowKey="id" rowKey="id"
columns={itemColumns} columns={itemColumns}
@@ -459,7 +308,7 @@ export function OrderDetailPage() {
{clientOrders && clientOrders.length > 0 && ( {clientOrders && clientOrders.length > 0 && (
<> <>
<Divider orientation="left">Outros Pedidos do Cliente</Divider> <Divider titlePlacement="left">Outros Pedidos do Cliente</Divider>
<Table <Table
rowKey="id" rowKey="id"
size="small" size="small"
@@ -501,22 +350,20 @@ export function OrderDetailPage() {
</> </>
)} )}
<Divider orientation="left">Histórico do Pedido</Divider> <Divider titlePlacement="left">Histórico do Pedido</Divider>
<HistoryTimeline history={order.historico} /> <HistoryTimeline history={order.historico} />
<ApproveModal {order && (
open={approveOpen} <AbrirChamadoModal
originalDiscount={order.descontoPerc} open={chamadoOpen}
onConfirm={(descontoPerc, nota) => approveMutation.mutate({ descontoPerc, nota })} onClose={() => setChamadoOpen(false)}
onCancel={() => setApproveOpen(false)} idPedido={order.id}
loading={approveMutation.isPending} numPedSar={parseInt(order.numPedSar.replace(/\D/g, ''), 10) || undefined}
/> nomeCliente={order.razaoCliente ?? order.nomeCliente}
<RejectModal idCliente={order.idCliente}
open={rejectOpen} itens={order.itens}
onConfirm={(motivo) => rejectMutation.mutate(motivo)}
onCancel={() => setRejectOpen(false)}
loading={rejectMutation.isPending}
/> />
)}
</div> </div>
); );
} }

View File

@@ -145,60 +145,71 @@ export function OrderPrintPage() {
color: INK, color: INK,
}} }}
> >
{/* ── Cabeçalho: empresa matriz que fatura ───────────────────────── */} {/* ── Cabeçalho compacto: logo + empresa matriz + nº do pedido ────── */}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'flex-start', alignItems: 'center',
padding: '22px 28px', gap: 16,
borderTop: `5px solid ${BLUE}`, padding: '12px 28px',
background: 'linear-gradient(180deg,#F8FAFD 0%,#fff 100%)', borderTop: `4px solid ${BLUE}`,
borderBottom: `1px solid ${LINE}`, borderBottom: `2px solid ${BLUE}`,
}} }}
> >
<div style={{ maxWidth: 460 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 14, minWidth: 0 }}>
<div style={{ fontSize: 19, fontWeight: 800, color: BLUE, lineHeight: 1.1 }}> {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 ?? '...'} {empresa?.nomeFantasia ?? empresa?.razaoSocial ?? '...'}
</div> </div>
<div style={{ fontSize: 11, color: MUTED, marginTop: 2 }}>{empresa?.razaoSocial}</div> <div style={{ fontSize: 8.5, color: MUTED, marginTop: 2, lineHeight: 1.45 }}>
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6, lineHeight: 1.5 }}> {empresa?.razaoSocial && empresa.razaoSocial !== empresa.nomeFantasia && (
<>{empresa.razaoSocial} · </>
)}
{empresa?.cnpj && <>CNPJ {empresa.cnpj}</>} {empresa?.cnpj && <>CNPJ {empresa.cnpj}</>}
{empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}</>} {empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}</>}
{enderecoEmp && <div>{enderecoEmp}</div>} <br />
{cidadeEmp && <div>{cidadeEmp}</div>} {[enderecoEmp, cidadeEmp].filter(Boolean).join(' · ')}
{(empresa?.telefone || empresa?.email) && ( {(empresa?.telefone || empresa?.email) && (
<div> <>
<br />
{empresa?.telefone && <>Tel {phone(empresa.telefone)}</>} {empresa?.telefone && <>Tel {phone(empresa.telefone)}</>}
{empresa?.telefone && empresa?.email && <> · </>} {empresa?.telefone && empresa?.email && <> · </>}
{empresa?.email} {empresa?.email}
</div> </>
)} )}
</div> </div>
</div> </div>
<div style={{ textAlign: 'right' }}> </div>
<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 PEDIDO
</div> </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} {order.numPedSar}
</div> </div>
<div <div style={{ fontSize: 9.5, color: MUTED, marginTop: 3 }}>
<span
style={{ style={{
display: 'inline-block', display: 'inline-block',
marginTop: 6, padding: '1px 8px',
padding: '2px 10px',
borderRadius: 20, borderRadius: 20,
background: `${BLUE}12`, background: `${BLUE}12`,
color: BLUE, color: BLUE,
fontSize: 10.5,
fontWeight: 700, fontWeight: 700,
marginRight: 6,
}} }}
> >
{SITUA_LABEL[order.situa] ?? String(order.situa)} {SITUA_LABEL[order.situa] ?? String(order.situa)}
</div> </span>
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6 }}> {dateBR(order.dtPedido)}
Emissão: {dateBR(order.dtPedido)}
</div> </div>
</div> </div>
</div> </div>
@@ -370,7 +381,30 @@ export function OrderPrintPage() {
</div> </div>
{/* ── Totais ──────────────────────────────────────────────────────── */} {/* ── 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 }}> <div style={{ width: 300 }}>
<TotRow k="Total dos produtos" v={money(order.totalProdutos)} /> <TotRow k="Total dos produtos" v={money(order.totalProdutos)} />
{Number(order.totalIpi) > 0 && <TotRow k="IPI" v={money(order.totalIpi)} />} {Number(order.totalIpi) > 0 && <TotRow k="IPI" v={money(order.totalIpi)} />}

View File

@@ -116,7 +116,7 @@ function ErpConsultaModal({
footer={<Button onClick={onClose}>Fechar</Button>} footer={<Button onClick={onClose}>Fechar</Button>}
> >
{!row ? null : ( {!row ? null : (
<Space direction="vertical" size={20} style={{ width: '100%' }}> <Space orientation="vertical" size={20} style={{ width: '100%' }}>
{/* ── Cabeçalho ── */} {/* ── Cabeçalho ── */}
<Card <Card
styles={{ body: { padding: '14px 16px' } }} styles={{ body: { padding: '14px 16px' } }}
@@ -257,7 +257,7 @@ function ErpConsultaModal({
title: 'Produto', title: 'Produto',
key: 'produto', key: 'produto',
render: (_: unknown, item) => ( render: (_: unknown, item) => (
<Space direction="vertical" size={0}> <Space orientation="vertical" size={0}>
<Text style={{ fontSize: 11, color: '#94A3B8' }}>{item.codProduto}</Text> <Text style={{ fontSize: 11, color: '#94A3B8' }}>{item.codProduto}</Text>
<Text style={{ fontWeight: 500, fontSize: 13 }}>{item.descProduto}</Text> <Text style={{ fontWeight: 500, fontSize: 13 }}>{item.descProduto}</Text>
</Space> </Space>
@@ -276,7 +276,7 @@ function ErpConsultaModal({
width: 120, width: 120,
align: 'right' as const, align: 'right' as const,
render: (v: string, item) => ( render: (v: string, item) => (
<Space direction="vertical" size={0} style={{ alignItems: 'flex-end' }}> <Space orientation="vertical" size={0} style={{ alignItems: 'flex-end' }}>
<Text className="tabular-nums">{fmt(Number(v))}</Text> <Text className="tabular-nums">{fmt(Number(v))}</Text>
{Number(item.descontoPerc) > 0 && ( {Number(item.descontoPerc) > 0 && (
<Text style={{ fontSize: 11, color: '#f59e0b' }}> <Text style={{ fontSize: 11, color: '#f59e0b' }}>
@@ -364,7 +364,7 @@ export function OrdersErpPage() {
key: 'pedido', key: 'pedido',
width: 110, width: 110,
render: (_: unknown, row: PedidoErpConsultaItem) => ( render: (_: unknown, row: PedidoErpConsultaItem) => (
<Space direction="vertical" size={0}> <Space orientation="vertical" size={0}>
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}> <Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{row.numeroPedido} {row.numeroPedido}
</Text> </Text>
@@ -446,7 +446,7 @@ export function OrdersErpPage() {
render: (_: unknown, row: PedidoErpConsultaItem) => { render: (_: unknown, row: PedidoErpConsultaItem) => {
if (row.tipo !== 'E') return <Text type="secondary"></Text>; if (row.tipo !== 'E') return <Text type="secondary"></Text>;
return ( return (
<Space direction="vertical" size={2}> <Space orientation="vertical" size={2}>
{row.numeroEntrega && ( {row.numeroEntrega && (
<Text className="tabular-nums" style={{ fontSize: 12, color: '#475569' }}> <Text className="tabular-nums" style={{ fontSize: 12, color: '#475569' }}>
Entrega {row.numeroEntrega} Entrega {row.numeroEntrega}

View File

@@ -6,6 +6,8 @@ import {
Card, Card,
Col, Col,
DatePicker, DatePicker,
Form,
Input,
Modal, Modal,
Dropdown, Dropdown,
Grid, Grid,
@@ -22,14 +24,13 @@ import {
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
const { RangePicker } = DatePicker;
import { import {
CheckCircleOutlined, CheckCircleOutlined,
ClockCircleOutlined, ClockCircleOutlined,
CloseCircleOutlined, CloseCircleOutlined,
CopyOutlined, CopyOutlined,
DollarOutlined, DollarOutlined,
EditOutlined,
EllipsisOutlined, EllipsisOutlined,
EyeOutlined, EyeOutlined,
FilePdfOutlined, FilePdfOutlined,
@@ -46,6 +47,8 @@ import { usePendingOrders } from '../../lib/hooks/usePendingOrders';
import { removePendingOrder, retryPendingOrder } from '../../lib/offline/order-queue'; import { removePendingOrder, retryPendingOrder } from '../../lib/offline/order-queue';
import { apiFetch } from '../../lib/api-client'; import { apiFetch } from '../../lib/api-client';
const { RangePicker } = DatePicker;
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
@@ -139,13 +142,11 @@ const STATUS: Record<number, { label: string; color: string; rowBg: string; tagC
function useOrderStats() { function useOrderStats() {
const all = useOrderList({ limit: 1 }); const all = useOrderList({ limit: 1 });
const pendentes = useOrderList({ limit: 1, situa: 1 }); const transmitidos = useOrderList({ limit: 1, situa: 2 });
const aprovados = useOrderList({ limit: 1, situa: 2 });
const faturados = useOrderList({ limit: 1, situa: 4 }); const faturados = useOrderList({ limit: 1, situa: 4 });
return { return {
total: all.data?.total ?? 0, total: all.data?.total ?? 0,
pendentes: pendentes.data?.total ?? 0, transmitidos: transmitidos.data?.total ?? 0,
aprovados: aprovados.data?.total ?? 0,
faturados: faturados.data?.total ?? 0, faturados: faturados.data?.total ?? 0,
loaded: !!all.data, loaded: !!all.data,
}; };
@@ -164,19 +165,18 @@ function OrdersMetrics({ stats }: { stats: OrderStats }) {
color: '#003B8E', color: '#003B8E',
}, },
{ {
label: 'Ag. Aprovação', label: 'Transmitidos',
value: stats.pendentes, value: stats.transmitidos,
icon: <ClockCircleOutlined />, icon: <CheckCircleOutlined />,
color: '#d46b08', color: '#389e0d',
}, },
{ label: 'Aprovados', value: stats.aprovados, icon: <CheckCircleOutlined />, color: '#389e0d' },
{ label: 'Faturados', value: stats.faturados, icon: <DollarOutlined />, color: '#1d39c4' }, { label: 'Faturados', value: stats.faturados, icon: <DollarOutlined />, color: '#1d39c4' },
]; ];
return ( return (
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}> <Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{metrics.map((m) => ( {metrics.map((m) => (
<Col key={m.label} xs={12} sm={6}> <Col key={m.label} xs={12} sm={8}>
<Card <Card
style={{ style={{
borderRadius: 10, borderRadius: 10,
@@ -244,6 +244,14 @@ function OrderStatusBadge({ situa, descr }: { situa: number; descr?: string }) {
// ─── OrderActionsMenu ───────────────────────────────────────────────────────── // ─── OrderActionsMenu ─────────────────────────────────────────────────────────
const MOTIVOS_CANCELAMENTO = [
'Cliente desistiu',
'Preço',
'Prazo de entrega',
'Erro de lançamento',
'Outro',
];
function OrderActionsMenu({ function OrderActionsMenu({
order, order,
onView, onView,
@@ -253,18 +261,24 @@ function OrderActionsMenu({
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient(); 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({ 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: () => { onSuccess: () => {
void msg.success('Orçamento cancelado.'); void msg.success('Orçamento cancelado.');
setCancelOpen(false);
void qc.invalidateQueries({ queryKey: ['orders'] }); void qc.invalidateQueries({ queryKey: ['orders'] });
}, },
onError: (e: unknown) => void msg.error(e instanceof Error ? e.message : 'Erro ao cancelar'), 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 canCancel = order.situa === 0 && order.fonte !== 'erp';
const canEdit = canCancel;
const items: MenuProps['items'] = [ const items: MenuProps['items'] = [
{ {
@@ -273,6 +287,16 @@ function OrderActionsMenu({
label: 'Ver detalhes', label: 'Ver detalhes',
onClick: () => onView(order.id), 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', key: 'duplicate',
icon: <CopyOutlined style={{ color: '#0057D9' }} />, icon: <CopyOutlined style={{ color: '#0057D9' }} />,
@@ -305,22 +329,59 @@ function OrderActionsMenu({
disabled: !canCancel, disabled: !canCancel,
onClick: () => { onClick: () => {
if (!canCancel) return; if (!canCancel) return;
void modal.confirm({ cancelForm.resetFields();
title: 'Cancelar orçamento?', setCancelOpen(true);
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(),
});
}, },
}, },
]; ];
return ( return (
// 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"> <Dropdown menu={{ items }} trigger={['click']} placement="bottomRight">
<Button type="text" icon={<EllipsisOutlined />} size="small" /> <Button type="text" icon={<EllipsisOutlined />} size="small" />
</Dropdown> </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>
); );
} }
@@ -341,7 +402,7 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v
) : ( ) : (
<ClockCircleOutlined style={{ color: '#d46b08' }} /> <ClockCircleOutlined style={{ color: '#d46b08' }} />
), ),
children: ( content: (
<span style={{ fontSize: 13 }}> <span style={{ fontSize: 13 }}>
<Text type="secondary">{new Date(h.changedAt).toLocaleString('pt-BR')}</Text> <Text type="secondary">{new Date(h.changedAt).toLocaleString('pt-BR')}</Text>
{' — '} {' — '}
@@ -667,7 +728,7 @@ export function OrdersPage() {
limit, limit,
}); });
const rows = data?.data ?? []; const rows = useMemo(() => data?.data ?? [], [data]);
const total = data?.total ?? 0; const total = data?.total ?? 0;
const hasFilters = !!query || !!situaFilter || !!period || !!range; const hasFilters = !!query || !!situaFilter || !!period || !!range;
@@ -801,7 +862,7 @@ export function OrdersPage() {
Pedidos Pedidos
</Title> </Title>
<p style={{ margin: '4px 0 0', color: '#64748B', fontSize: 14 }}> <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> </p>
</div> </div>
{!isMobile && ( {!isMobile && (
@@ -885,7 +946,6 @@ export function OrdersPage() {
}} }}
options={[ options={[
{ value: 0, label: 'Orçamento' }, { value: 0, label: 'Orçamento' },
{ value: 1, label: 'Ag. Aprovação' },
{ value: 2, label: 'Transmitido' }, { value: 2, label: 'Transmitido' },
{ value: 3, label: 'Cancelado' }, { value: 3, label: 'Cancelado' },
{ value: 4, label: 'Faturado' }, { value: 4, label: 'Faturado' },

View File

@@ -1,7 +1,14 @@
import { Modal, Tag, Skeleton, Typography, Row, Col, Statistic, Divider } from 'antd'; import { Modal, Tag, Skeleton, Typography, Row, Col, Statistic, Divider } from 'antd';
import { BarcodeOutlined, InboxOutlined, TagsOutlined } from '@ant-design/icons'; import {
BarcodeOutlined,
InboxOutlined,
PercentageOutlined,
TagsOutlined,
} from '@ant-design/icons';
import type { PautaPreco } from '@sar/api-interface'; import type { PautaPreco } from '@sar/api-interface';
import { useProdutoDetail } from '../../lib/queries/catalog'; import { useProdutoDetail } from '../../lib/queries/catalog';
import { useCondicoesComerciais, type CondicaoComercial } from '../../lib/condicoes-comerciais';
import { CondicaoTag } from '../../components/CondicaoTag';
const { Text, Title } = Typography; const { Text, Title } = Typography;
@@ -168,6 +175,56 @@ function PautaPrecoList({ items }: { items: PautaPreco[] }) {
); );
} }
// ─── Condições comerciais vigentes ────────────────────────────────────────────
function fmtData(iso: string): string {
const [y, m, d] = iso.split('-');
return `${d}/${m}/${y}`;
}
function CondicoesList({ conds }: { conds: CondicaoComercial[] }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{conds.map((c, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '10px 14px',
borderRadius: 8,
background: c.tipo === 'promocao' ? '#FFFBE6' : '#F0F5FF',
border: `1px solid ${c.tipo === 'promocao' ? '#FFE58F' : '#ADC6FF'}`,
}}
>
<div style={{ minWidth: 0 }}>
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', lineHeight: 1.2 }}>
{c.tipo === 'promocao' ? 'Promoção' : 'Condição especial'} · válida até{' '}
{fmtData(c.dataFim)}
</Text>
<Text strong style={{ fontSize: 13, color: '#1F2937' }}>
{c.descricao}
</Text>
</div>
<Text
strong
style={{
fontSize: 16,
color: c.tipo === 'promocao' ? '#D48806' : '#2F54EB',
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>
{c.descPct.toLocaleString('pt-BR')}% desc.
</Text>
</div>
))}
</div>
);
}
// ─── Modal principal ────────────────────────────────────────────────────────── // ─── Modal principal ──────────────────────────────────────────────────────────
interface Props { interface Props {
@@ -177,6 +234,8 @@ interface Props {
export function ProductDetailDrawer({ idErp, onClose }: Props) { export function ProductDetailDrawer({ idErp, onClose }: Props) {
const { data, isLoading } = useProdutoDetail(idErp ?? undefined); const { data, isLoading } = useProdutoDetail(idErp ?? undefined);
const condicoesDe = useCondicoesComerciais();
const conds = data ? condicoesDe(data) : [];
return ( return (
<Modal <Modal
@@ -227,6 +286,7 @@ export function ProductDetailDrawer({ idErp, onClose }: Props) {
justifyContent: 'flex-end', justifyContent: 'flex-end',
}} }}
> >
<CondicaoTag conds={conds} />
{data.marca && <Tag color="blue">{data.marca.trim()}</Tag>} {data.marca && <Tag color="blue">{data.marca.trim()}</Tag>}
{data.unidade && <Tag>{data.unidade}</Tag>} {data.unidade && <Tag>{data.unidade}</Tag>}
<Tag color={data.ativo ? 'green' : 'red'}>{data.ativo ? 'Ativo' : 'Inativo'}</Tag> <Tag color={data.ativo ? 'green' : 'red'}>{data.ativo ? 'Ativo' : 'Inativo'}</Tag>
@@ -239,6 +299,13 @@ export function ProductDetailDrawer({ idErp, onClose }: Props) {
<Row gutter={40}> <Row gutter={40}>
{/* Coluna esquerda */} {/* Coluna esquerda */}
<Col xs={24} md={14}> <Col xs={24} md={14}>
{/* Condições comerciais vigentes */}
{conds.length > 0 && (
<Section icon={<PercentageOutlined />} title="Condições Comerciais">
<CondicoesList conds={conds} />
</Section>
)}
{/* Classificação */} {/* Classificação */}
<Section icon={<TagsOutlined />} title="Classificação"> <Section icon={<TagsOutlined />} title="Classificação">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}>

View File

@@ -1,7 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Alert, Alert,
Badge,
Card, Card,
Col, Col,
DatePicker, DatePicker,
@@ -17,10 +16,10 @@ import {
Typography, Typography,
} from 'antd'; } from 'antd';
import type { TableColumnsType } 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 dayjs from 'dayjs';
import type { CarteiraCliente, AbcProduto } from '@sar/api-interface'; import type { AbcProduto } from '@sar/api-interface';
import { useReportMeta, useReportCarteira, useReportAbc } from '../../lib/queries/reports'; import { useReportMeta, useReportAbc } from '../../lib/queries/reports';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { Option } = Select; const { Option } = Select;
@@ -29,11 +28,6 @@ function fmt(v: number | string): string {
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); 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 ─────────────────────────────────────────────── // ─── Aba 1: Desempenho vs. Meta ───────────────────────────────────────────────
function TabMeta() { function TabMeta() {
@@ -196,177 +190,7 @@ function TabMeta() {
); );
} }
// ─── Aba 2: Carteira de Clientes ───────────────────────────────────────────── // ─── Aba 2: Curva ABC ─────────────────────────────────────────────────────────
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 ─────────────────────────────────────────────────────────
const CURVA_COLOR: Record<string, string> = { A: '#003B8E', B: '#f59e0b', C: '#94A3B8' }; const CURVA_COLOR: Record<string, string> = { A: '#003B8E', B: '#f59e0b', C: '#94A3B8' };
@@ -550,16 +374,6 @@ export function RelatoriosPage() {
), ),
children: <TabMeta />, children: <TabMeta />,
}, },
{
key: 'carteira',
label: (
<Space>
<TeamOutlined />
Carteira de Clientes
</Space>
),
children: <TabCarteira />,
},
{ {
key: 'abc', key: 'abc',
label: ( label: (
@@ -579,7 +393,7 @@ export function RelatoriosPage() {
Relatórios Relatórios
</Title> </Title>
<Text type="secondary" style={{ fontSize: 14 }}> <Text type="secondary" style={{ fontSize: 14 }}>
Análise de desempenho, carteira e mix de produtos. Análise de desempenho e mix de produtos.
</Text> </Text>
</div> </div>

View File

@@ -37,6 +37,9 @@ import {
import { Chart } from 'react-chartjs-2'; import { Chart } from 'react-chartjs-2';
import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar/api-interface'; import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface'; 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( ChartJS.register(
CategoryScale, CategoryScale,
@@ -47,8 +50,6 @@ ChartJS.register(
ChartTooltip, ChartTooltip,
Legend, Legend,
); );
import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -553,6 +554,9 @@ export function RepPainel() {
</Col> </Col>
</Row> </Row>
{/* Condições especiais vigentes hoje (promoções + descontos liberados) */}
<CondicoesEspeciaisCard />
{/* Metas por Grupo */} {/* Metas por Grupo */}
{metasPorGrupo.length > 0 && ( {metasPorGrupo.length > 0 && (
<Card <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 type { TableColumnsType } from 'antd';
import { WhatsAppOutlined } from '@ant-design/icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faCheckCircle, faAddressCard,
faCircleExclamation, faCircleExclamation,
faClipboardList, faClipboardList,
faRankingStar,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface'; import type {
import { useSupervisorDashboard } from '../../lib/queries/dashboard'; ClienteNaoPositivado,
PositivacaoRep,
RankingRep,
RepInativosSummary,
} from '@sar/api-interface';
import { useRepInativos, useSupervisorDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth'; import { useCurrentUser } from '../../lib/queries/auth';
import {
positivacaoRepColumns,
rankingRepColumns,
} from '../../components/dashboard/rep-performance-columns';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -17,10 +43,6 @@ function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); 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 { function delta(current: number, previous: number): { label: string; positive: boolean } | null {
if (previous === 0) return null; if (previous === 0) return null;
const pct = Math.round(((current - previous) / previous) * 100); 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' }); return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
} }
const queueColumns: TableColumnsType<PedidoSummary> = [ function mesLabel(): string {
{ return new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
title: 'Pedido', }
dataIndex: 'numPedSar',
width: 120, function waLink(whatsapp: string): string {
render: (num: string, row: PedidoSummary) => ( const digits = whatsapp.replace(/\D/g, '');
<Link to="/pedidos/$id" params={{ id: row.id }}> const number = digits.startsWith('55') ? digits : `55${digits}`;
{num} return `https://wa.me/${number}`;
</Link> }
),
}, // ─── Modal de clientes inativos de um rep ─────────────────────────────────────
{
title: 'Representante', const inativosColumns: TableColumnsType<ClienteNaoPositivado> = [
key: 'rep',
width: 150,
render: (_: unknown, row: PedidoSummary) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{ {
title: 'Cliente', title: 'Cliente',
key: 'cliente', key: 'cliente',
width: 180, render: (_: unknown, c: ClienteNaoPositivado) => (
render: (_: unknown, row: PedidoSummary) => <Space orientation="vertical" size={0}>
row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`, <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', title: 'Última compra',
dataIndex: 'total', dataIndex: 'ultimoPedido',
width: 130, width: 140,
align: 'right', render: (v: string | null) =>
render: (v: string) => fmt(Number(v)), v ? new Date(`${v}T00:00:00`).toLocaleDateString('pt-BR') : <Tag>Nunca comprou</Tag>,
}, },
{ {
title: 'Aguardando', title: 'Sem compra',
dataIndex: 'createdAt', dataIndex: 'diasSemPedido',
width: 120, width: 120,
render: (v: string) => { render: (d: number, c: ClienteNaoPositivado) =>
const h = hoursWaiting(v); c.comprouAntes ? (
return <Tag color={h > 2 ? 'red' : 'orange'}>{h}h</Tag>; <Tag color={d > 90 ? 'red' : 'orange'} className="tabular-nums">
}, {d} dias
</Tag>
) : (
'—'
),
}, },
{ {
title: '', title: '',
width: 90, key: 'acoes',
render: (_: unknown, row: PedidoSummary) => ( width: 120,
<Link to="/pedidos/$id" params={{ id: row.id }}> render: (_: unknown, c: ClienteNaoPositivado) => (
<Tag color="blue" style={{ cursor: 'pointer' }}> <Space>
Analisar <Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
</Tag> <Button size="small">Ficha</Button>
</Link> </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() { export function SupervisorPainel() {
const { data, isLoading } = useSupervisorDashboard(); const { data, isLoading } = useSupervisorDashboard();
const { data: user } = useCurrentUser(); const { data: user } = useCurrentUser();
const [inativosRep, setInativosRep] = useState<RepInativosSummary | null>(null);
if (isLoading || !data) { if (isLoading || !data) {
return ( return (
@@ -110,8 +186,7 @@ export function SupervisorPainel() {
); );
} }
const { approvalQueue, pedidosDia, inativosPorRep, syncedAt } = data; const { pedidosDia, inativosPorRep, equipeMes, rankingReps, positivacaoReps, syncedAt } = data;
const urgentCount = approvalQueue.filter((o) => hoursWaiting(o.createdAt) > 2).length;
const countDelta = delta(pedidosDia.count, pedidosDia.countSemanaAnterior); const countDelta = delta(pedidosDia.count, pedidosDia.countSemanaAnterior);
const totalDelta = delta(pedidosDia.total, pedidosDia.totalSemanaAnterior); const totalDelta = delta(pedidosDia.total, pedidosDia.totalSemanaAnterior);
@@ -124,49 +199,12 @@ export function SupervisorPainel() {
</Title> </Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
{today()} {today()}
{urgentCount > 0 && (
<>
{' '}
·{' '}
<span style={{ color: '#cf1322' }}>
{urgentCount} aprovação{urgentCount > 1 ? 'ões' : ''} urgente
{urgentCount > 1 ? 's' : ''}
</span>
</>
)}
</Text> </Text>
</Flex> </Flex>
{/* KPIs */} {/* KPIs */}
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
<Col xs={24} md={8}> <Col xs={24} md={12}>
<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}>
<Card> <Card>
<Space orientation="vertical" size={4}> <Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
@@ -189,7 +227,7 @@ export function SupervisorPainel() {
</Card> </Card>
</Col> </Col>
<Col xs={24} md={8}> <Col xs={24} md={12}>
<Card> <Card>
<Space orientation="vertical" size={4}> <Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
@@ -213,38 +251,9 @@ export function SupervisorPainel() {
</Col> </Col>
</Row> </Row>
{/* Fila de aprovações + Inativos por rep */} {/* Inativos por rep + Pedidos de hoje */}
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
<Col xs={24} lg={16}> <Col xs={24} lg={12}>
<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}>
<Card <Card
title={ title={
<Space> <Space>
@@ -281,20 +290,26 @@ export function SupervisorPainel() {
</Text> </Text>
)} )}
</Space> </Space>
<Space>
<Tag <Tag
color={r.inativosCount >= 3 ? 'orange' : 'default'} color={r.inativosCount >= 3 ? 'orange' : 'default'}
className="tabular-nums" className="tabular-nums"
> >
{r.inativosCount} cliente{r.inativosCount > 1 ? 's' : ''} {r.inativosCount} cliente{r.inativosCount > 1 ? 's' : ''}
</Tag> </Tag>
<Button size="small" onClick={() => setInativosRep(r)}>
Detalhar
</Button>
</Space>
</Flex> </Flex>
))} ))}
</Flex> </Flex>
)} )}
</Card> </Card>
</Col>
<Col xs={24} lg={12}>
<Card <Card
style={{ marginTop: 24 }}
title={ title={
<Space> <Space>
<FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} /> <FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} />
@@ -328,6 +343,130 @@ export function SupervisorPainel() {
</Col> </Col>
</Row> </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 }}> <Flex justify="space-between" style={{ paddingTop: 8 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
SAR · Força de Vendas · Powered by JCS Sistemas 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 Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')} · atualiza a cada 30s
</Text> </Text>
</Flex> </Flex>
<InativosRepModal rep={inativosRep} onClose={() => setInativosRep(null)} />
</Flex> </Flex>
); );
} }

View File

@@ -0,0 +1,27 @@
import { Tag } from 'antd';
import type { CondicaoComercial } from '../lib/condicoes-comerciais';
// Selo que sinaliza produto com proposta comercial diferenciada (promoção do
// gerente ou regra de desconto vigente). O detalhamento fica no modal do produto.
export function CondicaoTag({ conds, compact }: { conds: CondicaoComercial[]; compact?: boolean }) {
if (conds.length === 0) return null;
const temPromo = conds.some((c) => c.tipo === 'promocao');
const temRegra = conds.some((c) => c.tipo === 'regra');
const style = compact
? { fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '16px' }
: { fontSize: 11, margin: 0 };
return (
<>
{temPromo && (
<Tag color="gold" style={style}>
Promoção
</Tag>
)}
{temRegra && (
<Tag color="geekblue" style={style}>
Cond. especial
</Tag>
)}
</>
);
}

View File

@@ -94,7 +94,12 @@ export function ClientContacts({ idCliente, modalOpen = false, onModalClose }: P
anotacoes: values.anotacoes || undefined, anotacoes: values.anotacoes || undefined,
}; };
const result = await createMutation.mutateAsync(payload); let result: Awaited<ReturnType<typeof createMutation.mutateAsync>>;
try {
result = await createMutation.mutateAsync(payload);
} catch {
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
}
if (!result.sincronizado && result.erroSync) { if (!result.sincronizado && result.erroSync) {
void message.warning(`Contato salvo, mas não sincronizou com ERP: ${result.erroSync}`); void message.warning(`Contato salvo, mas não sincronizou com ERP: ${result.erroSync}`);

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

@@ -9,12 +9,14 @@ import { AuthTokenResponseSchema } from '@sar/api-interface';
type DevUser = { key: string; userId: string; role: string; label: string }; type DevUser = { key: string; userId: string; role: string; label: string };
// userId = cod_vendedor como string; idEmpresa = empresa no ERP (dev default = 1) // userId = cod_vendedor como string; idEmpresa fica a cargo do backend (DEV_EMPRESA_ID).
// Em dev, o backend força DEV_REP_CODE=29 independente do userId enviado. // Usuários provisórios para testar as telas por papel enquanto o cadastro de
// usuários não existe: Pavei (carteira própria), Sidnei (equipe via
// vw_representantes.cod_supervisor = 191), Lucas (empresa toda).
const DEV_USERS: DevUser[] = [ const DEV_USERS: DevUser[] = [
{ key: 'rep-29', userId: '29', role: 'rep', label: 'Representante (cód. 29)' }, { key: 'rep-29', userId: '29', role: 'rep', label: 'Pavei — Representante (cód. 29)' },
{ key: 'sup-29', userId: '29', role: 'supervisor', label: 'Supervisor (cód. 29)' }, { key: 'sup-191', userId: '191', role: 'supervisor', label: 'Sidnei — Supervisor (cód. 191)' },
{ key: 'mgr-29', userId: '29', role: 'manager', label: 'Gerente (cód. 29)' }, { key: 'mgr-156', userId: '156', role: 'manager', label: 'Lucas — Gerente/Admin (cód. 156)' },
]; ];
export function DevLogin({ onLogin }: { onLogin: () => void }) { export function DevLogin({ onLogin }: { onLogin: () => void }) {
@@ -27,7 +29,7 @@ export function DevLogin({ onLogin }: { onLogin: () => void }) {
try { try {
const raw = await apiFetch('/auth/dev/token', { const raw = await apiFetch('/auth/dev/token', {
method: 'POST', method: 'POST',
body: { userId: user.userId, idEmpresa: 1, role: user.role }, body: { userId: user.userId, role: user.role },
}); });
const { accessToken } = AuthTokenResponseSchema.parse(raw); const { accessToken } = AuthTokenResponseSchema.parse(raw);
authStore.set(accessToken); authStore.set(accessToken);

View File

@@ -1,5 +1,5 @@
import { type ReactNode } from 'react'; import { useEffect, type ReactNode } from 'react';
import { Alert, Button, Flex, Grid, Tooltip } from 'antd'; import { Alert, App, Button, Flex, Grid, Tooltip } from 'antd';
import { PlusOutlined, WifiOutlined } from '@ant-design/icons'; import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { Topbar } from './Topbar'; import { Topbar } from './Topbar';
@@ -7,6 +7,7 @@ import { Sidebar } from './Sidebar';
import { BottomNav } from './BottomNav'; import { BottomNav } from './BottomNav';
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus'; import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
import { useOfflineSync } from '../../lib/hooks/useOfflineSync'; import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
import { registerMessageApi } from '../../lib/feedback';
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
@@ -18,10 +19,16 @@ export function AppShell({ children }: AppShellProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const isOnline = useNetworkStatus(); const isOnline = useNetworkStatus();
const screens = useBreakpoint(); const screens = useBreakpoint();
const { message } = App.useApp();
// sidebar a partir de lg (992px) — abaixo disso usa bottom nav // sidebar a partir de lg (992px) — abaixo disso usa bottom nav
const isDesktop = !!screens.lg; const isDesktop = !!screens.lg;
useOfflineSync(); useOfflineSync();
// Disponibiliza o message (com tema) para os caches do TanStack Query
useEffect(() => {
registerMessageApi(message);
}, [message]);
return ( return (
<Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}> <Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}>
{!isOnline && ( {!isOnline && (

View File

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

View File

@@ -11,6 +11,8 @@ import {
faBoxesStacked, faBoxesStacked,
faFileInvoiceDollar, faFileInvoiceDollar,
faTags, faTags,
faHeadset,
faGear,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import type { ItemType } from 'antd/es/menu/interface'; import type { ItemType } from 'antd/es/menu/interface';
import { authStore } from '../../lib/auth-store'; import { authStore } from '../../lib/auth-store';
@@ -28,17 +30,17 @@ function getRole(): string {
const REP_ITEMS: ItemType[] = [ const REP_ITEMS: ItemType[] = [
{ key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' }, { key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
{
key: '/funil',
icon: <FontAwesomeIcon icon={faFunnelDollar} fixedWidth />,
label: 'Funil de Vendas',
},
{ key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' }, { key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
{ {
key: '/catalogo', key: '/catalogo',
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />, icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
label: 'Catálogo', label: 'Catálogo',
}, },
{
key: '/funil',
icon: <FontAwesomeIcon icon={faFunnelDollar} fixedWidth />,
label: 'Funil de Vendas',
},
{ {
key: '/pedidos', key: '/pedidos',
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />, icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
@@ -49,6 +51,7 @@ const REP_ITEMS: ItemType[] = [
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />, icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
label: 'Consulta ERP', label: 'Consulta ERP',
}, },
{ key: '/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
{ {
key: '/relatorios', key: '/relatorios',
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />, icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
@@ -58,11 +61,6 @@ const REP_ITEMS: ItemType[] = [
const SUPERVISOR_ITEMS: ItemType[] = [ const SUPERVISOR_ITEMS: ItemType[] = [
{ key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' }, { 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: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
{ {
key: '/pedidos', key: '/pedidos',
@@ -89,6 +87,11 @@ const GERENTE_ITEMS: ItemType[] = [
icon: <FontAwesomeIcon icon={faAddressCard} fixedWidth />, icon: <FontAwesomeIcon icon={faAddressCard} fixedWidth />,
label: 'Clientes', label: 'Clientes',
}, },
{
key: '/catalogo',
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
label: 'Catálogo',
},
{ {
key: '/relatorios', key: '/relatorios',
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />, icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
@@ -99,6 +102,12 @@ const GERENTE_ITEMS: ItemType[] = [
icon: <FontAwesomeIcon icon={faTags} fixedWidth />, icon: <FontAwesomeIcon icon={faTags} fixedWidth />,
label: 'Políticas Comerciais', 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[] { function getItems(role: string): ItemType[] {
@@ -113,10 +122,12 @@ export function Sidebar() {
const role = getRole(); const role = getRole();
const items = getItems(role); const items = getItems(role);
const selectedKey = items.find((item) => { const selectedKey = items.reduce<string | undefined>((best, item) => {
const key = (item as { key?: string }).key ?? ''; const key = (item as { key?: string }).key ?? '';
return key === '/' ? location.pathname === '/' : location.pathname.startsWith(key); const matches = key === '/' ? location.pathname === '/' : location.pathname.startsWith(key);
})?.key as string | undefined; if (!matches) return best;
return !best || key.length > best.length ? key : best;
}, undefined);
return ( return (
<aside <aside

View File

@@ -0,0 +1,78 @@
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
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
// catálogo do rep que o produto tem proposta diferenciada e detalhar qual é.
export interface CondicaoComercial {
tipo: 'promocao' | 'regra';
descricao: string;
descPct: number;
dataFim: string;
}
export interface ProdutoRef {
codigo: string;
codGrupo: number | null;
codSubgrupo: number | null;
}
export function condicoesDoProduto(
p: ProdutoRef,
promocoes: PromocaoVigente[] | undefined,
regras: RegraVigente[] | undefined,
): CondicaoComercial[] {
const conds: CondicaoComercial[] = [];
for (const promo of promocoes ?? []) {
const porProduto = promo.codProduto && promo.codProduto.trim() === p.codigo.trim();
const porGrupo =
promo.grpProd && p.codGrupo != null && promo.grpProd.trim() === String(p.codGrupo);
if (porProduto || porGrupo) {
conds.push({
tipo: 'promocao',
descricao: promo.descricao,
descPct: promo.descPct,
dataFim: promo.dataFim,
});
}
}
for (const regra of regras ?? []) {
const grupoOk = regra.codGrupo == null || (p.codGrupo != null && regra.codGrupo === p.codGrupo);
const subgrupoOk =
regra.codSubgrupo == null || (p.codSubgrupo != null && regra.codSubgrupo === p.codSubgrupo);
if (grupoOk && subgrupoOk) {
conds.push({
tipo: 'regra',
descricao: regra.descricao,
descPct: regra.descPct,
dataFim: regra.dataFim,
});
}
}
return conds.sort((a, b) => b.descPct - a.descPct);
}
// Hook: função de lookup por produto, com promoções e regras vigentes em cache
export function useCondicoesComerciais(): (p: ProdutoRef) => CondicaoComercial[] {
const { data: promos } = usePromocoesVigentes();
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

@@ -0,0 +1,15 @@
import type { MessageInstance } from 'antd/es/message/interface';
// Instância do message obtida via App.useApp() (herda tema/contexto), registrada
// pelo AppShell no mount. Fora do React (ex.: caches do TanStack Query) usamos
// esta referência em vez do message estático do AntD.
let messageApi: MessageInstance | null = null;
export function registerMessageApi(api: MessageInstance): void {
messageApi = api;
}
// key fixa colapsa erros repetidos (várias queries falhando juntas) num toast só.
export function notifyError(content: string, key?: string): void {
messageApi?.error({ content, key, duration: 5 });
}

View File

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

View File

@@ -1,8 +1,10 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
RepDashboardSchema, RepDashboardSchema,
RepInativosDetailSchema,
SupervisorDashboardSchema, SupervisorDashboardSchema,
type RepDashboard, type RepDashboard,
type RepInativosDetail,
type SupervisorDashboard, type SupervisorDashboard,
} from '@sar/api-interface'; } from '@sar/api-interface';
import { apiFetch } from '../api-client'; import { apiFetch } from '../api-client';
@@ -29,3 +31,30 @@ export function useSupervisorDashboard() {
refetchInterval: 30 * 1000, 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

@@ -0,0 +1,44 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
FunilResponseSchema,
OportunidadeSchema,
type CreateOportunidadeDto,
type UpdateOportunidadeDto,
type Oportunidade,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
export function useFunil() {
return useQuery({
queryKey: ['funil'],
queryFn: async () => FunilResponseSchema.parse(await apiFetch('/funil')),
});
}
export function useCreateOportunidade() {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: CreateOportunidadeDto): Promise<Oportunidade> =>
apiFetch('/funil', { method: 'POST', body: dto }).then((r) => OportunidadeSchema.parse(r)),
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
});
}
export function useUpdateOportunidade() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: number; dto: UpdateOportunidadeDto }): Promise<Oportunidade> =>
apiFetch(`/funil/${id}`, { method: 'PATCH', body: dto }).then((r) =>
OportunidadeSchema.parse(r),
),
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
});
}
export function useDeleteOportunidade() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number): Promise<void> => apiFetch(`/funil/${id}`, { method: 'DELETE' }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
});
}

View File

@@ -2,15 +2,20 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
ManagerDashboardSchema, ManagerDashboardSchema,
EquipeResponseSchema, EquipeResponseSchema,
AlcadaDescontosResponseSchema, SupervisoresResponseSchema,
PromocoesResponseSchema, PromocoesResponseSchema,
RegrasDescontoResponseSchema,
GruposProdutoResponseSchema,
type ManagerDashboard, type ManagerDashboard,
type EquipeResponse, type EquipeResponse,
type AlcadaDescontosResponse, type SupervisoresResponse,
type PromocoesResponse, type PromocoesResponse,
type UpsertDescontoBody,
type CreatePromocaoBody, type CreatePromocaoBody,
type UpdatePromocaoBody, type UpdatePromocaoBody,
type RegrasDescontoResponse,
type GruposProdutoResponse,
type CreateRegraDescontoBody,
type UpdateRegraDescontoBody,
} from '@sar/api-interface'; } from '@sar/api-interface';
import { apiFetch } from '../api-client'; import { apiFetch } from '../api-client';
@@ -41,14 +46,14 @@ export function useEquipe() {
}); });
} }
export function usePoliticasDescontos() { export function useSupervisores() {
return useQuery<AlcadaDescontosResponse>({ return useQuery<SupervisoresResponse>({
queryKey: ['politicas', 'descontos'], queryKey: ['equipe', 'supervisores'],
queryFn: async () => { queryFn: async () => {
const raw = await apiFetch('/politicas/descontos'); const raw = await apiFetch('/equipe/supervisores');
return AlcadaDescontosResponseSchema.parse(raw); return SupervisoresResponseSchema.parse(raw);
}, },
staleTime: 10 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
} }
@@ -63,20 +68,11 @@ export function usePromocoes() {
}); });
} }
export function useUpsertDesconto() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: UpsertDescontoBody) =>
apiFetch('/politicas/descontos', { method: 'POST', body: JSON.stringify(body) }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }),
});
}
export function useCreatePromocao() { export function useCreatePromocao() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (body: CreatePromocaoBody) => mutationFn: (body: CreatePromocaoBody) =>
apiFetch('/politicas/promocoes', { method: 'POST', body: JSON.stringify(body) }), apiFetch('/politicas/promocoes', { method: 'POST', body }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] }); qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] }); qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
@@ -88,7 +84,7 @@ export function useUpdatePromocao() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, body }: { id: number; body: UpdatePromocaoBody }) => mutationFn: ({ id, body }: { id: number; body: UpdatePromocaoBody }) =>
apiFetch(`/politicas/promocoes/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), apiFetch(`/politicas/promocoes/${id}`, { method: 'PATCH', body }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] }); qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] }); qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
@@ -106,3 +102,60 @@ export function useDeletePromocao() {
}, },
}); });
} }
export function useRegrasDesconto() {
return useQuery<RegrasDescontoResponse>({
queryKey: ['politicas', 'regras'],
queryFn: async () => {
const raw = await apiFetch('/politicas/regras');
return RegrasDescontoResponseSchema.parse(raw);
},
staleTime: 5 * 60 * 1000,
});
}
export function useGruposProduto() {
return useQuery<GruposProdutoResponse>({
queryKey: ['politicas', 'grupos-produto'],
queryFn: async () => {
const raw = await apiFetch('/politicas/grupos-produto');
return GruposProdutoResponseSchema.parse(raw);
},
staleTime: 30 * 60 * 1000,
});
}
export function useCreateRegraDesconto() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: CreateRegraDescontoBody) =>
apiFetch('/politicas/regras', { method: 'POST', body }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['politicas', 'regras'] });
qc.invalidateQueries({ queryKey: ['politicas', 'regras-vigentes'] });
},
});
}
export function useUpdateRegraDesconto() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: UpdateRegraDescontoBody }) =>
apiFetch(`/politicas/regras/${id}`, { method: 'PATCH', body }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['politicas', 'regras'] });
qc.invalidateQueries({ queryKey: ['politicas', 'regras-vigentes'] });
},
});
}
export function useDeleteRegraDesconto() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiFetch(`/politicas/regras/${id}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['politicas', 'regras'] });
qc.invalidateQueries({ queryKey: ['politicas', 'regras-vigentes'] });
},
});
}

View File

@@ -59,7 +59,7 @@ export function useClientOrders(idCliente: number | undefined) {
}); });
} }
export function useOrderErpConsulta(params: Partial<PedidoErpConsultaQuery> = {}) { export function useOrderErpConsulta(params: Partial<PedidoErpConsultaQuery> = {}, enabled = true) {
const search = new URLSearchParams(); const search = new URLSearchParams();
if (params.search) search.set('search', params.search); if (params.search) search.set('search', params.search);
if (params.from) search.set('from', params.from); if (params.from) search.set('from', params.from);
@@ -70,6 +70,7 @@ export function useOrderErpConsulta(params: Partial<PedidoErpConsultaQuery> = {}
const qs = search.toString(); const qs = search.toString();
return useQuery<PedidoErpConsultaResponse>({ return useQuery<PedidoErpConsultaResponse>({
queryKey: ['orders-erp-consulta', params], queryKey: ['orders-erp-consulta', params],
enabled,
queryFn: async () => { queryFn: async () => {
const res = await apiFetch(`/orders/erp-consulta${qs ? `?${qs}` : ''}`); const res = await apiFetch(`/orders/erp-consulta${qs ? `?${qs}` : ''}`);
return PedidoErpConsultaResponseSchema.parse(res); return PedidoErpConsultaResponseSchema.parse(res);

View File

@@ -0,0 +1,51 @@
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'],
queryFn: async () => {
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.
// 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'],
queryFn: async () => {
const raw = await apiFetch('/politicas/promocoes/vigentes');
return PromocoesVigentesResponseSchema.parse(raw);
},
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
});
}

View File

@@ -1,12 +1,10 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
ReportMetaResponseSchema, ReportMetaResponseSchema,
ReportCarteiraResponseSchema,
ReportAbcResponseSchema, ReportAbcResponseSchema,
type ReportMetaQuery, type ReportMetaQuery,
type ReportAbcQuery, type ReportAbcQuery,
type ReportMetaResponse, type ReportMetaResponse,
type ReportCarteiraResponse,
type ReportAbcResponse, type ReportAbcResponse,
} from '@sar/api-interface'; } from '@sar/api-interface';
import { apiFetch } from '../api-client'; 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) { export function useReportAbc(params: ReportAbcQuery) {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (params.mes != null) qs.set('mes', String(params.mes)); if (params.mes != null) qs.set('mes', String(params.mes));

View File

@@ -0,0 +1,102 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ChamadoListResponseSchema,
ChamadoSchema,
type CreateChamadoDto,
type AddMensagemDto,
type ResolverChamadoDto,
type Chamado,
type ChamadoListResponse,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
export function useChamados() {
return useQuery<ChamadoListResponse>({
queryKey: ['chamados'],
queryFn: async () => ChamadoListResponseSchema.parse(await apiFetch('/chamados')),
});
}
export function useChamado(id: number) {
return useQuery<Chamado>({
queryKey: ['chamados', id],
queryFn: async () => ChamadoSchema.parse(await apiFetch(`/chamados/${id}`)),
enabled: id > 0,
});
}
export function useCreateChamado() {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: CreateChamadoDto): Promise<Chamado> =>
apiFetch('/chamados', { method: 'POST', body: dto }).then((r) => ChamadoSchema.parse(r)),
onSuccess: () => qc.invalidateQueries({ queryKey: ['chamados'] }),
});
}
export function useAddMensagemRep(id: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
apiFetch(`/chamados/${id}/mensagens`, { method: 'POST', body: dto }).then((r) =>
ChamadoSchema.parse(r),
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['chamados', id] });
qc.invalidateQueries({ queryKey: ['chamados'] });
},
});
}
export function useCancelChamado() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number): Promise<Chamado> =>
apiFetch(`/chamados/${id}/cancelar`, { method: 'PATCH' }).then((r) => ChamadoSchema.parse(r)),
onSuccess: () => qc.invalidateQueries({ queryKey: ['chamados'] }),
});
}
// Gerente
export function useChamadosGer() {
return useQuery<ChamadoListResponse>({
queryKey: ['ger', 'chamados'],
queryFn: async () => ChamadoListResponseSchema.parse(await apiFetch('/ger/chamados')),
});
}
export function useChamadoGer(id: number) {
return useQuery<Chamado>({
queryKey: ['ger', 'chamados', id],
queryFn: async () => ChamadoSchema.parse(await apiFetch(`/ger/chamados/${id}`)),
enabled: id > 0,
});
}
export function useAddMensagemEmpresa(id: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
apiFetch(`/ger/chamados/${id}/mensagens`, { method: 'POST', body: dto }).then((r) =>
ChamadoSchema.parse(r),
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
qc.invalidateQueries({ queryKey: ['ger', 'chamados'] });
},
});
}
export function useResolverChamado(id: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: ResolverChamadoDto): Promise<Chamado> =>
apiFetch(`/ger/chamados/${id}/resolver`, { method: 'PATCH', body: dto }).then((r) =>
ChamadoSchema.parse(r),
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
qc.invalidateQueries({ queryKey: ['ger', 'chamados'] });
},
});
}

View File

@@ -1,11 +1,34 @@
import { QueryClient } from '@tanstack/react-query'; import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
import { ApiError } from './api-client';
import { notifyError } from './feedback';
function describeError(error: unknown): string {
if (error instanceof ApiError) {
return error.problem.detail ?? error.problem.title ?? 'Erro no servidor';
}
if (error instanceof Error && error.message) return error.message;
return 'Erro inesperado';
}
/** /**
* QueryClient canônico do SAR. * QueryClient canônico do SAR.
* Defaults conservadores: refetch on focus desabilitado (Visual DNA: "sereno"), * Defaults conservadores: refetch on focus desabilitado (Visual DNA: "sereno"),
* stale-while-revalidate para tempo real bater no Socket.IO, não em polling. * stale-while-revalidate para tempo real bater no Socket.IO, não em polling.
* Erros nunca são silenciosos: falha de query/mutation sem tratamento local
* vira toast — Sandra/Daniel jamais devem "achar que salvou".
*/ */
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
notifyError(`Falha ao carregar dados: ${describeError(error)}`, 'query-error');
},
}),
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
if (mutation.options.onError) return; // a tela já dá feedback próprio
notifyError(describeError(error), 'mutation-error');
},
}),
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 30_000, // 30s — Socket.IO atualiza antes na maioria dos casos staleTime: 30_000, // 30s — Socket.IO atualiza antes na maioria dos casos

View File

@@ -5,7 +5,7 @@ import {
Outlet, Outlet,
notFound, notFound,
} from '@tanstack/react-router'; } from '@tanstack/react-router';
import { Typography } from 'antd'; import { Button, Result, Typography } from 'antd';
import { AppShell } from '../components/layout/AppShell'; import { AppShell } from '../components/layout/AppShell';
import { RepPainel } from '../cockpits/rep/RepPainel'; import { RepPainel } from '../cockpits/rep/RepPainel';
import { ClientsPage } from '../cockpits/rep/ClientsPage'; import { ClientsPage } from '../cockpits/rep/ClientsPage';
@@ -18,11 +18,14 @@ import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
import { CatalogPage } from '../cockpits/rep/CatalogPage'; import { CatalogPage } from '../cockpits/rep/CatalogPage';
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage'; import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage'; import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage'; import { FunilPage } from '../cockpits/rep/FunilPage';
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel'; import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
import { GerPainel } from '../cockpits/ger/GerPainel'; import { GerPainel } from '../cockpits/ger/GerPainel';
import { EquipePage } from '../cockpits/ger/EquipePage'; import { EquipePage } from '../cockpits/ger/EquipePage';
import { PoliticasPage } from '../cockpits/ger/PoliticasPage'; 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'; import { authStore } from './auth-store';
function getRoleFromToken(): string { function getRoleFromToken(): string {
@@ -43,6 +46,23 @@ function HomeRoute() {
return <RepPainel />; return <RepPainel />;
} }
// Error boundary por rota: uma exceção de render (ex.: ZodError de payload
// inesperado) mostra esta tela em vez de derrubar o app inteiro em branco.
function RouteErrorPage({ error }: { error: Error }) {
return (
<Result
status="error"
title="Algo deu errado"
subTitle={error.message || 'Erro inesperado ao exibir esta tela.'}
extra={
<Button type="primary" onClick={() => window.location.reload()}>
Recarregar
</Button>
}
/>
);
}
function NotFoundPage() { function NotFoundPage() {
return ( return (
<div style={{ padding: 48, textAlign: 'center' }}> <div style={{ padding: 48, textAlign: 'center' }}>
@@ -63,6 +83,7 @@ const rootRoute = createRootRoute({
</AppShell> </AppShell>
), ),
notFoundComponent: NotFoundPage, notFoundComponent: NotFoundPage,
errorComponent: RouteErrorPage,
}); });
const indexRoute = createRoute({ const indexRoute = createRoute({
@@ -137,10 +158,10 @@ const relatoriosRoute = createRoute({
component: RelatoriosPage, component: RelatoriosPage,
}); });
const aprovacoes = createRoute({ const funilRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/aprovacoes', path: '/funil',
component: ApprovalQueuePage, component: FunilPage,
}); });
const gerPainelRoute = createRoute({ const gerPainelRoute = createRoute({
@@ -161,6 +182,24 @@ const politicasRoute = createRoute({
component: PoliticasPage, component: PoliticasPage,
}); });
const chamadosRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/chamados',
component: ChamadosPage,
});
const gerChamadosRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ger/chamados',
component: GerChamadosPage,
});
const gerConfigRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ger/config',
component: GerConfigPage,
});
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
rafaelRoute, rafaelRoute,
@@ -174,16 +213,20 @@ const routeTree = rootRoute.addChildren([
catalogoRoute, catalogoRoute,
pedidosErpRoute, pedidosErpRoute,
relatoriosRoute, relatoriosRoute,
aprovacoes, funilRoute,
gerPainelRoute, gerPainelRoute,
equipeRoute, equipeRoute,
politicasRoute, politicasRoute,
chamadosRoute,
gerChamadosRoute,
gerConfigRoute,
]); ]);
export const router = createRouter({ export const router = createRouter({
routeTree, routeTree,
defaultPreload: 'intent', defaultPreload: 'intent',
defaultPreloadStaleTime: 0, defaultPreloadStaleTime: 0,
defaultErrorComponent: RouteErrorPage,
}); });
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {

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

@@ -10,6 +10,7 @@ export default defineConfig(() => ({
server: { server: {
port: 4200, port: 4200,
host: '0.0.0.0', host: '0.0.0.0',
allowedHosts: true,
// Proxy /api/* → API Nest em :3000 (default API_PORT). // Proxy /api/* → API Nest em :3000 (default API_PORT).
// Evita CORS em dev e mantém URL relativa no código da Web — em produção, // Evita CORS em dev e mantém URL relativa no código da Web — em produção,
// mesmo origin via Nginx (/api/* → backend). // mesmo origin via Nginx (/api/* → backend).
@@ -43,6 +44,7 @@ export default defineConfig(() => ({
globals: true, globals: true,
environment: 'jsdom', environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
passWithNoTests: true,
reporters: ['default'], reporters: ['default'],
coverage: { coverage: {
reportsDirectory: '../../coverage/apps/web', reportsDirectory: '../../coverage/apps/web',

View File

@@ -9,3 +9,5 @@ export * from './lib/company.contract';
export * from './lib/report.contract'; export * from './lib/report.contract';
export * from './lib/politicas.contract'; export * from './lib/politicas.contract';
export * from './lib/equipe.contract'; export * from './lib/equipe.contract';
export * from './lib/funil.contract';
export * from './lib/sac.contract';

View File

@@ -9,7 +9,8 @@ const JwtRoleSchema = z.enum(['rep', 'supervisor', 'manager', 'admin']);
export const DevTokenRequestSchema = z.object({ export const DevTokenRequestSchema = z.object({
userId: z.string().min(1), userId: z.string().min(1),
idEmpresa: z.coerce.number().int().positive(), // Opcional: quando ausente, o backend usa DEV_EMPRESA_ID do .env
idEmpresa: z.coerce.number().int().positive().optional(),
role: JwtRoleSchema, role: JwtRoleSchema,
}); });

View File

@@ -93,7 +93,10 @@ export const CreateContatoSchema = z.object({
empresa: z.string().max(100).optional(), empresa: z.string().max(100).optional(),
cargo: z.string().max(100).optional(), cargo: z.string().max(100).optional(),
departamento: z.string().max(100).optional(), departamento: z.string().max(100).optional(),
dtAniversario: z.string().optional(), dtAniversario: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Data deve ser YYYY-MM-DD')
.optional(),
telefone: z.string().max(20).optional(), telefone: z.string().max(20).optional(),
ramal: z.string().max(10).optional(), ramal: z.string().max(10).optional(),
celular: z.string().max(20).optional(), celular: z.string().max(20).optional(),

View File

@@ -17,5 +17,18 @@ export const EmpresaInfoSchema = z.object({
cep: z.string().nullable(), cep: z.string().nullable(),
telefone: z.string().nullable(), telefone: z.string().nullable(),
email: 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>; 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 type RepInativosSummary = z.infer<typeof RepInativosSummarySchema>;
export const SupervisorDashboardSchema = z.object({ // Detalhe dos clientes inativos (>30 dias sem compra) de um representante
approvalQueue: z.array(PedidoSummarySchema), export const RepInativosDetailSchema = z.object({
pedidosDia: z.object({ codVendedor: z.number().int(),
count: z.number().int(), nomeVendedor: z.string().nullable(),
total: z.number(), clientes: z.array(ClienteNaoPositivadoSchema),
countSemanaAnterior: z.number().int(),
totalSemanaAnterior: z.number(),
}),
inativosPorRep: z.array(RepInativosSummarySchema),
syncedAt: z.iso.datetime(),
}); });
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({ export const PositivacaoRepSchema = z.object({
novosClientes: z.number().int().default(0),
codVendedor: z.number().int(), codVendedor: z.number().int(),
nomeVendedor: z.string().nullable(), nomeVendedor: z.string().nullable(),
totalClientes: z.number().int(), totalClientes: z.number().int(),
@@ -104,6 +102,57 @@ export const RankingRepSchema = z.object({
}); });
export type RankingRep = z.infer<typeof RankingRepSchema>; 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(),
clientes: z.number().int(),
});
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({ export const ManagerDashboardSchema = z.object({
faturamentoMes: z.number(), faturamentoMes: z.number(),
pedidosMes: z.number().int(), pedidosMes: z.number().int(),
@@ -114,6 +163,16 @@ export const ManagerDashboardSchema = z.object({
metasPorGrupo: z.array(MetaItemSchema).default([]), metasPorGrupo: z.array(MetaItemSchema).default([]),
rankingReps: z.array(RankingRepSchema), rankingReps: z.array(RankingRepSchema),
positivacaoReps: z.array(PositivacaoRepSchema), 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(), syncedAt: z.iso.datetime(),
}); });
export type ManagerDashboard = z.infer<typeof ManagerDashboardSchema>; 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(), syncedAt: z.iso.datetime(),
}); });
export type EquipeResponse = z.infer<typeof EquipeResponseSchema>; export type EquipeResponse = z.infer<typeof EquipeResponseSchema>;
// Card de disputa/análise por supervisor na aba Equipe (visão gerente/admin).
// Time = reps cujo cod_supervisor aponta para este supervisor + o próprio
// supervisor (mesma semântica de getTeamCodes). Métricas agregadas do time no mês.
export const SupervisorCardSchema = z.object({
codSupervisor: z.number().int(),
nomeSupervisor: z.string().nullable(),
faturamentoMes: z.number(),
faturamentoMesAnterior: z.number(),
variacaoPct: z.number().nullable(), // null quando não há base no mês anterior
pedidosMes: z.number().int(),
ticketMedio: z.number(),
metaTotal: z.number(),
pctMeta: z.number(),
totalClientes: z.number().int(),
clientesPositivados: z.number().int(),
pctPositivacao: z.number(),
repsTotal: z.number().int(),
repsAtivos: z.number().int(), // reps do time que faturaram no mês
});
export type SupervisorCard = z.infer<typeof SupervisorCardSchema>;
export const SupervisoresResponseSchema = z.object({
supervisores: z.array(SupervisorCardSchema),
syncedAt: z.iso.datetime(),
});
export type SupervisoresResponse = z.infer<typeof SupervisoresResponseSchema>;

View File

@@ -0,0 +1,45 @@
import { z } from 'zod';
export const ETAPAS_FUNIL = ['lead', 'proposta', 'negociacao', 'ganho', 'perdido'] as const;
export type EtapaFunil = (typeof ETAPAS_FUNIL)[number];
export const OportunidadeSchema = z.object({
id: z.number(),
idCliente: z.number().nullable(),
nomeProspect: z.string().nullable(),
empresaProspect: z.string().nullable(),
titulo: z.string(),
etapa: z.enum(ETAPAS_FUNIL),
valorEstimado: z.string().nullable(),
observacoes: z.string().nullable(),
idPedido: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
nomeCliente: z.string().nullable(),
});
export type Oportunidade = z.infer<typeof OportunidadeSchema>;
export const CreateOportunidadeSchema = z.object({
idCliente: z.number().int().positive().nullable().optional(),
nomeProspect: z.string().max(200).nullable().optional(),
empresaProspect: z.string().max(200).nullable().optional(),
titulo: z.string().min(1).max(200),
etapa: z.enum(ETAPAS_FUNIL).default('lead'),
valorEstimado: z.number().positive().nullable().optional(),
observacoes: z.string().max(2000).nullable().optional(),
});
export type CreateOportunidadeDto = z.infer<typeof CreateOportunidadeSchema>;
export const UpdateOportunidadeSchema = CreateOportunidadeSchema.partial().extend({
idPedido: z.string().nullable().optional(),
});
export type UpdateOportunidadeDto = z.infer<typeof UpdateOportunidadeSchema>;
export const FunilResponseSchema = z.object({
lead: z.array(OportunidadeSchema),
proposta: z.array(OportunidadeSchema),
negociacao: z.array(OportunidadeSchema),
ganho: z.array(OportunidadeSchema),
perdido: z.array(OportunidadeSchema),
});
export type FunilResponse = z.infer<typeof FunilResponseSchema>;

View File

@@ -11,6 +11,11 @@ export const SubscribePayloadSchema = z.object({
}); });
export type SubscribePayload = z.infer<typeof SubscribePayloadSchema>; export type SubscribePayload = z.infer<typeof SubscribePayloadSchema>;
export const UnsubscribePayloadSchema = z.object({
endpoint: z.string().url(),
});
export type UnsubscribePayload = z.infer<typeof UnsubscribePayloadSchema>;
export const PendingCountResponseSchema = z.object({ export const PendingCountResponseSchema = z.object({
count: z.number().int().min(0), count: z.number().int().min(0),
}); });

View File

@@ -6,9 +6,9 @@ import { z } from 'zod';
// ─── Situa ──────────────────────────────────────────────────────────────────── // ─── Situa ────────────────────────────────────────────────────────────────────
// Ciclo de vida do pedido SAR: // Ciclo de vida do pedido SAR: 0=Orçamento → 2=Transmitido.
// 0=Orçamento → 1=Ag. Aprovação (se desconto > alçada) → 2=Transmitido // Desconto acima da alçada é bloqueio duro na transmissão — não há fila de
// Estados que o SAR controla: Orçamento e Transmitido (1 é o gate de desconto). // 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…) // Após Transmitido, o status passa a refletir o ERP (Emitido/Cancelado/Aguardando…)
// — espelhado quando a integração existir. // — espelhado quando a integração existir.
export const SituaPedidoSchema = z.union([ export const SituaPedidoSchema = z.union([
@@ -40,6 +40,10 @@ export const PedidoItemSchema = z.object({
precoUnitario: z.string(), precoUnitario: z.string(),
descontoPerc: z.string(), descontoPerc: z.string(),
total: 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>; export type PedidoItem = z.infer<typeof PedidoItemSchema>;
@@ -89,6 +93,12 @@ export const PedidoDetailSchema = PedidoSummarySchema.extend({
pedFlex: z.string(), pedFlex: z.string(),
formaPagamento: z.string().nullable().optional(), formaPagamento: z.string().nullable().optional(),
endEntrega: 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(), aprovadoPor: z.number().int().nullable(),
aprovadoEm: z.iso.datetime().nullable(), aprovadoEm: z.iso.datetime().nullable(),
motivoRecusa: z.string().nullable(), motivoRecusa: z.string().nullable(),
@@ -101,12 +111,16 @@ export type PedidoDetail = z.infer<typeof PedidoDetailSchema>;
// ─── List query + response ──────────────────────────────────────────────────── // ─── List query + response ────────────────────────────────────────────────────
// Datas de filtro sempre YYYY-MM-DD — interpoladas em SQL no servidor, o formato
// fechado é parte da defesa contra injection, não só validação de UX.
export const DateOnlySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Data deve ser YYYY-MM-DD');
export const PedidoListQuerySchema = z.object({ export const PedidoListQuerySchema = z.object({
idCliente: z.coerce.number().int().optional(), idCliente: z.coerce.number().int().optional(),
situa: z.coerce.number().int().optional(), situa: z.coerce.number().int().optional(),
numPedSar: z.string().optional(), numPedSar: z.string().optional(),
from: z.string().optional(), from: DateOnlySchema.optional(),
to: z.string().optional(), to: DateOnlySchema.optional(),
page: z.coerce.number().int().positive().default(1), page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(200).default(50), limit: z.coerce.number().int().min(1).max(200).default(50),
}); });
@@ -145,16 +159,16 @@ export const CreatePedidoSchema = z.object({
}); });
export type CreatePedido = z.infer<typeof CreatePedidoSchema>; export type CreatePedido = z.infer<typeof CreatePedidoSchema>;
export const AprovarPedidoSchema = z.object({ // Edição de orçamento (situa 0): mesmo shape da criação, sem idempotencyKey
descontoPerc: z.number().min(0).max(100).optional(), export const UpdatePedidoSchema = CreatePedidoSchema.omit({ idempotencyKey: true });
nota: z.string().optional(), export type UpdatePedido = z.infer<typeof UpdatePedidoSchema>;
});
export type AprovarPedido = z.infer<typeof AprovarPedidoSchema>;
export const RecusarPedidoSchema = z.object({ // Cancelamento com motivo obrigatório + descrição livre opcional
motivo: z.string().min(1), 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 ───────────────────────────────────────────────────────────── // ─── ERP Consulta ─────────────────────────────────────────────────────────────
@@ -184,8 +198,8 @@ export type PedidoErpConsultaItem = z.infer<typeof PedidoErpConsultaItemSchema>;
export const PedidoErpConsultaQuerySchema = z.object({ export const PedidoErpConsultaQuerySchema = z.object({
search: z.string().optional(), search: z.string().optional(),
from: z.string().optional(), from: DateOnlySchema.optional(),
to: z.string().optional(), to: DateOnlySchema.optional(),
page: z.coerce.number().int().positive().default(1), page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(200).default(50), limit: z.coerce.number().int().min(1).max(200).default(50),
}); });

View File

@@ -5,7 +5,7 @@ describe('PingResponseSchema', () => {
status: 'ok', status: 'ok',
service: 'sar-api', service: 'sar-api',
version: '0.1.0', version: '0.1.0',
workspaceId: 'dev-workspace', idEmpresa: 1,
requestId: '550e8400-e29b-41d4-a716-446655440000', requestId: '550e8400-e29b-41d4-a716-446655440000',
uptimeSeconds: 42, uptimeSeconds: 42,
now: '2026-05-27T12:34:56.000Z', now: '2026-05-27T12:34:56.000Z',
@@ -36,8 +36,8 @@ describe('PingResponseSchema', () => {
expect(() => PingResponseSchema.parse(bad)).toThrow(); expect(() => PingResponseSchema.parse(bad)).toThrow();
}); });
it('rejeita workspaceId vazio', () => { it('rejeita idEmpresa não inteiro', () => {
const bad = { ...validPayload, workspaceId: '' }; const bad = { ...validPayload, idEmpresa: 1.5 };
expect(() => PingResponseSchema.parse(bad)).toThrow(); expect(() => PingResponseSchema.parse(bad)).toThrow();
}); });
}); });

View File

@@ -1,25 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
export const AlcadaDescontoItemSchema = z.object({
codVendedor: z.number().int(),
codGrupo: z.number().int(),
limitePerc: z.number(),
nomeVendedor: z.string().nullable(),
});
export type AlcadaDescontoItem = z.infer<typeof AlcadaDescontoItemSchema>;
export const AlcadaDescontosResponseSchema = z.object({
descontos: z.array(AlcadaDescontoItemSchema),
});
export type AlcadaDescontosResponse = z.infer<typeof AlcadaDescontosResponseSchema>;
export const UpsertDescontoBodySchema = z.object({
codVendedor: z.number().int().positive(),
codGrupo: z.number().int().default(0),
limitePerc: z.number().min(0).max(100),
});
export type UpsertDescontoBody = z.infer<typeof UpsertDescontoBodySchema>;
export const PromocaoSchema = z.object({ export const PromocaoSchema = z.object({
id: z.number().int(), id: z.number().int(),
descricao: z.string(), descricao: z.string(),
@@ -39,17 +19,160 @@ export const PromocoesResponseSchema = z.object({
}); });
export type PromocoesResponse = z.infer<typeof PromocoesResponseSchema>; 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({ export const CreatePromocaoBodySchema = z.object({
descricao: z.string().min(1).max(255), descricao: z.string().min(1).max(255),
codProduto: z.string().optional(), codProdutos: z.array(z.string().min(1)).max(100).optional(),
grpProd: z.string().optional(), grpProds: z.array(z.string().min(1)).max(100).optional(),
descPct: z.number().min(0).max(100), descPct: z.number().min(0).max(100),
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
dataFim: 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 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(), ativa: z.boolean().optional(),
}); });
export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>; export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>;
// ─── Regras de Desconto ───────────────────────────────────────────────────────
// Regra criada pelo Gerente: % de desconto liberado por grupo e/ou subgrupo de
// produto, pauta de preços e/ou representante, com vigência. Campos nulos = "todos".
export const RegraDescontoSchema = 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(),
codVendedor: z.number().int().nullable(),
nomeVendedor: z.string().nullable(),
descPct: z.number(),
dataInicio: z.string(),
dataFim: z.string(),
ativa: z.boolean(),
createdAt: z.string(),
createdBy: z.string(),
});
export type RegraDesconto = z.infer<typeof RegraDescontoSchema>;
export const RegrasDescontoResponseSchema = z.object({
regras: z.array(RegraDescontoSchema),
});
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),
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}$/),
dataFim: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
});
export type CreateRegraDescontoBody = z.infer<typeof CreateRegraDescontoBodySchema>;
// 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(),
});
export type PromocaoVigente = z.infer<typeof PromocaoVigenteSchema>;
export const PromocoesVigentesResponseSchema = z.object({
promocoes: z.array(PromocaoVigenteSchema),
});
export type PromocoesVigentesResponse = z.infer<typeof PromocoesVigentesResponseSchema>;
// Regras vigentes para o representante logado — aplicadas na montagem do pedido
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(),
});
export type RegraVigente = z.infer<typeof RegraVigenteSchema>;
export const RegrasVigentesResponseSchema = z.object({
regras: z.array(RegraVigenteSchema),
});
export type RegrasVigentesResponse = z.infer<typeof RegrasVigentesResponseSchema>;
// Grupos/subgrupos de produto (combos do formulário do gerente)
export const GrupoProdutoItemSchema = z.object({
codGrupo: z.number().int().nullable(),
grupo: z.string().nullable(),
codSubgrupo: z.number().int().nullable(),
subgrupo: z.string().nullable(),
});
export type GrupoProdutoItem = z.infer<typeof GrupoProdutoItemSchema>;
export const GruposProdutoResponseSchema = z.object({
grupos: z.array(GrupoProdutoItemSchema),
});
export type GruposProdutoResponse = z.infer<typeof GruposProdutoResponseSchema>;

View File

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

View File

@@ -30,6 +30,8 @@ export const CarteiraClienteSchema = z.object({
diasSemPedido: z.number().nullable(), diasSemPedido: z.number().nullable(),
totalPedidos: z.number(), totalPedidos: z.number(),
ticketMedio: z.string(), ticketMedio: z.string(),
faturamentoTotal: z.string(),
participacaoPct: z.number(),
}); });
export type CarteiraCliente = z.infer<typeof CarteiraClienteSchema>; export type CarteiraCliente = z.infer<typeof CarteiraClienteSchema>;
@@ -39,6 +41,7 @@ export const ReportCarteiraResponseSchema = z.object({
inativos30: z.number(), inativos30: z.number(),
inativos60: z.number(), inativos60: z.number(),
semPedido: z.number(), semPedido: z.number(),
faturamentoRepTotal: z.string(),
}); });
export type ReportCarteiraResponse = z.infer<typeof ReportCarteiraResponseSchema>; export type ReportCarteiraResponse = z.infer<typeof ReportCarteiraResponseSchema>;

View File

@@ -0,0 +1,127 @@
import { z } from 'zod';
export const TIPOS_CHAMADO = [
'avaria',
'quantidade_divergente',
'produto_errado',
'prazo_entrega',
'outro',
] as const;
export const STATUS_CHAMADO = [
'aberto',
'em_analise',
'aguardando_rep',
'resolvido',
'cancelado',
] as const;
export const RESOLUCAO_CHAMADO = [
'reposicao',
'desconto',
'cancelamento_item',
'sem_acao',
'outro',
] as const;
export const TIPO_CHAMADO_LABEL: Record<string, string> = {
avaria: 'Avaria / Dano',
quantidade_divergente: 'Quantidade Divergente',
produto_errado: 'Produto Errado',
prazo_entrega: 'Prazo de Entrega',
outro: 'Outro',
};
export const STATUS_CHAMADO_LABEL: Record<string, string> = {
aberto: 'Aberto',
em_analise: 'Em Análise',
aguardando_rep: 'Aguardando Rep.',
resolvido: 'Resolvido',
cancelado: 'Cancelado',
};
export const RESOLUCAO_CHAMADO_LABEL: Record<string, string> = {
reposicao: 'Reposição do Item',
desconto: 'Desconto / Abatimento',
cancelamento_item: 'Cancelamento do Item',
sem_acao: 'Sem Ação Necessária',
outro: 'Outro',
};
export const ItemAfetadoSchema = z.object({
codProduto: z.string(),
nomeProduto: z.string(),
qtd: z.number(),
});
export const ChamadoMensagemSchema = z.object({
id: z.number(),
idChamado: z.number(),
autor: z.enum(['rep', 'empresa']),
texto: z.string(),
createdAt: z.string(),
});
export const ChamadoSchema = z.object({
id: z.number(),
idEmpresa: z.number(),
codVendedor: z.number(),
idPedido: z.string().nullable().optional(),
numPedSar: z.number().nullable().optional(),
numPedErp: z.number().nullable().optional(),
idCliente: z.number(),
nomeCliente: z.string().nullable().optional(),
tipo: z.string(),
assunto: z.string(),
descricao: z.string(),
status: z.string(),
resolucao: z.string().nullable().optional(),
notaResolucao: z.string().nullable().optional(),
itensAfetados: z.array(ItemAfetadoSchema).nullable().optional(),
createdAt: z.string(),
updatedAt: z.string(),
mensagens: z.array(ChamadoMensagemSchema).optional(),
});
export const CreateChamadoSchema = z.object({
idPedido: z.string().uuid().optional(),
numPedSar: z.number().int().positive().optional(),
numPedErp: z.number().int().positive().optional(),
nomeCliente: z.string().optional(),
idCliente: z.number().int().positive(),
tipo: z.enum(TIPOS_CHAMADO),
assunto: z.string().min(5).max(200),
descricao: z.string().min(10),
itensAfetados: z.array(ItemAfetadoSchema).optional(),
});
export const AddMensagemSchema = z.object({
texto: z.string().min(1).max(2000),
});
export const ResolverChamadoSchema = z.object({
resolucao: z.enum(RESOLUCAO_CHAMADO),
notaResolucao: z.string().optional(),
mensagemFinal: z.string().optional(),
});
export const CancelChamadoSchema = z.object({
motivo: z.string().optional(),
});
export const ChamadoListResponseSchema = z.object({
abertos: z.array(ChamadoSchema),
fechados: z.array(ChamadoSchema),
});
export type TipoChamado = (typeof TIPOS_CHAMADO)[number];
export type StatusChamado = (typeof STATUS_CHAMADO)[number];
export type ResolucaoChamado = (typeof RESOLUCAO_CHAMADO)[number];
export type ItemAfetado = z.infer<typeof ItemAfetadoSchema>;
export type ChamadoMensagem = z.infer<typeof ChamadoMensagemSchema>;
export type Chamado = z.infer<typeof ChamadoSchema>;
export type CreateChamadoDto = z.infer<typeof CreateChamadoSchema>;
export type AddMensagemDto = z.infer<typeof AddMensagemSchema>;
export type ResolverChamadoDto = z.infer<typeof ResolverChamadoSchema>;
export type CancelChamadoDto = z.infer<typeof CancelChamadoSchema>;
export type ChamadoListResponse = z.infer<typeof ChamadoListResponseSchema>;

View File

@@ -17,7 +17,7 @@
"e2e": "nx run-many -t e2e", "e2e": "nx run-many -t e2e",
"dev:api": "nx run api:serve", "dev:api": "nx run api:serve",
"dev:web": "nx run web:serve", "dev:web": "nx run web:serve",
"workspace:provision": "tsx scripts/provision-workspace.ts", "client:provision": "tsx scripts/provision-client.ts",
"dev:up": "docker compose -f docker-compose.dev.yml up -d", "dev:up": "docker compose -f docker-compose.dev.yml up -d",
"dev:down": "docker compose -f docker-compose.dev.yml down", "dev:down": "docker compose -f docker-compose.dev.yml down",
"dev:logs": "docker compose -f docker-compose.dev.yml logs -f", "dev:logs": "docker compose -f docker-compose.dev.yml logs -f",
@@ -110,7 +110,7 @@
"lru-cache": "^11.5.0", "lru-cache": "^11.5.0",
"nestjs-cls": "^5.4.3", "nestjs-cls": "^5.4.3",
"nestjs-pino": "^4.6.1", "nestjs-pino": "^4.6.1",
"nestjs-zod": "^4.3.1", "nestjs-zod": "^5.4.0",
"pg": "^8.21.0", "pg": "^8.21.0",
"pino": "^9.14.0", "pino": "^9.14.0",
"pino-http": "^10.5.0", "pino-http": "^10.5.0",

56
pnpm-lock.yaml generated
View File

@@ -63,8 +63,8 @@ importers:
specifier: ^4.6.1 specifier: ^4.6.1
version: 4.6.1(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(pino-http@10.5.0)(pino@9.14.0)(rxjs@7.8.1) version: 4.6.1(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(pino-http@10.5.0)(pino@9.14.0)(rxjs@7.8.1)
nestjs-zod: nestjs-zod:
specifier: ^4.3.1 specifier: ^5.4.0
version: 4.3.1(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@11.1.24)(zod@4.4.3) version: 5.4.0(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(rxjs@7.8.1)(zod@4.4.3)
pg: pg:
specifier: ^8.21.0 specifier: ^8.21.0
version: 8.21.0 version: 8.21.0
@@ -319,6 +319,9 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.4.0 specifier: ^5.4.0
version: 5.4.0(react-hook-form@7.76.1(react@19.2.6)) 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': '@tanstack/react-query':
specifier: ^5.100.14 specifier: ^5.100.14
version: 5.100.14(react@19.2.6) version: 5.100.14(react@19.2.6)
@@ -2002,21 +2005,6 @@ packages:
'@emnapi/core': ^1.7.1 '@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1 '@emnapi/runtime': ^1.7.1
'@nest-zod/z@2.0.0':
resolution: {integrity: sha512-OqmJUZlpcx9ECzPYSIjaR/q/xLKrm9FkV+0FIWLEb9MVc9YeP21GmnQJBxE1dPRlLJ1kybNFBlt4D/PB8YAPkA==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
'@nestjs/swagger': ^7.4.2 || ^8.0.0 || ^11.0.0
zod: '>= 3.14.3'
peerDependenciesMeta:
'@nestjs/common':
optional: true
'@nestjs/core':
optional: true
'@nestjs/swagger':
optional: true
'@nestjs/common@11.1.24': '@nestjs/common@11.1.24':
resolution: {integrity: sha512-9zHxaDDM+oXW9As6UsP5yYB+UqczBmpeSCIFWdPEtEukMnZhxODG1BBjaUcdBB8Sc1uzojSJSJlp3yFp853t1g==} resolution: {integrity: sha512-9zHxaDDM+oXW9As6UsP5yYB+UqczBmpeSCIFWdPEtEukMnZhxODG1BBjaUcdBB8Sc1uzojSJSJlp3yFp853t1g==}
peerDependencies: peerDependencies:
@@ -3395,6 +3383,9 @@ packages:
'@standard-schema/utils@0.3.0': '@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} 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': '@svgr/babel-plugin-add-jsx-attribute@8.0.0':
resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -7357,18 +7348,14 @@ packages:
pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
rxjs: ^7.1.0 rxjs: ^7.1.0
nestjs-zod@4.3.1: nestjs-zod@5.4.0:
resolution: {integrity: sha512-gl10NOf/AhWt4HLcnMI1J0WUlBCKyteJBftD6SwNyX71/vzAorr5MJQWiDJoPPkou9QL2CRHHBtyjr55o7++Xw==} resolution: {integrity: sha512-dxVpy1fjfK4kp+ztK+7xQP46fpvZxkeR/jcEdIvEGh/2o71iwXuy/hrKOWSPhJ1nQXV4iBdHqMizndn2GTaXDg==}
peerDependencies: peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0 '@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
'@nestjs/swagger': ^7.4.2 || ^8.0.0 || ^11.0.0 '@nestjs/swagger': ^7.4.2 || ^8.0.0 || ^11.0.0
zod: '>= 3.14.3' rxjs: ^7.0.0
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta: peerDependenciesMeta:
'@nestjs/common':
optional: true
'@nestjs/core':
optional: true
'@nestjs/swagger': '@nestjs/swagger':
optional: true optional: true
@@ -11787,13 +11774,6 @@ snapshots:
'@tybys/wasm-util': 0.10.2 '@tybys/wasm-util': 0.10.2
optional: true optional: true
'@nest-zod/z@2.0.0(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@11.1.24)(zod@4.4.3)':
dependencies:
zod: 4.4.3
optionalDependencies:
'@nestjs/common': 11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1)
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.1.14)(rxjs@7.8.1)
'@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1)': '@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1)':
dependencies: dependencies:
file-type: 21.3.4 file-type: 21.3.4
@@ -13411,6 +13391,8 @@ snapshots:
'@standard-schema/utils@0.3.0': {} '@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)': '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.7)':
dependencies: dependencies:
'@babel/core': 7.29.7 '@babel/core': 7.29.7
@@ -18092,14 +18074,12 @@ snapshots:
pino-http: 10.5.0 pino-http: 10.5.0
rxjs: 7.8.1 rxjs: 7.8.1
nestjs-zod@4.3.1(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@11.1.24)(zod@4.4.3): nestjs-zod@5.4.0(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(rxjs@7.8.1)(zod@4.4.3):
dependencies: dependencies:
'@nest-zod/z': 2.0.0(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@11.1.24)(zod@4.4.3)
deepmerge: 4.3.1
zod: 4.4.3
optionalDependencies:
'@nestjs/common': 11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1) '@nestjs/common': 11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1)
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.1.14)(rxjs@7.8.1) deepmerge: 4.3.1
rxjs: 7.8.1
zod: 4.4.3
no-case@3.0.4: no-case@3.0.4:
dependencies: dependencies:

View File

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

View File

@@ -289,7 +289,9 @@ SELECT
COALESCE(p.preco_promocional, 0) AS preco_promocional, COALESCE(p.preco_promocional, 0) AS preco_promocional,
COALESCE(p.tx_desc_lote, 0) AS tx_desc_lote, COALESCE(p.tx_desc_lote, 0) AS tx_desc_lote,
p.lista_pauta, 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 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 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 LEFT JOIN gestao.grupo sub ON sub.codigo = p.cod_subgrupo AND sub.id_empresa = p.id_empresa
@@ -716,6 +718,39 @@ CREATE TABLE IF NOT EXISTS sar.promocoes (
created_by VARCHAR(100) NOT NULL created_by VARCHAR(100) NOT NULL
); );
-- -----------------------------------------------------------------------------
-- Regras de Desconto (Politicas Comerciais - criadas pelo Gerente/Admin)
-- 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,
id_empresa INTEGER NOT NULL,
descricao VARCHAR(200) NOT NULL,
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,
ativa BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
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) -- Oportunidades (Funil de Vendas)
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
@@ -783,6 +818,8 @@ CREATE INDEX IF NOT EXISTS idx_sar_meta_vend ON sar.meta_representante(cod_v
CREATE INDEX IF NOT EXISTS idx_sar_alcada_vend ON sar.alcada_desconto(cod_vendedor, id_empresa); CREATE INDEX IF NOT EXISTS idx_sar_alcada_vend ON sar.alcada_desconto(cod_vendedor, id_empresa);
CREATE INDEX IF NOT EXISTS idx_sar_promo_empresa ON sar.promocoes(id_empresa); CREATE INDEX IF NOT EXISTS idx_sar_promo_empresa ON sar.promocoes(id_empresa);
CREATE INDEX IF NOT EXISTS idx_sar_promo_vigente ON sar.promocoes(id_empresa, data_fim) WHERE ativa; CREATE INDEX IF NOT EXISTS idx_sar_promo_vigente ON sar.promocoes(id_empresa, data_fim) WHERE ativa;
CREATE INDEX IF NOT EXISTS idx_sar_regras_empresa ON sar.regras_desconto(id_empresa);
CREATE INDEX IF NOT EXISTS idx_sar_regras_vigente ON sar.regras_desconto(id_empresa, data_fim) WHERE ativa;
CREATE INDEX IF NOT EXISTS idx_sar_oport_vend ON sar.oportunidades(id_empresa, cod_vendedor); CREATE INDEX IF NOT EXISTS idx_sar_oport_vend ON sar.oportunidades(id_empresa, cod_vendedor);
CREATE INDEX IF NOT EXISTS idx_sar_oport_etapa ON sar.oportunidades(etapa); CREATE INDEX IF NOT EXISTS idx_sar_oport_etapa ON sar.oportunidades(etapa);
CREATE INDEX IF NOT EXISTS idx_chamados_empresa_vendedor ON sar.chamados(id_empresa, cod_vendedor); CREATE INDEX IF NOT EXISTS idx_chamados_empresa_vendedor ON sar.chamados(id_empresa, cod_vendedor);
@@ -798,6 +835,6 @@ CREATE INDEX IF NOT EXISTS idx_chamado_mensagens_chamado ON sar.chamado_mensagen
-- GRANT INSERT, UPDATE, DELETE ON -- GRANT INSERT, UPDATE, DELETE ON
-- sar.pedidos, sar.pedido_itens, sar.historico_pedido, -- sar.pedidos, sar.pedido_itens, sar.historico_pedido,
-- sar.alcada_desconto, sar.meta_representante, sar.push_subscription, -- sar.alcada_desconto, sar.meta_representante, sar.push_subscription,
-- sar.promocoes, sar.clientes_novos, sar.contatos_novos -- sar.promocoes, sar.regras_desconto, sar.config_empresa, sar.clientes_novos, sar.contatos_novos
-- TO <role>; -- TO <role>;
-- ============================================================================= -- =============================================================================