Compare commits

..

27 Commits

Author SHA1 Message Date
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
bf6e21ce47 refactor(infra)!: schema sar no banco ERP substitui banco-por-workspace
Revoga o ADR 0006. O isolamento multi-tenant deixa de ser um banco por
workspace e passa a ser o schema `sar` dentro do proprio banco ERP do
cliente, com `id_empresa` discriminando o tenant em todas as tabelas.

Provisionamento:
- `provision-client.ts` substitui `provision-workspace.ts`. Nao cria mais
  banco: aplica schema + views + triggers num banco ERP ja existente.
  Trata o encoding LATIN1 dos bancos ERP via `toLatinSafe()`.
- `sar-erp-schema.sql` vira a fonte unica: absorve as views que estavam em
  `sarweb_views.sql` e passa a conter tambem `sar.chamados` /
  `sar.chamado_mensagens`, que so existiam via migration.

Correcoes necessarias para o provisionamento rodar ponta a ponta:
- `migrate deploy` abortava com P3005, porque o schema SQL roda antes do
  Prisma e o schema `sar` nunca esta vazio. O script agora detecta banco
  pre-existente e faz o baseline sozinho.
- `_prisma_migrations` ia parar no schema `public` do ERP do cliente; a URL
  de migracao agora leva `?schema=sar`.
- `sar.pedidos` nao tinha `end_entrega`, coluna que o schema.prisma ja
  declarava desde a feature de endereco de entrega, e que derrubava
  GET /orders com 500. Migration adicionada.
- `vw_sitpedido` se perdeu junto com `sarweb_views.sql` e e usada pela
  consulta de pedidos ERP. Restaurada.

Remove o seed de dados demo e os fallbacks de DATABASE_URL hardcoded: sem
a variavel configurada a API agora falha explicitamente, em vez de apontar
silenciosamente para um banco de desenvolvimento inexistente.

Verificado ponta a ponta contra um banco ERP real: schema aplicado, 13
tabelas + 20 views, migrations registradas, e segunda execucao idempotente.

BREAKING CHANGE: DATABASE_URL passa a ser obrigatoria. O comando de
provisionamento muda para `pnpm client:provision --host <host> --db <banco>`
(rename efetivado no commit de dependencias).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:11:35 +00:00
32ff4b43fd fix(api): não positivados consulta só vw_pedidos_erp, remove sar.pedidos
Orçamentos SAR não integrados não devem marcar cliente como positivado.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 16:53:40 +00:00
795b805ae5 feat(web+api): gráfico anual realizado vs meta no painel do representante
Barras (azul/verde) por mês + linha tracejada de meta; meses futuros em cinza.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 16:51:20 +00:00
a6cb1df2aa feat(web+api): cockpit rep — novo pedido v2, endereço de entrega e cancelamento de orçamento
- NewOrderPage: etapas 1-5 — merge de cards, busca de produto melhorada
  (autocomplete com estoque + modal catálogo), responsividade mobile (tabela →
  cards, footer compacto), histórico de compras do cliente e campo de
  endereço de entrega (cadastrado vs livre)
- endEntrega: campo TEXT adicionado em sar.pedidos; contrato CreatePedidoSchema
  e PedidoDetailSchema atualizados; aparece no detalhe (OrderDetailPage,
  OrderDetailModal) e no PDF (OrderPrintPage)
- Cancelamento de orçamento: PATCH /orders/:id/cancel — rep cancela seu
  próprio orçamento (situa 0); histórico registrado; UI com Modal.confirm
  substituindo alert() em OrderActionsMenu
- Fix: useClientDetail chamado para cliente selecionado manualmente, garantindo
  que clientEndStr seja preenchido mesmo sem clientIdParam na URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 14:25:24 +00:00
5bda00009f feat(web+api): metas por grupo no painel gerencial
Agrega GR-metas por cod_grupo (todos os reps) e realizado por grupo
via JOIN em vw_peditens_erp/vw_produtos, retornando MetaItem[] no
endpoint /dashboard/manager. GerPainel exibe tabela com Meta /
Realizado / Falta / % Meta (barra + número) ordenada por valorMeta
desc; card oculto quando não há grupos cadastrados.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 13:18:09 +00:00
289c1a071e feat(web+api): cockpit gerente — painel, equipe, políticas e positivação
## API
- GET /dashboard/manager: KPIs agregados (faturamento, pedidos, ticket médio,
  promoções ativas), meta total do período, ranking top 10 com clientes
  atendidos e % meta, positivação por representante, venda necessária/dia
- Parâmetros opcionais ?mes&ano para filtrar período
- GET /equipe: lista de reps com pedidos, faturamento, ticket médio e % meta
  (deduplicação de vw_representantes)
- GET/POST /politicas/descontos: alçada de desconto por rep (AlcadaDesconto)
- GET/POST/PATCH/DELETE /politicas/promocoes: CRUD de promoções com validade
- Metas lidas de sar.vw_metas (tipo=GR), não de vw_metas do ERP

## Schema
- Novo model Promocao (sar.promocoes) criado via prisma db execute

## Frontend
- Cockpit /ger: GerPainel, EquipePage, PoliticasPage
- GerPainel: filtro mes/ano, cards KPI, cards de meta vs realizado
  (atingimento, falta, venda/dia), ranking com clientes atendidos,
  positivação de clientes por representante (paginada)
- Sidebar e BottomNav role-aware: rep / supervisor / gerente (manager+admin)
- Clientes acessível ao gerente (carteira completa de todos os reps)
- Rotas: HomeRoute redireciona por role; /ger, /ger/equipe, /ger/politicas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:14:57 +00:00
400fcb3360 feat(web+api): ficha do cliente — contatos, CTR, NF-e, pedidos e produtos
- Contatos: grid de cards 3 colunas, botão "Novo Contato" no topo da página
- Sync ERP↔SAR corrigido: vw_contatos aceita formato COR#<id>, trigger normaliza id_empresa (9001→1)
- CTR + NF-e: layout 50/50 — lista de títulos abertos com badge vencido/a vencer e lista de notas com botão copiar chave NF-e
- Histórico de pedidos: UNION SAR+ERP, top 5 + modal "Ver todos"
- Produtos mais comprados: top 5 com último preço + modal "Ver todos"
- Novos endpoints: ctr-list, notas, orders-history, top-produtos
- AppShell: overflow-x travado, sem scroll horizontal na aplicação

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 14:13:21 +00:00
a2bab75bad feat(web+api): catálogo com detalhe do produto, preços por pauta e consulta de pedidos ERP
- Catálogo: modal de detalhe centralizado (1100px) com identificação, estoque,
  preços base e lista de preços por pauta do representante (DISTINCT para evitar duplicatas)
- Contrato: loteMulVenda promovido para ProdutoSummarySchema; PautaPrecoSchema adicionado
- Pedido novo: valida lote múltiplo de venda — qtd inicial = lote, step = lote,
  InputNumber em erro quando inválido, alerta e bloqueio do submit
- Consulta ERP: tela OrdersErpPage com listagem de pedidos do ERP (P/E), contrato
  PedidoErpConsulta, endpoint no orders.controller, rota e sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 20:27:19 +00:00
0858e1e941 feat(web): abre ficha completa do cliente pelo nome na tabela e botão no drawer
Clicar no nome do cliente na lista navega direto para /clientes/:id (ficha completa
com contatos e pedidos). Botão 'Abrir ficha' adicionado no footer do drawer de preview.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:56:08 +00:00
fc6cc4c534 fix(web): navigate indefinido em ClientsPage + deprecations AntD 6.x
Adiciona useNavigate() no corpo de ClientsPage (faltava; sub-componentes já tinham).
Substitui Space direction→orientation (4 ocorrências) e Progress trailColor→railColor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:47:14 +00:00
3413fa7003 fix(web): corrige double JSON.stringify em mutations de contato/cliente e deprecation do Divider
apiFetch já serializa o body internamente; passá-lo como JSON.stringify(data) causava
double-encoding e 400 Bad Request no cadastro via browser.
Substitui orientation por titlePlacement no Divider de Contatos (AntD 6.x).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:40:30 +00:00
34368e8006 feat(web+api): contatos vinculados a clientes com sync ERP e base WhatsApp
DB:
- sar.vw_contatos: view sobre gestao.contato com join sig.corrent
  (id_entidade CHAR20 = id_corrent; cod_vendedor para filtro por rep)
- sar.contatos_novos: staging com nome, cargo, departamento, telefone,
  celular, whatsapp, email, dt_aniversario, anotacoes
- trg_contato_novo: AFTER INSERT → gestao.contato; grava id_contato_erp

API:
- GET  /clients/:id/contacts  — lista contatos ativos do cliente
- POST /clients/:id/contacts  — cria via staging; retorna { idContatoErp, sincronizado }

Web:
- ClientContacts: tabela de contatos no detalhe do cliente
- Links diretos WhatsApp (wa.me) + mailto por contato
- Modal "Novo Contato" com todos os campos; feedback de código ERP no sucesso
- whatsapp é o campo principal para envio futuro via ChatWoot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:25:48 +00:00
236dd69eba feat(web+api): tela de cadastro de cliente novo (CNPJ/CPF) com sync ERP
Web (NewClientPage):
- Segmentado PJ/CPF com campos específicos por categoria
- PJ: razão social, nome fantasia, CNPJ, IE, indicador IE, consumidor final
- PF: nome completo, CPF
- Endereço com auto-preenchimento via ViaCEP (CEP → rua/bairro/município)
- Município: Select com busca server-side (/catalog/municipios)
- Contato: DDD, telefone, email
- Comercial: forma pagamento, tabela de preços, limite crédito
- Feedback: código ERP exibido no sucesso; alerta de erro de sync sem bloquear

API:
- GET /catalog/municipios?q= — busca municipios da vw_municipios
- POST /clients/novo — INSERT em sar.clientes_novos; trigger sincroniza para sig.corrent
- Retorna { id, idCorrentErp, sincronizado, erroSync } após o trigger disparar
- Fix trigger fn_sync_cliente_to_erp: NULLIF para cgcpf vazio (evita violação
  do unique index key_corrent_cgcpf quando cliente não tem CPF/CNPJ)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:06:27 +00:00
ec0b7c1611 fix(triggers): advisory lock e fconta corretos em fn_sync_pedido_aprovado_to_erp
- FOR UPDATE é inválido com MAX() no PG 18; substituído por pg_advisory_xact_lock
- fconta=0 violava CHECK (1,2,3,4,5,6,9); corrigido para 1 (padrão SAR)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 16:52:10 +00:00
35d0ba68d6 feat(infra): triggers ERP + reset migrations Prisma para schema correto
Triggers SAR→ERP (scripts/sar-triggers.sql):
- trg_pedido_aprovado: ao aprovar (situa 1→2) em sar.pedidos, replica
  automaticamente em sig.pedidos + sig.peditens e grava erp_id_pedido
- trg_cliente_novo: INSERT em sar.clientes_novos replica em sig.corrent
  com todos os campos obrigatórios; grava id_corrent_erp e flag sincronizado
- Corrige trigger tsvectorupdate do ERP para PG 18 (bpchar→text cast)
- Adiciona coluna erp_id_pedido em sar.pedidos e tabela sar.clientes_novos

Migrations Prisma:
- Remove 5 migrations obsoletas com nomes PascalCase (Order, Client, etc.)
  que não batiam com os @@map snake_case do schema atual
- Cria baseline 20260624000000_init_baseline apontando para estado correto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:07:54 +00:00
6f16dc8274 fix(web): default [] para metasPorGrupo/pedidosRecentes/clientesInativos no RepPainel
Evita TypeError quando SW serve cache antigo sem esses campos no shape
da resposta do dashboard.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 21:54:27 +00:00
6fbf8bfb8e feat(orders): detalhe completo de pedidos ERP com produtos e pagamento
- Endpoint GET /orders/erp/:idPedido para pedidos do histórico ERP
  (endpoint estático antes de /:id com ParseUUIDPipe, sem conflito)
- JOIN vw_peditens_erp + vw_produtos: itens com codigo + descricao do produto
- forma_pagamento direto da vw_pedidos_erp (ex: "28/35/42 DIAS")
- Retorna PedidoDetail completo: totais, ipi, icmsst, comissao, obs
- Frontend: useOrderDetail detecta 'erp-*' → chama /orders/erp/{id}
- OrderDetailPage: Cond. Pagamento nas Descriptions; oculta botões
  Transmitir/Aprovar/Recusar para pedidos ERP (read-only)
- PedidoItemSchema.id relaxado de uuid() para string() (ERP usa '{id}-{ordem}')
- PedidoDetailSchema: campo formaPagamento opcional adicionado

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 21:49:53 +00:00
107 changed files with 14827 additions and 2842 deletions

View File

@@ -10,11 +10,6 @@ export default defineConfig({
datasource: {
// Prisma 7: url aqui serve apenas para o CLI (migrate/generate/studio).
// Runtime usa WorkspacePrismaPool → PrismaClient({ adapter: new PrismaPg(pool) }).
url:
process.env['DATABASE_URL'] ??
'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev',
},
migrations: {
seed: 'tsx prisma/seed.ts',
url: process.env['DATABASE_URL'],
},
});

View File

@@ -1,41 +0,0 @@
-- CreateEnum
CREATE TYPE "FinancialStatus" AS ENUM ('regular', 'attention', 'blocked');
-- CreateTable
CREATE TABLE "Client" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"tradeName" TEXT,
"taxId" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"address" JSONB,
"financialStatus" "FinancialStatus" NOT NULL DEFAULT 'regular',
"creditLimit" DECIMAL(15,2),
"repId" TEXT NOT NULL,
"lastOrderAt" TIMESTAMP(3),
"lastOrderValue" DECIMAL(15,2),
"openOrdersCount" INTEGER NOT NULL DEFAULT 0,
"erpCode" TEXT,
"syncedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "Client_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Client_taxId_key" ON "Client"("taxId");
-- CreateIndex
CREATE INDEX "Client_repId_idx" ON "Client"("repId");
-- CreateIndex
CREATE INDEX "Client_taxId_idx" ON "Client"("taxId");
-- CreateIndex
CREATE INDEX "Client_name_idx" ON "Client"("name");
-- CreateIndex
CREATE INDEX "Client_deletedAt_idx" ON "Client"("deletedAt");

View File

@@ -1,95 +0,0 @@
-- CreateEnum
CREATE TYPE "OrderStatus" AS ENUM ('budget', 'pending_approval', 'approved', 'invoiced', 'cancelled');
-- CreateTable
CREATE TABLE "Order" (
"id" UUID NOT NULL,
"number" TEXT NOT NULL,
"clientId" UUID NOT NULL,
"repId" TEXT NOT NULL,
"status" "OrderStatus" NOT NULL DEFAULT 'budget',
"discountPct" DECIMAL(5,2) NOT NULL DEFAULT 0,
"subtotal" DECIMAL(15,2) NOT NULL,
"total" DECIMAL(15,2) NOT NULL,
"notes" TEXT,
"approvedById" TEXT,
"approvedAt" TIMESTAMP(3),
"invoicedAt" TIMESTAMP(3),
"cancelledAt" TIMESTAMP(3),
"idempotencyKey" TEXT,
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderItem" (
"id" UUID NOT NULL,
"orderId" UUID NOT NULL,
"productCode" TEXT NOT NULL,
"productName" TEXT NOT NULL,
"quantity" DECIMAL(10,3) NOT NULL,
"unitPrice" DECIMAL(15,2) NOT NULL,
"discountPct" DECIMAL(5,2) NOT NULL DEFAULT 0,
"subtotal" DECIMAL(15,2) NOT NULL,
CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderStatusHistory" (
"id" UUID NOT NULL,
"orderId" UUID NOT NULL,
"fromStatus" "OrderStatus",
"toStatus" "OrderStatus" NOT NULL,
"changedById" TEXT NOT NULL,
"note" TEXT,
"changedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrderStatusHistory_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Order_number_key" ON "Order"("number");
-- CreateIndex
CREATE UNIQUE INDEX "Order_idempotencyKey_key" ON "Order"("idempotencyKey");
-- CreateIndex
CREATE INDEX "Order_clientId_idx" ON "Order"("clientId");
-- CreateIndex
CREATE INDEX "Order_repId_idx" ON "Order"("repId");
-- CreateIndex
CREATE INDEX "Order_status_idx" ON "Order"("status");
-- CreateIndex
CREATE INDEX "Order_issuedAt_idx" ON "Order"("issuedAt");
-- CreateIndex
CREATE INDEX "Order_number_idx" ON "Order"("number");
-- CreateIndex
CREATE INDEX "Order_deletedAt_idx" ON "Order"("deletedAt");
-- CreateIndex
CREATE INDEX "OrderItem_orderId_idx" ON "OrderItem"("orderId");
-- CreateIndex
CREATE INDEX "OrderStatusHistory_orderId_idx" ON "OrderStatusHistory"("orderId");
-- CreateIndex
CREATE INDEX "OrderStatusHistory_changedAt_idx" ON "OrderStatusHistory"("changedAt");
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderStatusHistory" ADD CONSTRAINT "OrderStatusHistory_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,52 +0,0 @@
-- AlterTable
ALTER TABLE "OrderItem" ADD COLUMN "productCategory" TEXT NOT NULL DEFAULT 'geral';
-- CreateTable
CREATE TABLE "Product" (
"id" UUID NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"category" TEXT NOT NULL DEFAULT 'geral',
"unitPrice" DECIMAL(15,2) NOT NULL,
"stock" DECIMAL(10,3),
"active" BOOLEAN NOT NULL DEFAULT true,
"erpCode" TEXT,
"syncedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RepDiscountLimit" (
"repId" TEXT NOT NULL,
"category" TEXT NOT NULL,
"limit" DECIMAL(5,2) NOT NULL DEFAULT 5,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RepDiscountLimit_pkey" PRIMARY KEY ("repId","category")
);
-- CreateIndex
CREATE UNIQUE INDEX "Product_code_key" ON "Product"("code");
-- CreateIndex
CREATE INDEX "Product_code_idx" ON "Product"("code");
-- CreateIndex
CREATE INDEX "Product_name_idx" ON "Product"("name");
-- CreateIndex
CREATE INDEX "Product_category_idx" ON "Product"("category");
-- CreateIndex
CREATE INDEX "Product_active_idx" ON "Product"("active");
-- CreateIndex
CREATE INDEX "Product_deletedAt_idx" ON "Product"("deletedAt");
-- CreateIndex
CREATE INDEX "RepDiscountLimit_repId_idx" ON "RepDiscountLimit"("repId");

View File

@@ -1,15 +0,0 @@
-- CreateTable
CREATE TABLE "RepTarget" (
"repId" TEXT NOT NULL,
"year" INTEGER NOT NULL,
"month" INTEGER NOT NULL,
"targetAmount" DECIMAL(15,2) NOT NULL,
"commissionRate" DECIMAL(5,2) NOT NULL DEFAULT 3,
"flexRate" DECIMAL(5,2) NOT NULL DEFAULT 1,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RepTarget_pkey" PRIMARY KEY ("repId","year","month")
);
-- CreateIndex
CREATE INDEX "RepTarget_repId_idx" ON "RepTarget"("repId");

View File

@@ -1,22 +0,0 @@
-- CreateTable
CREATE TABLE "PushSubscription" (
"id" UUID NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
-- CreateIndex
CREATE INDEX "PushSubscription_userId_idx" ON "PushSubscription"("userId");
-- CreateIndex
CREATE INDEX "PushSubscription_role_idx" ON "PushSubscription"("role");

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,5 @@
-- Endereco de entrega alternativo do pedido (feature "novo pedido v2").
-- O campo existia no schema.prisma sem migration correspondente.
-- Idempotente: provision-client.ts roda sar-erp-schema.sql antes do migrate deploy.
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
ALTER TABLE sar.pedidos ADD COLUMN IF NOT EXISTS end_entrega TEXT;

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

@@ -45,6 +45,7 @@ model Pedido {
comissao Decimal @default(0) @db.Decimal(15, 2)
pedFlex Decimal @default(0) @db.Decimal(15, 2) @map("ped_flex")
obs String?
endEntrega String? @map("end_entrega")
aprovadoPor Int? @map("aprovado_por")
aprovadoEm DateTime? @map("aprovado_em")
motivoRecusa String? @map("motivo_recusa")
@@ -147,6 +148,123 @@ model MetaRepresentante {
@@map("meta_representante")
}
// ─── Promocao (C8) ───────────────────────────────────────────────────────────
//
// Promoção comercial com validade, criada pelo Gerente. Pode ser por produto
// ou por grupo de produto. descPct em % (ex.: 5.00 = 5%).
model Promocao {
id Int @id @default(autoincrement())
idEmpresa Int @map("id_empresa")
descricao String
codProduto String? @map("cod_produto")
grpProd String? @map("grp_prod")
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("promocoes")
}
// ─── RegraDesconto (Políticas Comerciais) ────────────────────────────────────
//
// Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou
// subgrupo de produto e/ou representante, com vigência. Campos nulos = "todos".
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")
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")
}
// ─── 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) ───────────────────────────────────────────────────
//
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,11 @@ import { OrdersModule } from './orders/orders.module';
import { CatalogModule } from './catalog/catalog.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { NotificationsModule } from './notifications/notifications.module';
import { ReportsModule } from './reports/reports.module';
import { PoliticasModule } from './politicas/politicas.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';
@Module({
@@ -29,6 +34,11 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
CatalogModule,
DashboardModule,
NotificationsModule,
ReportsModule,
PoliticasModule,
EquipeModule,
FunilModule,
SacModule,
],
providers: [
{ provide: APP_PIPE, useClass: ZodValidationPipe },

View File

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

View File

@@ -51,17 +51,21 @@ export class JwtAuthGuard implements CanActivate {
(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 prod: usa os valores reais do JWT.
const idEmpresa = this.isProd ? payload.id_empresa : this.devEmpresaId;
const userId = this.isProd ? payload.sub : this.devRepCode;
// Em dev: usa sub/id_empresa do próprio JWT (emitido pelo /auth/dev/token,
// que permite logar como Pavei/Sidnei/Lucas) e cai para DEV_REP_CODE /
// DEV_EMPRESA_ID apenas se o token não trouxer os campos.
// 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('userId', userId);
this.cls.set('role', payload.role);
const baseUrl =
process.env['DATABASE_URL'] ??
'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev';
const baseUrl = process.env['DATABASE_URL'];
if (!baseUrl) throw new Error('DATABASE_URL não configurada');
this.cls.set('prisma', this.pool.getOrCreate(idEmpresa, baseUrl));
return true;

View File

@@ -4,6 +4,7 @@ import {
ProdutoListQuerySchema,
type EmpresaInfo,
type FormaPagamento,
type Municipio,
type Pauta,
type ProdutoDetail,
type ProdutoListQuery,
@@ -19,6 +20,11 @@ class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {}
export class CatalogController {
constructor(private readonly catalog: CatalogService) {}
@Get('municipios')
municipios(@Query('q') q?: string): Promise<Municipio[]> {
return this.catalog.municipios(q);
}
@Get('pautas')
pautas(): Promise<Pauta[]> {
return this.catalog.pautas();

View File

@@ -3,6 +3,7 @@ import { ClsService } from 'nestjs-cls';
import type {
EmpresaInfo,
FormaPagamento,
Municipio,
Pauta,
ProdutoDetail,
ProdutoListQuery,
@@ -84,15 +85,15 @@ export class CatalogService {
TRIM(e.cnpj) AS cnpj,
TRIM(e.inscr_estadual) AS inscr_estadual,
TRIM(e.endereco) AS endereco,
NULLIF(e.numero, 0)::text AS numero,
NULLIF(TRIM(e.complemento), '.') AS complemento,
e.num_endereco::text AS numero,
e.complemento,
TRIM(e.bairro) AS bairro,
TRIM(m.nome) AS cidade,
TRIM(e.estado::text) AS uf,
TRIM(e.cep::text) AS cep,
TRIM(e.telefone::text) AS telefone,
TRIM(e.uf) AS uf,
e.cep,
e.telefone,
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
WHERE e.id_empresa = ${idEmpresa}
LIMIT 1
@@ -122,6 +123,35 @@ export class CatalogService {
};
}
async municipios(q?: string): Promise<Municipio[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
interface Row {
id_municipio: number;
nome: string;
uf: string;
codigo_ibge: number | null;
}
const searchFilter = q ? `WHERE UPPER(nome) LIKE UPPER('%${escSql(q)}%')` : '';
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT id_municipio, TRIM(nome) AS nome, uf::text AS uf, codigo_ibge
FROM sar.vw_municipios
${searchFilter}
ORDER BY nome
LIMIT 100
`);
return rows.map((r) => ({
idMunicipio: Number(r.id_municipio),
nome: r.nome,
uf: r.uf,
codigoIbge: r.codigo_ibge !== null ? Number(r.codigo_ibge) : null,
}));
}
async pautas(): Promise<Pauta[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -253,6 +283,7 @@ export class CatalogService {
ativo: Number(p.ativo),
qtdEstoque: p.qtd_estoque,
listaParauta: p.lista_pauta !== null ? Number(p.lista_pauta) : null,
loteMulVenda: p.lote_mul_venda !== null ? Number(p.lote_mul_venda) : null,
};
}
@@ -260,8 +291,18 @@ export class CatalogService {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const rows = await prisma.$queryRawUnsafe<ProdutoRow[]>(`
interface PautaPrecoRow {
id_pauta: number;
codigo: number;
descricao: string;
preco: string;
}
const [rows, pautaRows] = await Promise.all([
prisma.$queryRawUnsafe<ProdutoRow[]>(`
SELECT
p.id_erp,
p.codigo,
@@ -289,7 +330,23 @@ export class CatalogService {
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
WHERE p.id_erp = ${idErp} AND p.ativo = 1
LIMIT 1
`);
`),
prisma.$queryRawUnsafe<PautaPrecoRow[]>(`
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao, pp.preco1::text AS preco
FROM vw_pauta_produtos pp
JOIN vw_pautas pa ON pa.id_pauta = pp.id_pauta
AND pa.id_empresa = ${idEmpresa}
AND pa.ativo = 1
JOIN vw_representantes r ON pa.codigo IN (
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
)
WHERE pp.id_produto = ${idErp}
AND r.codigo = ${codVendedor}
AND pp.preco1 > 0
ORDER BY pa.codigo
`),
]);
const p = rows[0];
if (!p) return null;
@@ -306,6 +363,12 @@ export class CatalogService {
loteMulVenda: p.lote_mul_venda !== null ? Number(p.lote_mul_venda) : null,
precoComIpi: null,
precoPromocional: p.preco_promocional,
pautaPrecos: pautaRows.map((r) => ({
idPauta: Number(r.id_pauta),
codigo: Number(r.codigo),
descricao: r.descricao,
preco: r.preco,
})),
};
}

View File

@@ -1,14 +1,28 @@
import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import {
ClientListQuerySchema,
CreateClientNovoSchema,
CreateContatoSchema,
type ClientDetail,
type ClientListQuery,
type ClientListResponse,
type ClientNovoResult,
type Contato,
type ContatoResult,
type CtrSummary,
type CreateClientNovo,
type CreateContato,
type CtrTitulo,
type NotaFiscal,
type PedidoSummary,
type TopProduto,
} from '@sar/api-interface';
import { ClientsService } from './clients.service';
class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
class CreateClientNovoDto extends createZodDto(CreateClientNovoSchema) {}
class CreateContatoDto extends createZodDto(CreateContatoSchema) {}
@Controller({ path: 'clients' })
export class ClientsController {
@@ -20,6 +34,67 @@ export class ClientsController {
return this.clients.list(parsed);
}
@Post('novo')
createNovo(@Body() body: CreateClientNovoDto): Promise<ClientNovoResult> {
const parsed = CreateClientNovoSchema.parse(body) as CreateClientNovo;
return this.clients.createNovo(parsed);
}
@Get(':id/notas')
listNotasFiscais(
@Param('id', ParseIntPipe) id: number,
@Query('limit') limit?: string,
): Promise<NotaFiscal[]> {
const n = limit ? Math.min(parseInt(limit, 10) || 30, 100) : 30;
return this.clients.listNotasFiscais(id, n);
}
@Get(':id/top-produtos')
listTopProdutos(
@Param('id', ParseIntPipe) id: number,
@Query('limit') limit?: string,
): Promise<TopProduto[]> {
const n = limit ? Math.min(parseInt(limit, 10) || 30, 100) : 30;
return this.clients.listTopProdutos(id, n);
}
@Get(':id/orders-history')
listOrdersHistory(
@Param('id', ParseIntPipe) id: number,
@Query('limit') limit?: string,
): Promise<PedidoSummary[]> {
const n = limit ? Math.min(parseInt(limit, 10) || 50, 200) : 50;
return this.clients.listOrdersHistory(id, n);
}
@Get(':id/ctr')
getCtrSummary(@Param('id', ParseIntPipe) id: number): Promise<CtrSummary> {
return this.clients.getCtrSummary(id);
}
@Get(':id/ctr-list')
listCtrTitulos(
@Param('id', ParseIntPipe) id: number,
@Query('limit') limit?: string,
): Promise<CtrTitulo[]> {
const n = limit ? Math.min(parseInt(limit, 10) || 60, 200) : 60;
return this.clients.listCtrTitulos(id, n);
}
@Get(':id/contacts')
listContacts(@Param('id', ParseIntPipe) id: number): Promise<Contato[]> {
return this.clients.listContacts(id);
}
@Post(':id/contacts')
createContato(
@Param('id', ParseIntPipe) id: number,
@Body() body: CreateContatoDto,
): Promise<ContatoResult> {
const parsed = CreateContatoSchema.parse(body) as CreateContato;
return this.clients.createContato(id, parsed);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number): Promise<ClientDetail> {
return this.clients.findOne(id);

View File

@@ -1,13 +1,24 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
ActivityStatus,
ClientDetail,
ClientListQuery,
ClientListResponse,
ClientNovoResult,
ClientSummary,
ActivityStatus,
Contato,
ContatoResult,
CtrSummary,
CtrTitulo,
CreateClientNovo,
CreateContato,
NotaFiscal,
PedidoSummary,
TopProduto,
} from '@sar/api-interface';
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.
const ALERT_DAYS = 30;
@@ -26,6 +37,12 @@ function escSql(s: string): string {
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
interface ClientRow {
id_cliente: number;
@@ -98,6 +115,29 @@ const ACTIVITY_CASE = (alias_erp = 'erp_ped', alias_sar = 'sar_ped') => `
export class ClientsService {
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> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -109,8 +149,13 @@ export class ClientsService {
const { q, status, page, limit } = query;
const offset = (page - 1) * limit;
// Rep vê apenas sua carteira (cod_vendedor = seu código)
const vendedorFilter = role === 'rep' ? `AND c.cod_vendedor = ${codVendedor}` : '';
// Rep vê apenas sua carteira; supervisor vê as carteiras da equipe
const vendedorFilter =
role === 'rep'
? `AND c.cod_vendedor = ${codVendedor}`
: role === 'supervisor'
? `AND c.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})`
: '';
const searchFilter = q
? `AND (c.nome ILIKE '%${escSql(q)}%' OR c.cgcpf LIKE '%${escSql(q)}%')`
: '';
@@ -187,7 +232,345 @@ export class ClientsService {
return { data: mapped, total, page, limit };
}
async createNovo(dto: CreateClientNovo): Promise<ClientNovoResult> {
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');
const codVendedor = userId ? parseInt(userId, 10) : 0;
interface Row {
id: string;
id_corrent_erp: number | null;
sincronizado: boolean;
erro_sync: string | null;
}
const esc = (s: string) => s.replace(/'/g, "''");
const opt = (v: string | undefined) => (v != null ? `'${esc(v)}'` : 'NULL');
const optN = (v: number | undefined) => (v != null ? String(v) : 'NULL');
// INSERT retorna id — RETURNING lê valores pré-trigger, então
// fazemos SELECT separado para capturar o que o trigger gravou
const insertRows = await prisma.$queryRawUnsafe<[{ id: string }]>(`
INSERT INTO sar.clientes_novos (
id_empresa, cod_vendedor, pesso, consfinal,
nome, razao, cgcpf, suf_cgcpf, inscr, indicador_ie,
endereco, num_endereco, bairro, id_municipio, cep,
ddd, telefone, email, limite_credito, cod_formapag, cod_pauta
) VALUES (
${idEmpresa}, ${codVendedor}, ${dto.pesso}, ${dto.consfinal},
'${esc(dto.nome)}', '${esc(dto.razao)}',
${opt(dto.cgcpf)}, '${esc(dto.sufCgcpf ?? '')}',
'${esc(dto.inscr ?? '')}', ${optN(dto.indicadorIe)},
'${esc(dto.endereco ?? '')}', '${esc(dto.numEndereco ?? '')}',
'${esc(dto.bairro ?? '')}', ${dto.idMunicipio}, '${esc(dto.cep ?? '')}',
'${esc(dto.ddd ?? '')}', '${esc(dto.telefone ?? '')}',
'${esc(dto.email ?? '')}', ${dto.limiteCredito ?? 0},
${optN(dto.codFormapag)}, ${optN(dto.codPauta)}
)
RETURNING id::text
`);
const id = insertRows[0]?.id;
if (!id) throw new Error('Falha ao inserir cliente');
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT id::text, id_corrent_erp, sincronizado, erro_sync
FROM sar.clientes_novos WHERE id = '${id}'
`);
const r = rows[0];
if (!r) throw new Error('Falha ao recuperar cliente inserido');
return {
id: r.id,
idCorrentErp: r.id_corrent_erp !== null ? Number(r.id_corrent_erp) : null,
sincronizado: r.sincronizado,
erroSync: r.erro_sync,
};
}
async listContacts(idCorrent: number): Promise<Contato[]> {
await this.assertClienteDaCarteira(idCorrent);
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
interface Row {
id_contato: number;
id_empresa: number;
id_corrent: number;
id_chatwoot: number | null;
nome: string;
empresa: string | null;
cargo: string | null;
departamento: string | null;
dt_aniversario: string | null;
telefone: string | null;
ramal: string | null;
celular: string | null;
whatsapp: string | null;
email: string | null;
ativo: number;
anotacoes: string | null;
nome_cliente: string | null;
razao_cliente: string | null;
cod_vendedor: number | null;
}
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT id_contato, id_empresa, id_corrent, id_chatwoot,
nome, empresa, cargo, departamento,
dt_aniversario::text, telefone, ramal, celular, whatsapp, email,
ativo, anotacoes, nome_cliente, razao_cliente, cod_vendedor
FROM sar.vw_contatos
WHERE id_corrent = ${idCorrent} AND ativo = 1
ORDER BY nome
`);
return rows.map((r) => ({
idContato: Number(r.id_contato),
idEmpresa: Number(r.id_empresa),
idCorrent: Number(r.id_corrent),
idChatwoot: r.id_chatwoot !== null ? Number(r.id_chatwoot) : null,
nome: r.nome,
empresa: r.empresa,
cargo: r.cargo,
departamento: r.departamento,
dtAniversario: r.dt_aniversario,
telefone: r.telefone,
ramal: r.ramal,
celular: r.celular,
whatsapp: r.whatsapp,
email: r.email,
ativo: Number(r.ativo),
anotacoes: r.anotacoes,
nomeCliente: r.nome_cliente,
razaoCliente: r.razao_cliente,
codVendedor: r.cod_vendedor !== null ? Number(r.cod_vendedor) : null,
}));
}
async createContato(idCorrent: number, dto: CreateContato): Promise<ContatoResult> {
await this.assertClienteDaCarteira(idCorrent);
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');
const codVendedor = userId ? parseInt(userId, 10) : 0;
interface Row {
id: string;
}
const esc = (s: string) => s.replace(/'/g, "''");
const opt = (v: string | undefined) => (v ? `'${esc(v)}'` : 'NULL');
const insertRows = await prisma.$queryRawUnsafe<Row[]>(`
INSERT INTO sar.contatos_novos (
id_empresa, cod_vendedor, id_corrent,
nome, empresa, cargo, departamento,
dt_aniversario, telefone, ramal, celular, whatsapp, email, anotacoes
) VALUES (
${idEmpresa}, ${codVendedor}, ${idCorrent},
'${esc(dto.nome)}',
${opt(dto.empresa)}, ${opt(dto.cargo)}, ${opt(dto.departamento)},
${dto.dtAniversario ? `'${esc(dto.dtAniversario)}'` : 'NULL'},
${opt(dto.telefone)}, ${opt(dto.ramal)}, ${opt(dto.celular)},
${opt(dto.whatsapp)}, ${opt(dto.email)}, ${opt(dto.anotacoes)}
)
RETURNING id::text
`);
const id = insertRows[0]?.id;
if (!id) throw new Error('Falha ao inserir contato');
interface ResultRow {
id: string;
id_contato_erp: number | null;
sincronizado: boolean;
erro_sync: string | null;
}
const rows = await prisma.$queryRawUnsafe<ResultRow[]>(`
SELECT id::text, id_contato_erp, sincronizado, erro_sync
FROM sar.contatos_novos WHERE id = '${id}'
`);
const r = rows[0];
if (!r) throw new Error('Falha ao recuperar contato inserido');
return {
id: r.id,
idContatoErp: r.id_contato_erp !== null ? Number(r.id_contato_erp) : null,
sincronizado: r.sincronizado,
erroSync: r.erro_sync,
};
}
async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
interface Row {
id: string;
num_ped: string;
situa: number;
dt_pedido: Date;
total: string;
fonte: string;
}
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT id::text AS id,
num_ped_sar AS num_ped,
situa,
dt_pedido,
total::text,
'sar' AS fonte
FROM sar.pedidos
WHERE id_cliente = ${idCliente}
AND id_empresa = ${idEmpresa}
UNION ALL
SELECT ('erp-' || id_pedido::text) AS id,
COALESCE(NULLIF(TRIM(num_ped_sar::text),''), numero::text) AS num_ped,
CASE WHEN situa = 5 THEN 3 ELSE situa END AS situa,
dt_pedido,
total::text,
'erp' AS fonte
FROM sar.vw_pedidos_erp
WHERE id_cliente = ${idCliente}
AND id_empresa = ${idEmpresa}
AND situa != 5
ORDER BY dt_pedido DESC
LIMIT ${limit}
`);
return rows.map((r) => ({
id: r.id,
numPedSar: r.num_ped ?? r.id,
idCliente,
nomeCliente: null,
razaoCliente: null,
codVendedor: 0,
nomeVendedor: null,
situa: Number(r.situa),
dtPedido: r.dt_pedido.toISOString(),
total: r.total ?? '0',
descontoPerc: '0',
obs: null,
createdAt: r.dt_pedido.toISOString(),
fonte: r.fonte as 'sar' | 'erp',
}));
}
async listTopProdutos(idCliente: number, limit: number): Promise<TopProduto[]> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
// Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
interface Row {
id_produto: number;
codigo: string;
descricao: string;
unidade: string | null;
qtd_total: string;
num_pedidos: string;
ultima_compra: Date;
ultimo_preco: string;
}
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT
i.id_produto,
TRIM(p.codigo) AS codigo,
TRIM(p.descricao) AS descricao,
TRIM(p.unidade) AS unidade,
SUM(i.qtd)::numeric(15,3)::text AS qtd_total,
COUNT(DISTINCT ped.id_pedido)::text AS num_pedidos,
MAX(ped.dt_pedido) AS ultima_compra,
(
SELECT i2.preco_unitario::numeric(15,2)::text
FROM sar.vw_peditens_erp i2
JOIN sar.vw_pedidos_erp p2 ON p2.id_pedido = i2.id_pedido
WHERE i2.id_produto = i.id_produto
AND p2.id_cliente = ped.id_cliente
AND p2.id_empresa = ${idEmpresa}
AND p2.situa NOT IN (5)
ORDER BY p2.dt_pedido DESC, p2.id_pedido DESC
LIMIT 1
) AS ultimo_preco
FROM sar.vw_pedidos_erp ped
JOIN sar.vw_peditens_erp i ON i.id_pedido = ped.id_pedido
JOIN sar.vw_produtos p ON p.id_erp = i.id_produto
AND p.id_empresa = ${idEmpresaMatriz}
WHERE ped.id_cliente = ${idCliente}
AND ped.id_empresa = ${idEmpresa}
AND ped.situa NOT IN (5)
GROUP BY i.id_produto, p.codigo, p.descricao, p.unidade, ped.id_cliente
ORDER BY SUM(i.qtd) DESC
LIMIT ${limit}
`);
return rows.map((r) => ({
idProduto: Number(r.id_produto),
codProduto: r.codigo ?? '',
descricao: r.descricao,
unidade: r.unidade,
qtdTotal: parseFloat(r.qtd_total ?? '0'),
numPedidos: parseInt(r.num_pedidos ?? '0', 10),
ultimaCompra: (r.ultima_compra as Date).toISOString().slice(0, 10),
ultimoPreco: parseFloat(r.ultimo_preco ?? '0'),
}));
}
async getCtrSummary(idCliente: number): Promise<CtrSummary> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
interface Row {
qtd_aberto: string;
total_aberto: string;
qtd_vencido: string;
total_vencido: string;
maior_atraso_dias: string;
}
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT
COUNT(*)::text AS qtd_aberto,
COALESCE(SUM(saldo), 0)::numeric(15,2)::text AS total_aberto,
(COUNT(*) FILTER (WHERE dt_vencimento < CURRENT_DATE))::text AS qtd_vencido,
COALESCE(SUM(saldo) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::numeric(15,2)::text AS total_vencido,
COALESCE(MAX(CURRENT_DATE - dt_vencimento) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::text AS maior_atraso_dias
FROM sar.vw_ctr
WHERE id_cliente = ${idCliente}
AND id_empresa = ${idEmpresaMatriz}
AND situacao = 'A'
AND saldo > 0
`);
const r = rows[0];
return {
qtdAberto: parseInt(r?.qtd_aberto ?? '0', 10),
totalAberto: parseFloat(r?.total_aberto ?? '0'),
qtdVencido: parseInt(r?.qtd_vencido ?? '0', 10),
totalVencido: parseFloat(r?.total_vencido ?? '0'),
maiorAtrasoDias: parseInt(r?.maior_atraso_dias ?? '0', 10),
};
}
async findOne(idCliente: number): Promise<ClientDetail> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
@@ -211,6 +594,7 @@ export class ClientsService {
return {
idCliente: Number(r.id_cliente),
idEmpresa: Number(r.id_empresa),
nome: r.nome,
razao: r.razao,
@@ -236,4 +620,115 @@ export class ClientsService {
dtAtual: r.dt_atual,
};
}
async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
type CtrRow = {
id_ctr: number;
numero: string;
prefixo: string;
dt_emissao: Date;
dt_vencimento: Date;
saldo: string;
};
const rows = await prisma.$queryRawUnsafe<CtrRow[]>(
`
SELECT
id_ctr,
TRIM(numero) AS numero,
TRIM(prefixo) AS prefixo,
dt_emissao,
dt_vencimento,
saldo::text AS saldo
FROM sar.vw_ctr
WHERE id_cliente = $1
AND id_empresa = $3
AND situacao = 'A'
AND saldo > 0
ORDER BY dt_vencimento ASC
LIMIT $2
`,
idCliente,
limit,
idEmpresaMatriz,
);
const toDate = (d: Date | string): string =>
d instanceof Date ? d.toISOString().slice(0, 10) : String(d).slice(0, 10);
return rows.map((r: CtrRow) => ({
idCtr: Number(r.id_ctr),
numero: r.numero,
prefixo: r.prefixo,
dtEmissao: toDate(r.dt_emissao),
dtVencimento: toDate(r.dt_vencimento),
saldo: Number(r.saldo),
}));
}
async listNotasFiscais(idCliente: number, limit: number): Promise<NotaFiscal[]> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma');
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 = {
id_nf: number;
numero: number;
serie: string;
dt_emissao: Date;
vl_nf: string;
chave_acesso_nfe: string | null;
};
const rows = await prisma.$queryRawUnsafe<NfRow[]>(
`
SELECT id_nf, numero, serie, dt_emissao, vl_nf, chave_acesso_nfe
FROM (
SELECT DISTINCT ON (nf.id_nf)
nf.id_nf,
nf.numero,
TRIM(nf.serie) AS serie,
nf.dt_emissao,
nf.vl_nf::text AS vl_nf,
TRIM(nf.chave_acesso_nfe) AS chave_acesso_nfe
FROM sar.vw_nf nf
JOIN sar.vw_pedidos_erp p
ON p.numero = nf.num_entrega
AND p.tipo = 'E'
AND p.id_empresa IN ($3, $4)
WHERE p.id_cliente = $1
AND nf.id_empresa = $3
AND nf.status = 'E'
AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != ''
ORDER BY nf.id_nf
) sub
ORDER BY dt_emissao DESC
LIMIT $2
`,
idCliente,
limit,
idEmpresaMatriz,
idEmpresaFiscal,
);
return rows.map((r: NfRow) => ({
idNf: Number(r.id_nf),
numero: Number(r.numero),
serie: r.serie?.trim() ?? '',
dtEmissao:
(r.dt_emissao instanceof Date
? r.dt_emissao.toISOString().split('T')[0]
: String(r.dt_emissao)) ?? '',
vlNf: Number(r.vl_nf),
chaveAcessoNfe: r.chave_acesso_nfe?.trim() || null,
}));
}
}

View File

@@ -1,6 +1,6 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, ForbiddenException, Get, Query } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { RepDashboard, SupervisorDashboard } from '@sar/api-interface';
import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { DashboardService } from './dashboard.service';
@@ -11,6 +11,15 @@ export class DashboardController {
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')
repDashboard(): Promise<RepDashboard> {
return this.dashboard.repDashboard(this.cls.get('userId') ?? '');
@@ -18,6 +27,19 @@ export class DashboardController {
@Get('supervisor')
supervisorDashboard(): Promise<SupervisorDashboard> {
this.assertGestor();
return this.dashboard.supervisorDashboard();
}
@Get('manager')
managerDashboard(
@Query('mes') mes?: string,
@Query('ano') ano?: string,
): Promise<ManagerDashboard> {
this.assertGestor();
return this.dashboard.managerDashboard(
mes ? parseInt(mes, 10) : undefined,
ano ? parseInt(ano, 10) : undefined,
);
}
}

View File

@@ -1,7 +1,14 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { RepDashboard, SupervisorDashboard } from '@sar/api-interface';
import type {
RepDashboard,
SupervisorDashboard,
ManagerDashboard,
RankingRep,
PositivacaoRep,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util';
// Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado
// Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
@@ -35,16 +42,14 @@ interface RealizadoGrupoRow {
peso: string;
}
interface RepRow {
taxa_com: string;
permitir_flex: number; // 0 ou 1 (char do ERP convertido)
}
interface InativoRow {
interface NaoPositivadoRow {
id_cliente: number;
nome: string;
dt_ultima_compra: Date | null;
ultima_compra_valor: string | null;
razao: string | null;
dt_ultimo_pedido: string | null; // YYYY-MM-DD ou null
dias_sem_pedido: number | null;
comprou_antes: boolean;
whatsapp: string | null;
}
interface InativosPorRepRow {
@@ -92,24 +97,7 @@ export class DashboardService {
: grRows.reduce((a, m) => a + Number(m.valor), 0);
const metaDimensao = grRows.length > 0 ? ('grupo' as const) : ('global' as const);
// 2. Taxas do representante — fonte: gestao.vendedor (via vw_representantes)
const repRows = await prisma.$queryRawUnsafe<RepRow[]>(`
SELECT taxa_com::text, COALESCE(permitir_flex, 0) AS permitir_flex
FROM vw_representantes
WHERE codigo = ${codVendedor}
LIMIT 1
`);
const commissionRate = repRows[0] ? Number(repRows[0].taxa_com) : 3;
const permitirFlex = (repRows[0]?.permitir_flex ?? 0) === 1;
// 3. Taxa flex — fonte: sar.meta_representante (override SAR; default 1%)
const flexOverride = await prisma.metaRepresentante.findUnique({
where: { codVendedor_idEmpresa_ano_mes: { codVendedor, idEmpresa, ano: year, mes: month } },
select: { taxaFlex: true },
});
const flexRate = flexOverride ? Number(flexOverride.taxaFlex) : 1;
// 4. Atingido do mês — realizado = tudo menos Cancelado(5) e Pendente/não-transmitido(1).
// 2. Atingido do mês — realizado = tudo menos Cancelado(5) e Pendente/não-transmitido(1).
// Inclui Liberado(2), Enviado(3,6,92,95,200) e Faturado(4). Base: data do pedido.
const monthStartStr = monthStart.toISOString().slice(0, 10);
const monthEndStr = monthEnd.toISOString().slice(0, 10);
@@ -137,7 +125,24 @@ export class DashboardService {
obs: string | null;
}
const [atingidoRows, pedidosMesRows, recentRows, realizadoGrupoRows] = await Promise.all([
interface RealizadoMesRow {
mes: string;
valor: string;
}
interface MetaMesRow {
mes: string;
tipo: string;
valor: string;
}
const [
atingidoRows,
pedidosMesRows,
recentRows,
realizadoGrupoRows,
realizadoMesRows,
metaMesRows,
] = await Promise.all([
prisma.$queryRawUnsafe<TotalRow[]>(`
SELECT COALESCE(SUM(total), 0)::text AS total
FROM vw_pedidos_erp
@@ -191,6 +196,30 @@ export class DashboardService {
AND e.dt_pedido <= '${monthEndStr}'
GROUP BY p.cod_grupo, grupo
`),
// Realizado por mês do ano corrente.
prisma.$queryRawUnsafe<RealizadoMesRow[]>(`
SELECT EXTRACT(MONTH FROM dt_pedido)::int::text AS mes,
COALESCE(SUM(total), 0)::text AS valor
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND cod_vendedor = ${codVendedor}
AND situa NOT IN (1, 5)
AND EXTRACT(YEAR FROM dt_pedido) = ${year}
GROUP BY mes
ORDER BY mes
`),
// Meta (GL + GR) por mês do ano corrente.
prisma.$queryRawUnsafe<MetaMesRow[]>(`
SELECT mes::text, TRIM(tipo) AS tipo,
COALESCE(SUM(valor), 0)::text AS valor
FROM vw_metas
WHERE id_empresa = ${idEmpresaMatriz}
AND cod_vendedor = ${codVendedor}
AND ano = ${year}
AND TRIM(tipo) IN ('GL', 'GR')
GROUP BY mes, tipo
ORDER BY mes
`),
]);
const atingido = Number(atingidoRows[0]?.total ?? 0);
@@ -198,32 +227,47 @@ export class DashboardService {
const pct = targetAmount > 0 ? Math.round((atingido / targetAmount) * 100) : 0;
const falta = Math.max(0, targetAmount - atingido);
const fixa = Math.round(atingido * commissionRate) / 100;
const flex =
permitirFlex && targetAmount > 0 && atingido >= targetAmount
? Math.round(atingido * flexRate) / 100
: 0;
// 7. Clientes inativos — sem pedido no ERP há >30 dias
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const inactiveClients = await prisma.$queryRawUnsafe<InativoRow[]>(`
// Clientes não positivados no mês: ativos do representante sem pedido desde monthStart.
// Junta o último pedido histórico (ERP+SAR) e o primeiro whatsapp ativo dos contatos.
const naoPositivadosRows = await prisma.$queryRawUnsafe<NaoPositivadoRow[]>(`
WITH ultimo_pedido AS (
SELECT id_cliente,
MAX(dt_pedido) AS dt_max
FROM vw_pedidos_erp
WHERE situa NOT IN (5)
AND id_empresa = ${idEmpresa}
GROUP BY id_cliente
),
pedido_mes AS (
SELECT DISTINCT id_cliente
FROM vw_pedidos_erp
WHERE situa NOT IN (5)
AND id_empresa = ${idEmpresa}
AND dt_pedido >= '${monthStartStr}'
)
SELECT
c.id_cliente,
c.nome,
MAX(p.dt_pedido) AS dt_ultima_compra,
MAX(p.total)::text AS ultima_compra_valor
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 vw_pedidos_erp p
ON p.id_cliente = c.id_cliente
AND p.id_empresa = ${idEmpresa}
AND p.situa != 5
LEFT JOIN ultimo_pedido up ON up.id_cliente = c.id_cliente
LEFT JOIN pedido_mes pm ON pm.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
GROUP BY c.id_cliente, c.nome
HAVING MAX(p.dt_pedido) IS NULL
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
ORDER BY dt_ultima_compra ASC NULLS FIRST
LIMIT 10
AND pm.id_cliente IS NULL
ORDER BY up.dt_max ASC NULLS FIRST
`);
// Metas por grupo: junta meta (GR) com realizado (itens), por cod_grupo.
@@ -257,12 +301,48 @@ export class DashboardService {
})
.sort((a, b) => b.valorMeta - a.valorMeta);
// Deduplica por idCliente — vw_clientes pode retornar o mesmo cliente em
// mais de uma empresa; mantemos a primeira ocorrência (ORDER BY dt_max ASC NULLS FIRST).
const seenNP = new Set<number>();
const naoPositivados = naoPositivadosRows
.filter((c) => {
const id = Number(c.id_cliente);
if (seenNP.has(id)) return false;
seenNP.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,
}));
// Monta histório mensal: 12 meses do ano, cruzando realizado e meta.
const realizadoPorMes = new Map(realizadoMesRows.map((r) => [Number(r.mes), Number(r.valor)]));
const metaGlPorMes = new Map<number, number>();
const metaGrPorMes = new Map<number, number>();
for (const r of metaMesRows) {
const m = Number(r.mes);
if (r.tipo === 'GL') metaGlPorMes.set(m, (metaGlPorMes.get(m) ?? 0) + Number(r.valor));
else metaGrPorMes.set(m, (metaGrPorMes.get(m) ?? 0) + Number(r.valor));
}
const historicoMensal = Array.from({ length: 12 }, (_, i) => {
const m = i + 1;
const valor = realizadoPorMes.get(m) ?? 0;
const meta = metaGlPorMes.has(m) ? (metaGlPorMes.get(m) ?? 0) : (metaGrPorMes.get(m) ?? 0);
return { mes: m, valor, meta, atingiu: meta > 0 && valor >= meta };
});
return {
meta: { atingido, total: targetAmount, pct, falta },
metaDimensao,
metasPorGrupo,
comissao: { fixa, flex, total: fixa + flex },
pedidosMes,
historicoMensal,
pedidosRecentes: recentRows.map((o) => ({
id: `erp-${o.id_pedido}`,
numPedSar: (o.num_ped_sar ?? '').trim(),
@@ -281,13 +361,255 @@ export class DashboardService {
createdAt: new Date(o.dt_pedido).toISOString(),
fonte: 'erp' as const,
})),
clientesInativos: inactiveClients.map((c) => ({
idCliente: Number(c.id_cliente),
nome: c.nome,
diasSemCompra: c.dt_ultima_compra
? Math.floor((now.getTime() - c.dt_ultima_compra.getTime()) / 86_400_000)
: 999,
ultimaCompraValor: c.ultima_compra_valor,
naoPositivados,
totalNaoPositivados: naoPositivados.length,
syncedAt: now.toISOString(),
};
}
async managerDashboard(mes?: number, ano?: number): Promise<ManagerDashboard> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const now = new Date();
function matrizEmpresa(id: number): number {
return id > 9000 ? id - 9000 : id;
}
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
const year = ano ?? now.getFullYear();
const month = mes ?? 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 TotalRow {
count: string;
total: string;
}
interface RankingRow {
cod_vendedor: number;
nome: string | null;
pedidos: string;
clientes_atendidos: string;
faturamento: string;
meta_valor: string | null;
}
interface PromoRow {
count: string;
}
interface MetaTotalRow {
meta_total: string;
}
interface MetaGrupoRow {
cod_grupo: number | null;
desc_grupo: string | null;
valor: string;
qtdade: string;
peso: string;
vl_fator: string;
}
interface PositivacaoRow {
cod_vendedor: number;
nome_vendedor: string | null;
total_clientes: string;
clientes_positivados: string;
}
interface PositivacaoDiaRow {
dia: string;
clientes: string;
}
const [
statsRows,
rankingRows,
promoRows,
metaTotalRows,
positivacaoRows,
metaGrupoRows,
realizadoGrupoRows,
positivacaoDiariaRows,
] = await Promise.all([
prisma.$queryRawUnsafe<TotalRow[]>(`
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}'
`),
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) = 'GR') 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}'
GROUP BY p.cod_vendedor
ORDER BY SUM(p.total) DESC
LIMIT 10
`),
prisma.$queryRawUnsafe<PromoRow[]>(`
SELECT COUNT(*)::text AS count
FROM sar.promocoes
WHERE id_empresa = ${idEmpresa}
AND ativa = true
AND data_fim >= CURRENT_DATE
`),
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) = 'GR'
`),
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
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<MetaGrupoRow[]>(`
SELECT cod_grupo, TRIM(desc_grupo) AS desc_grupo,
SUM(valor)::text AS valor,
SUM(qtdade)::text AS qtdade,
SUM(peso)::text AS peso,
AVG(vl_fator)::text AS vl_fator
FROM sar.vw_metas
WHERE id_empresa = ${idEmpresaMatriz}
AND ano = ${year}
AND mes = ${month}
AND TRIM(tipo) = 'GR'
GROUP BY cod_grupo, desc_grupo
ORDER BY SUM(valor) DESC
`),
prisma.$queryRawUnsafe<RealizadoGrupoRow[]>(`
SELECT p.cod_grupo,
COALESCE(NULLIF(TRIM(p.grupo), ''), p.cod_grupo::text) AS grupo,
COUNT(DISTINCT pi.id_pedido)::text AS pedidos,
COALESCE(SUM(pi.total), 0)::text AS valor,
COALESCE(SUM(pi.qtd), 0)::text AS qtd,
COALESCE(SUM(pi.qtd * COALESCE(p.peso_liquido, 0)), 0)::text AS peso
FROM vw_peditens_erp pi
JOIN vw_pedidos_erp e ON e.id_pedido = pi.id_pedido
JOIN vw_produtos p ON p.id_erp = pi.id_produto AND p.id_empresa = ${idEmpresaMatriz}
WHERE e.id_empresa = ${idEmpresa}
AND e.situa NOT IN (1, 5)
AND e.dt_pedido >= '${monthStartStr}'
AND e.dt_pedido <= '${monthEndStr}'
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
`),
]);
const pedidosMes = Number(statsRows[0]?.count ?? 0);
const faturamentoMes = Number(statsRows[0]?.total ?? 0);
const ticketMedio = pedidosMes > 0 ? faturamentoMes / pedidosMes : 0;
const realGrupoPorCod = new Map<number, RealizadoGrupoRow>();
for (const r of realizadoGrupoRows) {
if (r.cod_grupo != null) realGrupoPorCod.set(Number(r.cod_grupo), r);
}
const metasPorGrupo = metaGrupoRows.map((m) => {
const cod = m.cod_grupo != null ? Number(m.cod_grupo) : null;
const real = cod != null ? realGrupoPorCod.get(cod) : undefined;
const valorMeta = Number(m.valor);
const valorReal = real ? Number(real.valor) : 0;
const pesoMeta = Number(m.peso);
const pesoReal = real ? Number(real.peso) : 0;
return {
codigo: cod,
rotulo: m.desc_grupo || real?.grupo || `Grupo ${cod ?? '?'}`,
pedidos: real ? Number(real.pedidos) : 0,
valorMeta,
valorReal,
qtdMeta: Number(m.qtdade),
qtdReal: real ? Number(real.qtd) : 0,
pesoMeta,
pesoReal,
fatorMeta: Number(m.vl_fator),
fatorReal: pesoReal > 0 ? valorReal / pesoReal : 0,
pct: valorMeta > 0 ? Math.round((valorReal / valorMeta) * 100) : 0,
falta: Math.max(0, valorMeta - valorReal),
};
});
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 totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size;
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0);
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,
};
});
return {
faturamentoMes,
pedidosMes,
ticketMedio,
totalReps,
promocoesAtivas: Number(promoRows[0]?.count ?? 0),
metaTotal,
metasPorGrupo,
rankingReps,
positivacaoReps,
positivacaoDiaria: positivacaoDiariaRows.map((r) => ({
dia: r.dia,
clientes: Number(r.clientes),
})),
syncedAt: now.toISOString(),
};
@@ -299,9 +621,21 @@ export class DashboardService {
const idEmpresa = this.cls.get('idEmpresa');
const now = new Date();
// Supervisor vê só a própria equipe (cod_supervisor em vw_representantes);
// gerente/admin acessando este painel veem a empresa toda.
const role = this.cls.get('role');
const userId = this.cls.get('userId');
const team =
role === 'supervisor' ? await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0) : null;
const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : '');
// Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP)
const approvalQueue = await prisma.pedido.findMany({
where: { idEmpresa, situa: SITUA_PENDENTE },
where: {
idEmpresa,
situa: SITUA_PENDENTE,
...(team ? { codVendedor: { in: team } } : {}),
},
orderBy: { dtPedido: 'asc' },
take: 50,
});
@@ -325,12 +659,14 @@ export class DashboardService {
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa} AND situa != 5 AND dt_pedido >= '${todayStr}'
${teamSqlFilter('cod_vendedor')}
`),
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 != 5
AND dt_pedido >= '${lastWeekStr}' AND dt_pedido < '${lastWeekEndStr}'
${teamSqlFilter('cod_vendedor')}
`),
]);
@@ -350,6 +686,7 @@ export class DashboardService {
AND p.id_empresa = ${idEmpresa}
AND p.situa != 5
WHERE c.ativo = 1
${teamSqlFilter('c.cod_vendedor')}
GROUP BY c.id_cliente, c.cod_vendedor
HAVING MAX(p.dt_pedido) IS NULL
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'

View File

@@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
import type { EquipeResponse } from '@sar/api-interface';
import { EquipeService } from './equipe.service';
@Controller({ path: 'equipe' })
export class EquipeController {
constructor(private readonly equipe: EquipeService) {}
@Get()
listEquipe(): Promise<EquipeResponse> {
return this.equipe.listEquipe();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { EquipeController } from './equipe.controller';
import { EquipeService } from './equipe.service';
@Module({
controllers: [EquipeController],
providers: [EquipeService],
})
export class EquipeModule {}

View File

@@ -0,0 +1,113 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { EquipeResponse } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
function matrizEmpresa(idEmpresa: number): number {
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
}
interface RepRow {
cod_vendedor: number;
nome: string | null;
}
interface PedidosRepRow {
cod_vendedor: number;
pedidos: string;
faturamento: string;
}
interface MetaRepRow {
cod_vendedor: number;
meta_valor: string;
}
@Injectable()
export class EquipeService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
async listEquipe(): Promise<EquipeResponse> {
const role = this.cls.get('role') ?? 'rep';
if (role !== 'manager' && role !== 'admin') {
throw new ForbiddenException('Apenas gerentes podem consultar a equipe');
}
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 [repRows, pedidosRows, metaRows] = await Promise.all([
prisma.$queryRawUnsafe<RepRow[]>(
`SELECT codigo AS cod_vendedor, nome FROM vw_representantes ORDER BY nome`,
),
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<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
`),
]);
const pedidosMap = new Map(
pedidosRows.map((r) => [
Number(r.cod_vendedor),
{ pedidos: Number(r.pedidos), faturamento: Number(r.faturamento) },
]),
);
const metaMap = new Map(metaRows.map((r) => [Number(r.cod_vendedor), Number(r.meta_valor)]));
// vw_representantes pode retornar duplicatas por cod_vendedor — deduplicar
const seenCods = new Set<number>();
const uniqueReps = repRows.filter((r) => {
const cod = Number(r.cod_vendedor);
if (seenCods.has(cod)) return false;
seenCods.add(cod);
return true;
});
const reps = uniqueReps.map((r) => {
const cod = Number(r.cod_vendedor);
const stats = pedidosMap.get(cod);
const meta = metaMap.get(cod) ?? 0;
const faturamento = stats?.faturamento ?? 0;
const pedidos = stats?.pedidos ?? 0;
return {
codVendedor: cod,
nomeVendedor: r.nome ?? null,
pedidosMes: pedidos,
faturamentoMes: faturamento,
ticketMedio: pedidos > 0 ? faturamento / pedidos : 0,
pctMeta: meta > 0 ? Math.round((faturamento / meta) * 100) : 0,
};
});
return {
reps,
totalReps: reps.length,
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 { 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 { NotificationsService } from './notifications.service';
class SubscribeDto extends createZodDto(SubscribePayloadSchema) {}
class UnsubscribeDto extends createZodDto(UnsubscribePayloadSchema) {}
@Controller('notifications')
export class NotificationsController {
@@ -18,8 +23,8 @@ export class NotificationsController {
@Delete('unsubscribe')
@HttpCode(HttpStatus.NO_CONTENT)
async unsubscribe(@Body() body: { endpoint: string }): Promise<void> {
await this.svc.unsubscribe(body.endpoint);
async unsubscribe(@Req() req: AuthenticatedRequest, @Body() body: UnsubscribeDto): Promise<void> {
await this.svc.unsubscribe(req.user.sub, body.endpoint);
}
@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');
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> {

View File

@@ -5,6 +5,7 @@ import {
Get,
HttpCode,
Param,
ParseIntPipe,
ParseUUIDPipe,
Patch,
Post,
@@ -15,11 +16,14 @@ import { createZodDto } from 'nestjs-zod';
import {
AprovarPedidoSchema,
CreatePedidoSchema,
PedidoErpConsultaQuerySchema,
PedidoListQuerySchema,
RecusarPedidoSchema,
type AprovarPedido,
type CreatePedido,
type PedidoDetail,
type PedidoErpConsultaQuery,
type PedidoErpConsultaResponse,
type PedidoListQuery,
type PedidoListResponse,
type RecusarPedido,
@@ -31,6 +35,7 @@ class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
@Controller({ path: 'orders' })
export class OrdersController {
@@ -79,6 +84,22 @@ export class OrdersController {
return this.orders.reject(id, parsed);
}
@Patch(':id/cancel')
cancel(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
return this.orders.cancel(id);
}
@Get('erp/:idPedido')
findOneErp(@Param('idPedido', ParseIntPipe) idPedido: number): Promise<PedidoDetail> {
return this.orders.findOneErp(idPedido);
}
@Get('erp-consulta')
listErpConsulta(@Query() query: PedidoErpConsultaQueryDto): Promise<PedidoErpConsultaResponse> {
const parsed = PedidoErpConsultaQuerySchema.parse(query) as PedidoErpConsultaQuery;
return this.orders.listErpConsulta(parsed);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
return this.orders.findOne(id);

View File

@@ -5,12 +5,16 @@ import type {
AprovarPedido,
CreatePedido,
PedidoDetail,
PedidoErpConsultaItem,
PedidoErpConsultaQuery,
PedidoErpConsultaResponse,
PedidoListQuery,
PedidoListResponse,
PedidoSummary,
RecusarPedido,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util';
import { NotificationsService } from '../notifications/notifications.service';
// Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado
@@ -26,11 +30,6 @@ function sigToSar(sigSitua: number): number {
return sigSitua === 5 ? 3 : sigSitua;
}
// Mapeia situa SAR → situa SIG para usar nos filtros SQL contra vw_pedidos_erp.
function sarToSig(sarSitua: number): number {
return sarSitua === 3 ? 5 : sarSitua;
}
function decimalToString(v: Prisma.Decimal | null | undefined): string {
return v ? v.toString() : '0';
}
@@ -53,45 +52,13 @@ export class OrdersService {
const { idCliente, situa, numPedSar, from, to, page, limit } = query;
const offset = (page - 1) * limit;
// Filtro de vendedor: rep vê apenas seus pedidos
const vendedorFilter = role === 'rep' ? `AND e.cod_vendedor = ${codVendedor}` : '';
const clienteFilter = idCliente != null ? `AND e.id_cliente = ${idCliente}` : '';
// Converte situa SAR → SIG para filtrar corretamente contra vw_pedidos_erp
const sigSitua = situa != null ? sarToSig(situa) : null;
const situaFilter = sigSitua != null ? `AND e.situa = ${sigSitua}` : '';
const pedSarFilter = numPedSar ? `AND TRIM(e.num_ped_sar) ILIKE '%${numPedSar}%'` : '';
const fromFilter = from ? `AND e.dt_pedido >= '${from}'` : '';
const toFilter = to ? `AND e.dt_pedido <= '${to}'` : '';
const filters = `
WHERE e.id_empresa = ${idEmpresa}
${vendedorFilter} ${clienteFilter} ${situaFilter}
${pedSarFilter} ${fromFilter} ${toFilter}
`;
interface ErpRow {
id_pedido: number;
num_ped_sar: string;
numero: number;
id_cliente: number;
nome_cliente: string | null;
razao_cliente: string | null;
cod_vendedor: number;
nome_vendedor: string | null;
situa: number;
status_descr: string;
dt_pedido: Date;
total: string;
desconto_perc: string;
obs: string | null;
}
// Pedidos SAR-nativos (Orçamento/Transmitido) — ainda não estão no ERP.
const sarWhere: Prisma.PedidoWhereInput = {
// 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 = {
idEmpresa,
...(role === 'rep' ? { codVendedor } : {}),
...(role === 'supervisor' ? { codVendedor: { in: team } } : {}),
...(idCliente != null ? { idCliente } : {}),
...(situa != null ? { situa } : {}),
...(numPedSar ? { numPedSar: { contains: numPedSar, mode: 'insensitive' as const } } : {}),
@@ -105,42 +72,13 @@ export class OrdersService {
: {}),
};
const [sarPedidos, countRows] = await Promise.all([
prisma.pedido.findMany({ where: sarWhere, orderBy: { dtPedido: 'desc' } }),
prisma.$queryRawUnsafe<[{ count: string }]>(`
SELECT COUNT(*)::text AS count FROM vw_pedidos_erp e ${filters}
`),
const [pedidos, total] = await Promise.all([
prisma.pedido.findMany({ where, orderBy: { dtPedido: 'desc' }, skip: offset, take: limit }),
prisma.pedido.count({ where }),
]);
const sarCount = sarPedidos.length;
const erpTotal = Number(countRows[0]?.count ?? 0);
const total = sarCount + erpTotal;
// Paginação combinada: SAR-nativos primeiro (ativos), depois histórico ERP.
const sarSlice = sarPedidos.slice(offset, offset + limit);
const erpNeeded = limit - sarSlice.length;
const erpOffset = Math.max(0, offset - sarCount);
const erpRows =
erpNeeded > 0
? await prisma.$queryRawUnsafe<ErpRow[]>(`
SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor,
e.situa, e.status_descr, e.dt_pedido, e.total::text, e.desconto_perc::text, e.obs,
c.nome AS nome_cliente, c.razao AS razao_cliente,
(SELECT r.nome FROM vw_representantes r
WHERE r.codigo = e.cod_vendedor
LIMIT 1) AS nome_vendedor
FROM vw_pedidos_erp e
LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente
${filters}
ORDER BY e.dt_pedido DESC
LIMIT ${erpNeeded} OFFSET ${erpOffset}
`)
: [];
// Resolve nomes (cliente/rep) dos pedidos SAR em lote — views globais.
const cliIds = [...new Set(sarSlice.map((p) => p.idCliente))];
const repCods = [...new Set(sarSlice.map((p) => p.codVendedor))];
const cliIds = [...new Set(pedidos.map((p) => p.idCliente))];
const repCods = [...new Set(pedidos.map((p) => p.codVendedor))];
const [cliNameRows, repNameRows] = await Promise.all([
cliIds.length
? prisma.$queryRawUnsafe<
@@ -158,7 +96,7 @@ export class OrdersService {
const cliMap = new Map(cliNameRows.map((c) => [Number(c.id_cliente), c]));
const repMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
const sarData: PedidoSummary[] = sarSlice.map((p) => ({
const data: PedidoSummary[] = pedidos.map((p) => ({
id: p.id,
numPedSar: p.numPedSar,
idCliente: p.idCliente,
@@ -175,27 +113,193 @@ export class OrdersService {
fonte: 'sar' as const,
}));
const erpData: PedidoSummary[] = erpRows.map((o) => ({
id: `erp-${o.id_pedido}`,
numPedSar: (o.num_ped_sar ?? '').trim(),
numero: Number(o.numero),
idCliente: Number(o.id_cliente),
nomeCliente: o.nome_cliente ?? null,
razaoCliente: o.razao_cliente ?? null,
codVendedor: Number(o.cod_vendedor),
nomeVendedor: o.nome_vendedor ?? null,
// Normaliza situa SIG → SAR para consistência com pedidos SAR
situa: sigToSar(Number(o.situa)),
statusDescr: o.status_descr,
dtPedido: new Date(o.dt_pedido).toISOString(),
total: o.total ?? '0',
descontoPerc: o.desconto_perc ?? '0',
obs: o.obs ?? null,
createdAt: new Date(o.dt_pedido).toISOString(),
fonte: 'erp' as const,
return { data, total, page, limit };
}
async listErpConsulta(query: PedidoErpConsultaQuery): Promise<PedidoErpConsultaResponse> {
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');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
const { search, from, to, page, limit } = query;
const offset = (page - 1) * limit;
const dataHistorico = new Date();
dataHistorico.setDate(dataHistorico.getDate() - 90);
const dataHistoricoStr = dataHistorico.toISOString().split('T')[0];
// 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 toFilterE = to ? `AND COALESCE(a.dt_pedido, b.dt_pedido) <= '${to}'` : '';
const fromFilterP = from ? `AND a.dt_pedido >= '${from}'` : '';
const toFilterP = to ? `AND a.dt_pedido <= '${to}'` : '';
const safe = (s: string) => s.replace(/'/g, "''");
const searchFilter = search
? `AND (r.numero_pedido::text ILIKE '%${safe(search)}%' OR LOWER(cl.nome) LIKE LOWER('%${safe(search)}%') OR LOWER(cl.razao) LIKE LOWER('%${safe(search)}%'))`
: '';
interface ErpConsultaRow {
numero_pedido: number;
data: Date;
dt_prevent: Date | null;
data_emissao: Date | null;
situa: number;
status_descr: string | null;
id_cliente: number;
nome_cliente: string | null;
razao_cliente: string | null;
cod_vendedor: number;
forma_pagamento: string | null;
total: string;
obs: string | null;
tipo: 'P' | 'E';
num_ped_sar: string | null;
numero_entrega: number | null;
numero_nf: number | null;
chave_acesso_nfe: string | null;
sit_ped: number | null;
id_pedido: number | null;
}
const cte = `
WITH all_rows AS (
SELECT
b.numero AS numero_pedido,
COALESCE(b.dt_pedido, a.dt_pedido) AS data,
a.dt_prevent,
a.dt_emissao AS data_emissao,
a.situa,
NULL::text AS status_descr,
a.id_cliente,
a.cod_vendedor,
a.forma_pagamento,
a.total::text,
a.obs,
'E'::varchar(1) AS tipo,
TRIM(b.num_ped_sar) AS num_ped_sar,
a.numero AS numero_entrega,
d.numero AS numero_nf,
d.chave_acesso_nfe,
b.situa AS sit_ped,
a.id_pedido
FROM sar.vw_pedidos_erp a
LEFT JOIN sar.vw_pedidos_erp b
ON a.numer_ped_vinc = b.numero
AND a.id_empresa = b.id_empresa
AND b.tipo = 'P'
LEFT JOIN sar.vw_nf d
ON a.numero = d.num_entrega
AND d.id_empresa = ${idMatriz}
WHERE a.id_empresa = ${idEmpresa}
AND a.tipo = 'E'
${vendedorFilter}
AND b.id_pedido IS NOT NULL
AND COALESCE(a.dt_pedido, b.dt_pedido) >= '${dataHistoricoStr}'
${fromFilterE} ${toFilterE}
UNION ALL
SELECT
a.numero AS numero_pedido,
a.dt_pedido AS data,
a.dt_prevent,
a.dt_emissao AS data_emissao,
a.situa,
c.descricao AS status_descr,
a.id_cliente,
a.cod_vendedor,
a.forma_pagamento,
a.total::text,
a.obs,
'P'::varchar(1) AS tipo,
TRIM(a.num_ped_sar) AS num_ped_sar,
NULL::integer AS numero_entrega,
NULL::integer AS numero_nf,
NULL::varchar AS chave_acesso_nfe,
NULL::integer AS sit_ped,
a.id_pedido
FROM sar.vw_pedidos_erp a
LEFT JOIN vw_sitpedido c
ON c.id_sitpedido = a.situa
WHERE a.id_empresa = ${idEmpresa}
AND a.tipo = 'P'
${vendedorFilter}
AND a.situa NOT IN (3, 4, 5, 6)
${fromFilterP} ${toFilterP}
),
named AS (
SELECT r.*, cl.nome AS nome_cliente, cl.razao AS razao_cliente
FROM all_rows r
LEFT JOIN vw_clientes cl ON cl.id_cliente = r.id_cliente
WHERE 1=1 ${searchFilter}
)
`;
const [countRows, dataRows] = await Promise.all([
prisma.$queryRawUnsafe<[{ count: string }]>(
`${cte} SELECT COUNT(*)::text AS count FROM named`,
),
prisma.$queryRawUnsafe<ErpConsultaRow[]>(
`${cte} SELECT * FROM named ORDER BY data DESC LIMIT ${limit} OFFSET ${offset}`,
),
]);
const total = Number(countRows[0]?.count ?? 0);
const data: PedidoErpConsultaItem[] = dataRows.map((r) => ({
numeroPedido: Number(r.numero_pedido),
data: r.data ? new Date(r.data).toISOString() : new Date().toISOString(),
dtPrevent: r.dt_prevent ? new Date(r.dt_prevent).toISOString() : null,
dataEmissao: r.data_emissao ? new Date(r.data_emissao).toISOString() : null,
situa: Number(r.situa),
statusDescr: r.status_descr ?? null,
idCliente: Number(r.id_cliente),
nomeCliente: r.nome_cliente ?? null,
razaoCliente: r.razao_cliente ?? null,
codVendedor: Number(r.cod_vendedor),
formaPagamento: r.forma_pagamento ?? null,
total: r.total ?? '0',
obs: r.obs ?? null,
tipo: r.tipo as 'P' | 'E',
numPedSar: r.num_ped_sar ?? null,
numeroEntrega: r.numero_entrega != null ? Number(r.numero_entrega) : null,
numeroNf: r.numero_nf != null ? Number(r.numero_nf) : null,
chaveAcessoNfe: r.chave_acesso_nfe ?? null,
sitPed: r.sit_ped != null ? Number(r.sit_ped) : null,
idPedido: r.id_pedido != null ? Number(r.id_pedido) : null,
}));
return { data: [...sarData, ...erpData], total, page, limit };
return { data, total, page, limit };
}
// Supervisor só age sobre pedidos da própria equipe; gerente/admin passam
// direto. NotFound (não Forbidden) para não revelar pedidos de outras equipes.
private async assertPedidoDaEquipe(codVendedorPedido: number, idPedido: string): Promise<void> {
const role = this.cls.get('role');
if (role !== 'supervisor') return;
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const userId = this.cls.get('userId') ?? '0';
const team = await getTeamCodes(prisma, parseInt(userId, 10));
if (!team.includes(codVendedorPedido)) {
throw new NotFoundException(`Pedido ${idPedido} não encontrado`);
}
}
async findOne(id: string): Promise<PedidoDetail> {
@@ -206,7 +310,12 @@ export class OrdersService {
const userId = this.cls.get('userId');
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({
where: { id, idEmpresa, ...repFilter },
@@ -228,13 +337,26 @@ export class OrdersService {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const role = this.cls.get('role');
const userId = this.cls.get('userId') ?? '0';
const codVendedor = parseInt(userId, 10);
// 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) {
const existing = await prisma.pedido.findUnique({
where: { idempotencyKey: dto.idempotencyKey },
const existing = await prisma.pedido.findFirst({
where: { idempotencyKey: dto.idempotencyKey, idEmpresa, codVendedor },
include: {
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
@@ -290,6 +412,7 @@ export class OrdersService {
descontoPerc: dto.descontoPerc,
descontoValor: descontoValorGlobal,
obs: dto.obs ?? null,
endEntrega: dto.endEntrega ?? null,
idempotencyKey: dto.idempotencyKey ?? null,
itens: { create: itemsData },
historico: {
@@ -325,7 +448,10 @@ export class OrdersService {
// Rep só transmite o próprio orçamento
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.situa !== SITUA_ORCAMENTO)
throw new BadRequestException(
@@ -343,6 +469,8 @@ export class OrdersService {
);
}
await this.assertAlcadaItens(pedido, limitMap, limiteMax);
const now = new Date();
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } });
await prisma.historicoPedido.create({
@@ -366,6 +494,146 @@ export class OrdersService {
return this.mapDetail(final);
}
// Alçada por ITEM (complementa o gate global do transmit): o desconto EFETIVO
// de cada item — preço cobrado vs preço de tabela, combinado com o desconto
// global — deve caber no limite do grupo do produto (alcada_desconto por
// codGrupo; 0 = default), somado à maior folga entre promoção ativa
// (produto/grupo) e regra de desconto vigente (grupo/subgrupo/rep). Cobre o
// desconto lançado no item E desconto disfarçado de preço unitário reduzido.
// Preço de tabela: pauta do pedido > promocional > preço base; produto sem
// referência valida só pelo campo de desconto.
private async assertAlcadaItens(
pedido: {
codVendedor: number;
descontoPerc: Prisma.Decimal;
idPauta: number | null;
itens: {
ordem: number;
idProduto: number;
codProduto: string | null;
precoUnitario: Prisma.Decimal;
descontoPerc: Prisma.Decimal;
}[];
},
limitMap: Map<number, number>,
limiteDefault: 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 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 grupo/subgrupo vale para qualquer produto; com grupo e/ou
// subgrupo, o produto precisa casar com o que a regra especifica.
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)),
)
.reduce((max, r) => Math.max(max, Number(r.descPct)), 0);
const limiteItem =
(codGrupo != null ? (limitMap.get(codGrupo) ?? limiteDefault) : limiteDefault) +
Math.max(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 transmitir.`,
);
}
}
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -379,6 +647,7 @@ export class OrdersService {
throw new BadRequestException(
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
);
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
const now = new Date();
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc);
@@ -437,6 +706,7 @@ export class OrdersService {
throw new BadRequestException(
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
);
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
const now = new Date();
@@ -473,6 +743,44 @@ export class OrdersService {
return this.mapDetail(final);
}
async cancel(id: string): 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);
// Rep só pode cancelar seu próprio orçamento (situa 0).
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, codVendedor } });
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (pedido.situa !== SITUA_ORCAMENTO)
throw new BadRequestException('Apenas orçamentos podem ser cancelados pelo representante');
const now = new Date();
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_CANCELADO } });
await prisma.historicoPedido.create({
data: {
idPedido: id,
situaAnterior: SITUA_ORCAMENTO,
situaNova: SITUA_CANCELADO,
changedBy: codVendedor,
changedAt: now,
nota: 'Cancelado pelo representante',
},
});
const final = await prisma.pedido.findUniqueOrThrow({
where: { id },
include: {
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
return this.mapDetail(final);
}
// Resolve nome do cliente (nome + razão) e nome do representante a partir dos
// códigos, lendo das views do ERP. Usado no detalhe de pedidos SAR-nativos.
private async lookupNames(
@@ -523,6 +831,7 @@ export class OrdersService {
aprovadoEm: Date | null;
motivoRecusa: string | null;
obs: string | null;
endEntrega: string | null;
idempotencyKey: string | null;
createdAt: Date;
updatedAt: Date;
@@ -560,6 +869,7 @@ export class OrdersService {
total: decimalToString(o.total),
descontoPerc: decimalToString(o.descontoPerc),
obs: o.obs,
endEntrega: o.endEntrega,
createdAt: o.createdAt.toISOString(),
totalProdutos: decimalToString(o.totalProdutos),
totalIpi: decimalToString(o.totalIpi),
@@ -595,4 +905,136 @@ export class OrdersService {
})),
};
}
async findOneErp(idPedido: number): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const role = this.cls.get('role');
const userId = this.cls.get('userId') ?? '0';
const codVendedor = parseInt(userId, 10);
interface ErpHeader {
id_pedido: number;
num_ped_sar: string;
numero: number;
id_cliente: number;
cod_vendedor: number;
situa: number;
status_descr: string;
dt_pedido: Date;
total_produtos: string;
total_ipi: string;
total_icmsst: string;
total: string;
desconto_perc: string;
desconto_valor: string;
acrescimo: string;
comissao: string;
ped_flex: string;
obs: string | null;
forma_pagamento: string | null;
nome_cliente: string | null;
razao_cliente: string | null;
nome_vendedor: string | null;
}
interface ErpItem {
ordem: number;
id_produto: number;
codigo: string | null;
descricao: string | null;
qtd: string;
preco_unitario: string;
desconto_perc: string;
total: string;
}
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 [headerRows, itemRows] = await Promise.all([
prisma.$queryRawUnsafe<ErpHeader[]>(`
SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor,
e.situa, e.status_descr, e.dt_pedido,
e.total_produtos::text, e.total_ipi::text, e.total_icmsst::text,
e.total::text, e.desconto_perc::text, e.desconto_valor::text,
e.acrescimo::text, e.comissao::text, e.ped_flex::text, e.obs,
TRIM(e.forma_pagamento) AS forma_pagamento,
c.nome AS nome_cliente, c.razao AS razao_cliente,
(SELECT r.nome FROM vw_representantes r
WHERE r.codigo = e.cod_vendedor LIMIT 1) AS nome_vendedor
FROM vw_pedidos_erp e
LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente
WHERE e.id_empresa = ${idEmpresa}
AND e.id_pedido = ${idPedido}
${vendedorFilter}
LIMIT 1
`),
prisma.$queryRawUnsafe<ErpItem[]>(`
SELECT ei.ordem, ei.id_produto,
TRIM(p.codigo) AS codigo, TRIM(p.descricao) AS descricao,
ei.qtd::text, ei.preco_unitario::text,
ei.desconto_perc::text, ei.total::text
FROM vw_peditens_erp ei
LEFT JOIN vw_produtos p
ON p.id_erp = ei.id_produto
AND p.id_empresa = ${idMatriz}
WHERE ei.id_pedido = ${idPedido}
ORDER BY ei.ordem
`),
]);
if (!headerRows[0]) throw new NotFoundException(`Pedido ERP ${idPedido} não encontrado`);
const h = headerRows[0];
return {
id: `erp-${h.id_pedido}`,
numPedSar: (h.num_ped_sar ?? '').trim(),
numero: Number(h.numero),
idCliente: Number(h.id_cliente),
nomeCliente: h.nome_cliente ?? null,
razaoCliente: h.razao_cliente ?? null,
codVendedor: Number(h.cod_vendedor),
nomeVendedor: h.nome_vendedor ?? null,
situa: sigToSar(Number(h.situa)),
statusDescr: h.status_descr,
dtPedido: new Date(h.dt_pedido).toISOString(),
total: h.total ?? '0',
descontoPerc: h.desconto_perc ?? '0',
obs: h.obs?.trim() || null,
createdAt: new Date(h.dt_pedido).toISOString(),
updatedAt: new Date(h.dt_pedido).toISOString(),
fonte: 'erp' as const,
formaPagamento: h.forma_pagamento || null,
totalProdutos: h.total_produtos ?? '0',
totalIpi: h.total_ipi ?? '0',
totalIcmsst: h.total_icmsst ?? '0',
descontoValor: h.desconto_valor ?? '0',
acrescimo: h.acrescimo ?? '0',
comissao: h.comissao ?? '0',
pedFlex: h.ped_flex ?? '0',
aprovadoPor: null,
aprovadoEm: null,
motivoRecusa: null,
idempotencyKey: null,
itens: itemRows.map((it) => ({
id: `${idPedido}-${it.ordem}`,
idProduto: Number(it.id_produto),
codProduto: it.codigo ?? null,
descProduto: it.descricao ?? null,
ordem: Number(it.ordem),
qtd: it.qtd,
precoUnitario: it.preco_unitario,
descontoPerc: it.desconto_perc,
total: it.total,
})),
historico: [],
};
}
}

View File

@@ -0,0 +1,116 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseIntPipe,
Patch,
Post,
} from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import {
UpsertDescontoBodySchema,
CreatePromocaoBodySchema,
UpdatePromocaoBodySchema,
CreateRegraDescontoBodySchema,
UpdateRegraDescontoBodySchema,
type UpsertDescontoBody,
type CreatePromocaoBody,
type UpdatePromocaoBody,
type CreateRegraDescontoBody,
type UpdateRegraDescontoBody,
type AlcadaDescontosResponse,
type PromocoesResponse,
type PromocoesVigentesResponse,
type RegrasDescontoResponse,
type RegrasVigentesResponse,
type GruposProdutoResponse,
} from '@sar/api-interface';
import { PoliticasService } from './politicas.service';
class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {}
class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {}
class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {}
class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {}
class UpdateRegraDescontoDto extends createZodDto(UpdateRegraDescontoBodySchema) {}
@Controller({ path: 'politicas' })
export class PoliticasController {
constructor(private readonly politicas: PoliticasService) {}
@Get('descontos')
listDescontos(): Promise<AlcadaDescontosResponse> {
return this.politicas.listDescontos();
}
@Post('descontos')
@HttpCode(200)
upsertDesconto(@Body() body: UpsertDescontoDto): Promise<void> {
return this.politicas.upsertDesconto(body as unknown as UpsertDescontoBody);
}
@Get('promocoes')
listPromocoes(): Promise<PromocoesResponse> {
return this.politicas.listPromocoes();
}
@Get('promocoes/vigentes')
listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
return this.politicas.listPromocoesVigentes();
}
@Post('promocoes')
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> {
return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
}
@Patch('promocoes/:id')
updatePromocao(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdatePromocaoDto,
): Promise<void> {
return this.politicas.updatePromocao(id, body as unknown as UpdatePromocaoBody);
}
@Delete('promocoes/:id')
@HttpCode(204)
deletePromocao(@Param('id', ParseIntPipe) id: number): Promise<void> {
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<{ id: 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

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PoliticasController } from './politicas.controller';
import { PoliticasService } from './politicas.service';
@Module({
controllers: [PoliticasController],
providers: [PoliticasService],
})
export class PoliticasModule {}

View File

@@ -0,0 +1,382 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
AlcadaDescontosResponse,
UpsertDescontoBody,
PromocoesResponse,
CreatePromocaoBody,
UpdatePromocaoBody,
RegrasDescontoResponse,
CreateRegraDescontoBody,
UpdateRegraDescontoBody,
RegrasVigentesResponse,
PromocoesVigentesResponse,
GruposProdutoResponse,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
@Injectable()
export class PoliticasService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
private assertManager(): void {
const role = this.cls.get('role') ?? 'rep';
if (role !== 'manager' && role !== 'admin') {
throw new ForbiddenException('Apenas gerentes podem gerenciar políticas comerciais');
}
}
async listDescontos(): Promise<AlcadaDescontosResponse> {
this.assertManager();
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const rows = await prisma.alcadaDesconto.findMany({
where: { idEmpresa },
orderBy: [{ codVendedor: 'asc' }, { codGrupo: 'asc' }],
});
// Busca nomes dos representantes
const codigos = [...new Set(rows.map((r) => r.codVendedor))];
const repRows =
codigos.length > 0
? await prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${codigos.join(',')})`,
)
: [];
const repNameMap = new Map(repRows.map((r) => [Number(r.codigo), r.nome]));
return {
descontos: rows.map((r) => ({
codVendedor: r.codVendedor,
codGrupo: r.codGrupo,
limitePerc: Number(r.limitePerc),
nomeVendedor: repNameMap.get(r.codVendedor) ?? null,
})),
};
}
async upsertDesconto(body: UpsertDescontoBody): Promise<void> {
this.assertManager();
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
await prisma.alcadaDesconto.upsert({
where: {
codVendedor_idEmpresa_codGrupo: {
codVendedor: body.codVendedor,
idEmpresa,
codGrupo: body.codGrupo ?? 0,
},
},
create: {
codVendedor: body.codVendedor,
idEmpresa,
codGrupo: body.codGrupo ?? 0,
limitePerc: body.limitePerc,
},
update: { limitePerc: body.limitePerc },
});
}
async listPromocoes(): Promise<PromocoesResponse> {
this.assertManager();
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const rows = await prisma.promocao.findMany({
where: { idEmpresa },
orderBy: [{ dataFim: 'desc' }, { id: 'desc' }],
});
return {
promocoes: rows.map((p) => ({
id: p.id,
descricao: p.descricao,
codProduto: p.codProduto,
grpProd: p.grpProd,
descPct: Number(p.descPct),
dataInicio: p.dataInicio.toISOString().slice(0, 10),
dataFim: p.dataFim.toISOString().slice(0, 10),
ativa: p.ativa,
createdAt: p.createdAt.toISOString(),
createdBy: p.createdBy,
})),
};
}
async createPromocao(body: CreatePromocaoBody): Promise<{ id: number }> {
this.assertManager();
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId') ?? 'system';
const p = await prisma.promocao.create({
data: {
idEmpresa,
descricao: body.descricao,
codProduto: body.codProduto ?? null,
grpProd: body.grpProd ?? null,
descPct: body.descPct,
dataInicio: new Date(body.dataInicio),
dataFim: new Date(body.dataFim),
createdBy: userId,
},
});
return { id: p.id };
}
async updatePromocao(id: number, body: UpdatePromocaoBody): 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.promocao.findFirst({ where: { id, idEmpresa } });
if (!exists) throw new NotFoundException('Promoção não encontrada');
await prisma.promocao.update({
where: { id },
data: {
...(body.descricao !== undefined && { descricao: body.descricao }),
...(body.codProduto !== undefined && { codProduto: body.codProduto }),
...(body.grpProd !== undefined && { grpProd: body.grpProd }),
...(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 deletePromocao(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.promocao.findFirst({ where: { id, idEmpresa } });
if (!exists) throw new NotFoundException('Promoção não encontrada');
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,
})),
};
}
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]),
);
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,
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,
})),
};
}
async createRegra(body: CreateRegraDescontoBody): Promise<{ id: number }> {
this.assertManager();
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId') ?? 'system';
const r = await prisma.regraDesconto.create({
data: {
idEmpresa,
descricao: body.descricao,
codGrupo: body.codGrupo ?? null,
codSubgrupo: body.codSubgrupo ?? null,
codVendedor: body.codVendedor ?? null,
descPct: body.descPct,
dataInicio: new Date(body.dataInicio),
dataFim: new Date(body.dataFim),
createdBy: userId,
},
});
return { id: r.id };
}
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.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 } });
}
// 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' }],
});
return {
promocoes: rows.map((p) => ({
id: p.id,
descricao: p.descricao,
codProduto: p.codProduto,
grpProd: p.grpProd,
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' }],
});
return {
regras: rows.map((r) => ({
id: r.id,
descricao: r.descricao,
codGrupo: r.codGrupo,
codSubgrupo: r.codSubgrupo,
descPct: Number(r.descPct),
dataFim: r.dataFim.toISOString().slice(0, 10),
})),
};
}
}

View File

@@ -0,0 +1,33 @@
import { Controller, Get, Query } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import {
ReportMetaQuerySchema,
ReportAbcQuerySchema,
type ReportMetaResponse,
type ReportCarteiraResponse,
type ReportAbcResponse,
} from '@sar/api-interface';
import { ReportsService } from './reports.service';
class ReportMetaQueryDto extends createZodDto(ReportMetaQuerySchema) {}
class ReportAbcQueryDto extends createZodDto(ReportAbcQuerySchema) {}
@Controller({ path: 'reports' })
export class ReportsController {
constructor(private readonly reports: ReportsService) {}
@Get('meta')
meta(@Query() query: ReportMetaQueryDto): Promise<ReportMetaResponse> {
return this.reports.metaVsRealizado(ReportMetaQuerySchema.parse(query));
}
@Get('carteira')
carteira(): Promise<ReportCarteiraResponse> {
return this.reports.carteira();
}
@Get('abc')
abc(@Query() query: ReportAbcQueryDto): Promise<ReportAbcResponse> {
return this.reports.abcProdutos(ReportAbcQuerySchema.parse(query));
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
@Module({
controllers: [ReportsController],
providers: [ReportsService],
})
export class ReportsModule {}

View File

@@ -0,0 +1,264 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
ReportMetaQuery,
ReportMetaResponse,
ReportCarteiraResponse,
ReportAbcQuery,
ReportAbcResponse,
AbcProduto,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util';
function d(v: unknown): string {
return v != null ? String(v) : '0';
}
function n(v: unknown): number {
return v != null ? Number(v) : 0;
}
@Injectable()
export class ReportsService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
async metaVsRealizado(query: ReportMetaQuery): Promise<ReportMetaResponse> {
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');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const { mes, ano } = query;
interface RealizadoRow {
realizado: unknown;
comissao: unknown;
total_pedidos: unknown;
}
const [realizadoRows, metaRows] = await Promise.all([
prisma.$queryRawUnsafe<RealizadoRow[]>(`
SELECT
COALESCE(SUM(total), 0) AS realizado,
COALESCE(SUM(comissao), 0) AS comissao,
COUNT(id) AS total_pedidos
FROM sar.pedidos
WHERE id_empresa = ${idEmpresa}
AND cod_vendedor = ${codVendedor}
AND situa IN (2, 4)
AND EXTRACT(MONTH FROM dt_pedido) = ${mes}
AND EXTRACT(YEAR FROM dt_pedido) = ${ano}
`),
prisma.metaRepresentante.findUnique({
where: { codVendedor_idEmpresa_ano_mes: { codVendedor, idEmpresa, ano, mes } },
}),
]);
const realizado = n(realizadoRows[0]?.realizado);
const comissao = n(realizadoRows[0]?.comissao);
const totalPedidos = n(realizadoRows[0]?.total_pedidos);
const meta = metaRows ? Number(metaRows.metaValor) : 0;
const percentual = meta > 0 ? (realizado / meta) * 100 : 0;
return {
mes,
ano,
realizado: realizado.toFixed(2),
meta: meta.toFixed(2),
percentual: percentual.toFixed(2),
comissaoEstimada: comissao.toFixed(2),
totalPedidos,
};
}
async carteira(): Promise<ReportCarteiraResponse> {
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');
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 {
id_cliente: unknown;
nome: string | null;
razao: string | null;
ativo: unknown;
ultimo_pedido: string | null;
dias_sem_pedido: unknown;
total_pedidos: unknown;
ticket_medio: unknown;
faturamento_total: unknown;
}
const rows = await prisma.$queryRawUnsafe<CarteiraRow[]>(`
WITH ultimos AS (
SELECT
id_cliente,
MAX(dt_pedido) AS ultimo_pedido,
COUNT(*) AS total_pedidos,
CASE WHEN COUNT(*) > 0
THEN SUM(total)::numeric / COUNT(*)
ELSE 0
END AS ticket_medio,
COALESCE(SUM(total), 0)::numeric AS faturamento_total
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND ${vendCond('cod_vendedor')}
AND situa NOT IN (1, 5)
GROUP BY id_cliente
)
SELECT
c.id_cliente,
TRIM(c.nome) AS nome,
TRIM(c.razao) AS razao,
c.ativo,
TO_CHAR(u.ultimo_pedido, 'YYYY-MM-DD') AS ultimo_pedido,
(CURRENT_DATE - u.ultimo_pedido::date) AS dias_sem_pedido,
COALESCE(u.total_pedidos, 0)::int AS total_pedidos,
COALESCE(u.ticket_medio, 0)::numeric AS ticket_medio,
COALESCE(u.faturamento_total, 0)::numeric AS faturamento_total
FROM vw_clientes c
LEFT JOIN ultimos u ON u.id_cliente = c.id_cliente
WHERE ${vendCond('c.cod_vendedor')}
AND c.ativo = 1
ORDER BY u.faturamento_total DESC NULLS LAST, c.nome ASC
`);
// 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),
nome: r.nome ?? null,
razao: r.razao ?? null,
ativo: n(r.ativo),
ultimoPedido: r.ultimo_pedido ?? null,
diasSemPedido: r.dias_sem_pedido != null ? n(r.dias_sem_pedido) : null,
totalPedidos: n(r.total_pedidos),
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 inativos60 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60).length;
const semPedido = data.filter((c) => c.totalPedidos === 0).length;
return {
data,
total: data.length,
inativos30,
inativos60,
semPedido,
faturamentoRepTotal: faturamentoRepTotal.toFixed(2),
};
}
async abcProdutos(query: ReportAbcQuery): Promise<ReportAbcResponse> {
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');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const { mes, ano } = query;
const mesFilter = mes != null ? `AND EXTRACT(MONTH FROM p.dt_pedido) = ${mes}` : '';
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 {
cod_produto: string | null;
desc_produto: string | null;
total_faturado: unknown;
qtd_vendida: unknown;
desconto_medio: unknown;
comissao_total: unknown;
}
const rows = await prisma.$queryRawUnsafe<AbcRow[]>(`
SELECT
pi.cod_produto,
pi.desc_produto,
SUM(pi.total) AS total_faturado,
SUM(pi.qtd) AS qtd_vendida,
AVG(pi.desconto_perc) AS desconto_medio,
SUM(pi.comissao) AS comissao_total
FROM sar.pedido_itens pi
JOIN sar.pedidos p ON p.id = pi.id_pedido
WHERE p.id_empresa = ${idEmpresa}
AND ${vendCond}
AND p.situa NOT IN (0, 3)
${mesFilter}
${anoFilter}
GROUP BY pi.cod_produto, pi.desc_produto
ORDER BY total_faturado DESC
`);
const totalGeral = rows.reduce((s, r) => s + n(r.total_faturado), 0);
let acumulado = 0;
const data: AbcProduto[] = rows.map((r) => {
const total = n(r.total_faturado);
const participacao = totalGeral > 0 ? (total / totalGeral) * 100 : 0;
acumulado += participacao;
const curva: 'A' | 'B' | 'C' = acumulado <= 80 ? 'A' : acumulado <= 95 ? 'B' : 'C';
return {
codProduto: r.cod_produto ?? null,
descProduto: r.desc_produto ?? '(sem descrição)',
totalFaturado: total.toFixed(2),
qtdVendida: d(r.qtd_vendida),
participacao: participacao.toFixed(2),
curva,
descontoMedio: n(r.desconto_medio).toFixed(2),
comissaoTotal: n(r.comissao_total).toFixed(2),
};
});
const meses = [
'Jan',
'Fev',
'Mar',
'Abr',
'Mai',
'Jun',
'Jul',
'Ago',
'Set',
'Out',
'Nov',
'Dez',
];
const periodo =
mes != null && ano != null
? `${meses[mes - 1]}/${ano}`
: ano != null
? String(ano)
: 'Acumulado';
return { data, totalFaturado: totalGeral.toFixed(2), periodo };
}
}

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

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

View File

@@ -0,0 +1,122 @@
import { Card, Flex, Progress, Skeleton, Table, Tag, Typography } from 'antd';
import type { TableColumnsType } from 'antd';
import type { RepStats } from '@sar/api-interface';
import { useEquipe } from '../../lib/queries/gerente';
const { Title, Text } = Typography;
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
const columns: TableColumnsType<RepStats> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: RepStats) => (
<Flex vertical gap={0}>
<Text strong>{row.nomeVendedor ?? `Cód. ${row.codVendedor}`}</Text>
{row.nomeVendedor && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
cód. {row.codVendedor}
</Text>
)}
</Flex>
),
},
{
title: 'Pedidos',
dataIndex: 'pedidosMes',
width: 90,
align: 'right',
className: 'tabular-nums',
sorter: (a, b) => a.pedidosMes - b.pedidosMes,
render: (v: number) => (v === 0 ? <Tag>0</Tag> : v),
},
{
title: 'Faturamento',
dataIndex: 'faturamentoMes',
width: 160,
align: 'right',
className: 'tabular-nums',
sorter: (a, b) => a.faturamentoMes - b.faturamentoMes,
defaultSortOrder: 'descend',
render: (v: number) => fmt(v),
},
{
title: 'Ticket Medio',
dataIndex: 'ticketMedio',
width: 140,
align: 'right',
className: 'tabular-nums',
sorter: (a, b) => a.ticketMedio - b.ticketMedio,
render: (v: number) => (v > 0 ? fmt(v) : <Text type="secondary">--</Text>),
},
{
title: '% Meta',
dataIndex: 'pctMeta',
width: 170,
sorter: (a, b) => a.pctMeta - b.pctMeta,
render: (pct: number) =>
pct === 0 ? (
<Text type="secondary">sem meta</Text>
) : (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex>
),
},
];
export function EquipePage() {
const { data, isLoading } = useEquipe();
if (isLoading || !data) {
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
<Skeleton active paragraph={{ rows: 1 }} />
<Skeleton active paragraph={{ rows: 6 }} />
</Flex>
);
}
const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
<Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}>
Equipe
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
Performance dos representantes &mdash; {mes}
</Text>
</Flex>
<Card>
<Table<RepStats>
rowKey="codVendedor"
columns={columns}
dataSource={data.reps}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhum representante encontrado.' }}
scroll={{ x: 700 }}
/>
</Card>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')}
</Text>
</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,672 @@
import { useState } from 'react';
import {
Button,
Card,
Col,
DatePicker,
Flex,
InputNumber,
Progress,
Row,
Skeleton,
Space,
Table,
Typography,
} from 'antd';
import { useNavigate } from '@tanstack/react-router';
import type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faChartBar,
faTags,
faUsers,
faFileInvoiceDollar,
faAddressCard,
faBullseye,
faArrowTrendUp,
faCalendarDay,
faLayerGroup,
} 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 type { Dayjs } from 'dayjs';
import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface';
import { useManagerDashboard } from '../../lib/queries/gerente';
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
ChartTooltip,
Legend,
);
const { Title, Text } = Typography;
// Meta diária de positivação é preferência local do gerente (não vem do ERP)
const META_POSITIVACAO_DIA_KEY = 'sar:ger:meta-positivacao-dia';
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function today(): string {
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
}
const positivacaoColumns: TableColumnsType<PositivacaoRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: PositivacaoRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
ellipsis: true,
},
{
title: 'Positivados / Carteira',
key: 'pos',
width: 160,
align: 'center' as const,
render: (_: unknown, row: PositivacaoRep) => (
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
{row.clientesPositivados} / {row.totalClientes}
</Text>
),
},
{
title: '% Positivação',
dataIndex: 'pctPositivacao',
width: 170,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 30 ? 'var(--green)' : pct >= 15 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex>
),
},
];
const metasGrupoColumns: TableColumnsType<MetaItem> = [
{
title: 'Grupo',
dataIndex: 'rotulo',
ellipsis: true,
},
{
title: 'Meta',
dataIndex: 'valorMeta',
width: 150,
align: 'right',
render: (v: number) => fmt(v),
className: 'tabular-nums',
},
{
title: 'Realizado',
dataIndex: 'valorReal',
width: 150,
align: 'right',
render: (v: number) => fmt(v),
className: 'tabular-nums',
},
{
title: 'Falta',
dataIndex: 'falta',
width: 130,
align: 'right',
render: (v: number) => (
<Text
className="tabular-nums"
style={{ fontSize: 'var(--text-sm)', color: v === 0 ? 'var(--green)' : undefined }}
>
{v === 0 ? '—' : fmt(v)}
</Text>
),
},
{
title: '% Meta',
dataIndex: 'pct',
width: 170,
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: 48 }}>
{pct}%
</Text>
</Flex>
),
},
];
const rankingColumns: TableColumnsType<RankingRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: RankingRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Pedidos',
dataIndex: 'pedidos',
width: 80,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Clientes',
dataIndex: 'clientesAtendidos',
width: 80,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Faturamento',
dataIndex: 'faturamento',
width: 150,
align: 'right',
render: (v: number) => fmt(v),
className: 'tabular-nums',
},
{
title: '% Meta',
dataIndex: 'pctMeta',
width: 160,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex>
),
},
];
function PositivacaoDiariaCard({
dados,
periodo,
periodoLabel,
isMesAtual,
}: {
dados: PositivacaoDia[];
periodo: Dayjs;
periodoLabel: string;
isMesAtual: boolean;
}) {
const [meta, setMeta] = useState<number>(() => {
const saved = Number(localStorage.getItem(META_POSITIVACAO_DIA_KEY));
return Number.isFinite(saved) && saved > 0 ? saved : 0;
});
function changeMeta(v: number | null) {
const val = v ?? 0;
setMeta(val);
localStorage.setItem(META_POSITIVACAO_DIA_KEY, String(val));
}
// 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: [
{
type: 'bar' as const,
label: 'Clientes positivados',
data: valores,
backgroundColor: 'rgba(0, 74, 153, 0.8)',
borderRadius: 4,
order: 2,
},
...(meta > 0
? [
{
type: 'line' as const,
label: `Meta (${meta}/dia)`,
data: labels.map(() => meta),
borderColor: '#f5222d',
backgroundColor: 'transparent',
borderWidth: 2,
borderDash: [5, 4],
pointRadius: 0,
order: 1,
},
]
: []),
],
};
const options = {
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')}`;
},
},
},
},
scales: {
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}>
{meta > 0 && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{diasComMeta} de {ultimoDia} dia{ultimoDia !== 1 ? 's' : ''} na meta
</Text>
)}
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
Meta/dia:
</Text>
<InputNumber
min={0}
size="small"
value={meta > 0 ? meta : undefined}
onChange={changeMeta}
placeholder="—"
style={{ width: 80 }}
/>
</Flex>
}
>
<div style={{ height: 280 }}>
<Chart type="bar" data={chartData} options={options} />
</div>
</Card>
);
}
export function GerPainel() {
const [periodo, setPeriodo] = useState<Dayjs>(dayjs());
const mes = periodo.month() + 1;
const ano = periodo.year();
const { data, isLoading } = useManagerDashboard(mes, ano);
const navigate = useNavigate();
const periodoLabel = periodo
.toDate()
.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
const isMesAtual = mes === dayjs().month() + 1 && ano === dayjs().year();
if (isLoading || !data) {
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
<Skeleton active paragraph={{ rows: 1 }} />
<Row gutter={[24, 24]}>
{[1, 2, 3, 4].map((i) => (
<Col key={i} xs={24} sm={12} lg={6}>
<Skeleton active />
</Col>
))}
</Row>
</Flex>
);
}
const {
faturamentoMes,
pedidosMes,
ticketMedio,
totalReps,
promocoesAtivas,
metaTotal,
metasPorGrupo,
rankingReps,
positivacaoReps,
positivacaoDiaria,
syncedAt,
} = data;
const pctMeta = metaTotal > 0 ? Math.round((faturamentoMes / metaTotal) * 100) : 0;
const faltaMeta = Math.max(0, metaTotal - faturamentoMes);
const superouMeta = faturamentoMes > metaTotal ? faturamentoMes - metaTotal : 0;
const metaCorColor =
pctMeta >= 100 ? 'var(--green)' : pctMeta >= 70 ? 'var(--jcs-blue)' : '#faad14';
// Dias restantes no período selecionado (incluindo hoje se for mês atual)
const hoje = dayjs();
const fimMes = periodo.endOf('month');
const diasRestantes = isMesAtual
? Math.max(0, fimMes.date() - hoje.date() + 1)
: periodo.isBefore(hoje, 'month')
? 0
: fimMes.date(); // mês futuro: todos os dias
const vendaPorDia = faltaMeta > 0 && diasRestantes > 0 ? faltaMeta / diasRestantes : 0;
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
{/* Cabeçalho */}
<Flex align="flex-start" justify="space-between" wrap="wrap" gap={12}>
<Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}>
Painel Gerencial
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
{today()}
</Text>
</Flex>
<Flex align="center" gap={8}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
Período:
</Text>
<DatePicker
picker="month"
value={periodo}
onChange={(d) => d && setPeriodo(d)}
format="MMM/YYYY"
allowClear={false}
disabledDate={(d) => d.isAfter(dayjs(), 'month')}
style={{ width: 130 }}
/>
</Flex>
</Flex>
{/* KPIs linha 1 */}
<Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()}
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(faturamentoMes)}
</Title>
<FontAwesomeIcon
icon={faFileInvoiceDollar}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
PEDIDOS NO {isMesAtual ? 'MÊS' : 'PERÍODO'}
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{pedidosMes}
</Title>
<FontAwesomeIcon
icon={faChartBar}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
TICKET MÉDIO
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(ticketMedio)}
</Title>
<FontAwesomeIcon icon={faUsers} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
PROMOÇÕES ATIVAS
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{promocoesAtivas}
</Title>
<FontAwesomeIcon icon={faTags} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
</Flex>
</Card>
</Col>
</Row>
{/* KPIs linha 2 — Meta */}
{metaTotal > 0 && (
<Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={8}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(metaTotal)}
</Title>
<FontAwesomeIcon
icon={faBullseye}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card>
<Flex vertical gap={8}>
<Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
ATINGIMENTO DA META
</Text>
<FontAwesomeIcon
icon={faArrowTrendUp}
style={{ color: metaCorColor, opacity: 0.7 }}
/>
</Flex>
<Title
level={3}
style={{ margin: 0, color: metaCorColor }}
className="tabular-nums"
>
{pctMeta}%
</Title>
<Progress
percent={Math.min(pctMeta, 100)}
strokeColor={metaCorColor}
showInfo={false}
size="small"
/>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
</Text>
<Title
level={3}
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
className="tabular-nums"
>
{fmt(superouMeta > 0 ? superouMeta : faltaMeta)}
</Title>
{superouMeta > 0 ? (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{pctMeta - 100}% acima da meta
</Text>
) : vendaPorDia > 0 ? (
<Text
style={{ fontSize: 'var(--text-xs)', color: metaCorColor, fontWeight: 600 }}
>
{fmt(vendaPorDia)}/dia &mdash; {diasRestantes} dia
{diasRestantes !== 1 ? 's' : ''} restante{diasRestantes !== 1 ? 's' : ''}
</Text>
) : diasRestantes === 0 ? (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
período encerrado
</Text>
) : null}
</Flex>
</Card>
</Col>
</Row>
)}
{/* Metas por Grupo */}
{metasPorGrupo.length > 0 && (
<Card
title={
<Space>
<FontAwesomeIcon icon={faLayerGroup} style={{ color: 'var(--jcs-blue)' }} />
Metas por Grupo &mdash; {periodoLabel}
</Space>
}
extra={
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{metasPorGrupo.filter((g: MetaItem) => g.pct >= 100).length} de {metasPorGrupo.length}{' '}
grupo
{metasPorGrupo.length !== 1 ? 's' : ''} atingido
{metasPorGrupo.filter((g: MetaItem) => g.pct >= 100).length !== 1 ? 's' : ''}
</Text>
}
>
<Table<MetaItem>
rowKey={(r) => String(r.codigo ?? r.rotulo)}
columns={metasGrupoColumns}
dataSource={metasPorGrupo}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhuma meta por grupo cadastrada.' }}
/>
</Card>
)}
{/* Ranking */}
<Card
title={
<Space>
<FontAwesomeIcon icon={faChartBar} style={{ color: 'var(--jcs-blue)' }} />
Ranking de Representantes &mdash; {periodoLabel}
</Space>
}
extra={
<Flex align="center" gap={12}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
top 10 de {totalReps} rep{totalReps !== 1 ? 's' : ''} com pedidos
</Text>
<Button size="small" onClick={() => void navigate({ to: '/ger/equipe' })}>
Ver todos
</Button>
</Flex>
}
>
<Table<RankingRep>
rowKey="codVendedor"
columns={rankingColumns}
dataSource={rankingReps}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhum pedido registrado neste período.' }}
/>
</Card>
{/* Positivação Diária */}
<PositivacaoDiariaCard
dados={positivacaoDiaria}
periodo={periodo}
periodoLabel={periodoLabel}
isMesAtual={isMesAtual}
/>
{/* Positivação por Representante */}
<Card
title={
<Space>
<FontAwesomeIcon icon={faAddressCard} style={{ color: 'var(--jcs-blue)' }} />
Positivação de Clientes por Representante &mdash; {periodoLabel}
</Space>
}
extra={
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação
</Text>
}
>
<Table<PositivacaoRep>
rowKey="codVendedor"
columns={positivacaoColumns}
dataSource={positivacaoReps}
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }}
size="small"
locale={{ emptyText: 'Nenhum dado de carteira encontrado.' }}
/>
</Card>
<Flex justify="space-between" style={{ paddingTop: 8 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
SAR &middot; Força de Vendas &middot; Powered by JCS Sistemas
</Text>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')}
{isMesAtual && ' · atualiza a cada 60s'}
</Text>
</Flex>
</Flex>
);
}

View File

@@ -0,0 +1,711 @@
import { useState } from 'react';
import {
Button,
Card,
DatePicker,
Flex,
Form,
InputNumber,
Modal,
Popconfirm,
Select,
Skeleton,
Space,
Table,
Tabs,
Tag,
Tooltip,
Typography,
Input,
message,
} from 'antd';
import type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons';
import dayjs from 'dayjs';
import type {
AlcadaDescontoItem,
Promocao,
RegraDesconto,
RepStats,
GrupoProdutoItem,
} from '@sar/api-interface';
import {
usePoliticasDescontos,
usePromocoes,
useUpsertDesconto,
useCreatePromocao,
useUpdatePromocao,
useDeletePromocao,
useEquipe,
useRegrasDesconto,
useGruposProduto,
useCreateRegraDesconto,
useUpdateRegraDesconto,
useDeleteRegraDesconto,
} from '../../lib/queries/gerente';
const { Title, Text } = Typography;
// ─── Tab Descontos ────────────────────────────────────────────────────────────
function TabDescontos() {
const { data, isLoading } = usePoliticasDescontos();
const upsert = useUpsertDesconto();
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editValue, setEditValue] = useState<number>(0);
const [msg, msgCtx] = message.useMessage();
function startEdit(row: AlcadaDescontoItem) {
setEditingKey(`${row.codVendedor}-${row.codGrupo}`);
setEditValue(row.limitePerc);
}
async function saveEdit(row: AlcadaDescontoItem) {
try {
await upsert.mutateAsync({
codVendedor: row.codVendedor,
codGrupo: row.codGrupo,
limitePerc: editValue,
});
} catch {
return; // erro já notificado pelo handler global do QueryClient
}
setEditingKey(null);
void msg.success('Limite atualizado');
}
const columns: TableColumnsType<AlcadaDescontoItem> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: AlcadaDescontoItem) =>
row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Grupo',
dataIndex: 'codGrupo',
width: 100,
render: (v: number) => (v === 0 ? <Tag>Global</Tag> : <Tag color="blue">Grupo {v}</Tag>),
},
{
title: 'Limite de Desconto',
dataIndex: 'limitePerc',
width: 200,
render: (v: number, row: AlcadaDescontoItem) => {
const key = `${row.codVendedor}-${row.codGrupo}`;
if (editingKey === key) {
return (
<InputNumber
value={editValue}
onChange={(val) => setEditValue(val ?? 0)}
min={0}
max={100}
suffix="%"
size="small"
style={{ width: 100 }}
autoFocus
/>
);
}
return <Text className="tabular-nums">{v}%</Text>;
},
},
{
title: '',
width: 120,
render: (_: unknown, row: AlcadaDescontoItem) => {
const key = `${row.codVendedor}-${row.codGrupo}`;
if (editingKey === key) {
return (
<Space>
<Button
size="small"
type="primary"
loading={upsert.isPending}
onClick={() => void saveEdit(row)}
>
Salvar
</Button>
<Button size="small" onClick={() => setEditingKey(null)}>
Cancelar
</Button>
</Space>
);
}
return (
<Button
size="small"
icon={<FontAwesomeIcon icon={faPen} />}
onClick={() => startEdit(row)}
>
Editar
</Button>
);
},
},
];
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
return (
<>
{msgCtx}
<Table<AlcadaDescontoItem>
rowKey={(r) => `${r.codVendedor}-${r.codGrupo}`}
columns={columns}
dataSource={data.descontos}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhum limite configurado.' }}
/>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
Limite 0 = sem restricao de desconto para este representante.
</Text>
</>
);
}
// ─── Tab Promocoes ────────────────────────────────────────────────────────────
interface PromocaoForm {
descricao: string;
codProduto?: string;
grpProd?: string;
descPct: number;
periodo: [dayjs.Dayjs, dayjs.Dayjs];
}
function TabPromocoes() {
const { data, isLoading } = usePromocoes();
const createMutation = useCreatePromocao();
const updateMutation = useUpdatePromocao();
const deleteMutation = useDeletePromocao();
const [form] = Form.useForm<PromocaoForm>();
const [modalOpen, setModalOpen] = useState(false);
const [editTarget, setEditTarget] = useState<Promocao | null>(null);
const [msg, msgCtx] = message.useMessage();
function openCreate() {
setEditTarget(null);
form.resetFields();
setModalOpen(true);
}
function openEdit(p: Promocao) {
setEditTarget(p);
form.setFieldsValue({
descricao: p.descricao,
codProduto: p.codProduto ?? undefined,
grpProd: p.grpProd ?? undefined,
descPct: p.descPct,
periodo: [dayjs(p.dataInicio), dayjs(p.dataFim)],
});
setModalOpen(true);
}
async function handleSubmit(values: PromocaoForm) {
const [inicio, fim] = values.periodo;
const body = {
descricao: values.descricao,
codProduto: values.codProduto || undefined,
grpProd: values.grpProd || undefined,
descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'),
};
try {
if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, body });
void msg.success('Promocao atualizada');
} else {
await createMutation.mutateAsync(body);
void msg.success('Promocao 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('Promocao removida');
}
const today = dayjs().format('YYYY-MM-DD');
const columns: TableColumnsType<Promocao> = [
{
title: 'Descricao',
dataIndex: 'descricao',
render: (v: string, row: Promocao) => (
<Flex vertical gap={2}>
<Text>{v}</Text>
{(row.codProduto || row.grpProd) && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{row.codProduto ? `Produto: ${row.codProduto}` : `Grupo: ${row.grpProd}`}
</Text>
)}
</Flex>
),
},
{
title: 'Desconto',
dataIndex: 'descPct',
width: 100,
align: 'right',
render: (v: number) => <Text className="tabular-nums">{v}%</Text>,
},
{
title: 'Vigencia',
width: 200,
render: (_: unknown, row: Promocao) => (
<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: Promocao) => {
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: '',
width: 100,
render: (_: unknown, row: Promocao) => (
<Space>
<Tooltip title="Editar">
<Button
size="small"
icon={<FontAwesomeIcon icon={faPen} />}
onClick={() => openEdit(row)}
/>
</Tooltip>
<Popconfirm
title="Remover promocao?"
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 Promocao
</Button>
</Flex>
<Table<Promocao>
rowKey="id"
columns={columns}
dataSource={data.promocoes}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhuma promocao cadastrada.' }}
/>
<Modal
title={editTarget ? 'Editar Promocao' : 'Nova Promocao'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void form.validateFields().then(handleSubmit)}
okText={editTarget ? 'Salvar' : 'Criar'}
confirmLoading={createMutation.isPending || updateMutation.isPending}
width={520}
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.: Promocao de inverno" />
</Form.Item>
<Flex gap={16}>
<Form.Item name="codProduto" label="Codigo do produto" style={{ flex: 1 }}>
<Input placeholder="Opcional" />
</Form.Item>
<Form.Item name="grpProd" label="Grupo de produto" style={{ flex: 1 }}>
<Input placeholder="Opcional" />
</Form.Item>
</Flex>
<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;
codGrupo?: number;
codSubgrupo?: number;
descPct: number;
periodo: [dayjs.Dayjs, dayjs.Dayjs];
}
function TabRegras() {
const { data, isLoading } = useRegrasDesconto();
const { data: equipe } = useEquipe();
const { data: gruposData } = useGruposProduto();
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 repOptions = (equipe?.reps ?? []).map((r: RepStats) => ({
value: r.codVendedor,
label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`,
}));
const grupoRows: GrupoProdutoItem[] = gruposData?.grupos ?? [];
const grupoMap = new Map<number, { value: number; label: string }>();
const subgrupoMap = new Map<number, { value: number; label: string }>();
for (const g of grupoRows) {
if (g.codGrupo != null && !grupoMap.has(g.codGrupo)) {
grupoMap.set(g.codGrupo, { value: g.codGrupo, label: g.grupo ?? `Grupo ${g.codGrupo}` });
}
if (
g.codSubgrupo != null &&
(grupoSelecionado == null || g.codGrupo === grupoSelecionado) &&
!subgrupoMap.has(g.codSubgrupo)
) {
subgrupoMap.set(g.codSubgrupo, {
value: g.codSubgrupo,
label: g.subgrupo ?? `Subgrupo ${g.codSubgrupo}`,
});
}
}
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,
descPct: r.descPct,
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
});
setModalOpen(true);
}
async function handleSubmit(values: RegraForm) {
const [inicio, fim] = values.periodo;
const body = {
descricao: values.descricao,
codVendedor: values.codVendedor ?? null,
codGrupo: values.codGrupo ?? null,
codSubgrupo: values.codSubgrupo ?? null,
descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'),
};
try {
if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, body });
void msg.success('Regra atualizada');
} else {
await createMutation.mutateAsync(body);
void msg.success('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: 220,
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: '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: '',
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 e subgrupo 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={520}
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>
<Flex gap={16}>
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
<Select
options={grupoOptions}
placeholder="Todos os grupos"
allowClear
showSearch
optionFilterProp="label"
onChange={() => form.setFieldValue('codSubgrupo', undefined)}
/>
</Form.Item>
<Form.Item name="codSubgrupo" label="Subgrupo" style={{ flex: 1 }}>
<Select
options={subgrupoOptions}
placeholder="Todos os subgrupos"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
</Flex>
<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>
</>
);
}
// ─── PoliticasPage ─────────────────────────────────────────────────────────────
export function PoliticasPage() {
const items = [
{
key: 'descontos',
label: 'Desconto por Representante',
children: <TabDescontos />,
},
{
key: 'promocoes',
label: 'Promocoes',
children: <TabPromocoes />,
},
{
key: 'regras',
label: 'Regras de Desconto',
children: <TabRegras />,
},
];
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
<Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}>
Politicas Comerciais
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
Limites de desconto e promocoes com vigencia
</Text>
</Flex>
<Card styles={{ body: { paddingTop: 0 } }}>
<Tabs items={items} />
</Card>
</Flex>
);
}

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

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

View File

@@ -1,10 +1,16 @@
import { useState } from 'react';
import { Table, Input, Select, Space, Typography, Tag } from 'antd';
import { Grid, Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd';
import { EyeOutlined } from '@ant-design/icons';
import type { TableColumnsType } from 'antd';
import type { ProdutoSummary } from '@sar/api-interface';
import { useCatalog, usePautas } from '../../lib/queries/catalog';
import { useCondicoesComerciais, type CondicaoComercial } from '../../lib/condicoes-comerciais';
import { CondicaoTag } from '../../components/CondicaoTag';
import { ProductDetailDrawer } from './ProductDetailDrawer';
const { Title } = Typography;
const { useBreakpoint } = Grid;
const { Title, Text } = Typography;
const { Search } = Input;
function fmtPrice(v: string | null | undefined): string {
@@ -12,7 +18,11 @@ function fmtPrice(v: string | null | undefined): string {
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
}
const columns: TableColumnsType<ProdutoSummary> = [
function buildColumns(
onDetail: (id: number) => void,
condicoesDe: (p: ProdutoSummary) => CondicaoComercial[],
): TableColumnsType<ProdutoSummary> {
return [
{
title: 'Código',
dataIndex: 'codigo',
@@ -24,7 +34,18 @@ const columns: TableColumnsType<ProdutoSummary> = [
dataIndex: 'descricao',
render: (v: string, row: ProdutoSummary) => (
<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>}
</div>
),
@@ -66,28 +87,58 @@ const columns: TableColumnsType<ProdutoSummary> = [
);
},
},
{
title: '',
key: 'actions',
width: 48,
align: 'center',
render: (_: unknown, row: ProdutoSummary) => (
<Tooltip title="Ver detalhes">
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => onDetail(row.idErp)}
/>
</Tooltip>
),
},
];
}
export function CatalogPage() {
const [q, setQ] = useState('');
const [idPauta, setIdPauta] = useState<number | undefined>();
const [page, setPage] = useState(1);
const [selectedIdErp, setSelectedIdErp] = useState<number | null>(null);
const limit = 50;
const screens = useBreakpoint();
const isMobile = !screens.md;
const { data: pautas, isLoading: pautasLoading } = usePautas();
const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit });
const condicoesDe = useCondicoesComerciais();
const columns = buildColumns((id) => setSelectedIdErp(id), condicoesDe);
return (
<div style={{ padding: 24 }}>
<Title level={3} style={{ marginBottom: 16 }}>
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
{/* ── Cabeçalho ────────────────────────────────────────────────── */}
<div style={{ marginBottom: 20 }}>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
Catálogo de Produtos
</Title>
<Text style={{ color: '#64748B', fontSize: 14 }}>
Consulte produtos, preços por pauta e disponibilidade de estoque.
</Text>
</div>
<Space style={{ marginBottom: 16 }} wrap>
{/* ── Filtros ──────────────────────────────────────────────────── */}
<Space style={{ marginBottom: 16, width: '100%' }} wrap>
<Search
placeholder="Buscar por código ou descrição..."
allowClear
style={{ width: 300 }}
style={{ width: isMobile ? '100%' : 300 }}
onSearch={(v) => {
setQ(v);
setPage(1);
@@ -103,7 +154,7 @@ export function CatalogPage() {
placeholder="Selecionar pauta de preços"
allowClear
loading={pautasLoading}
style={{ width: 340 }}
style={{ width: isMobile ? '100%' : 340 }}
onChange={(v) => {
setIdPauta(v as number | undefined);
setPage(1);
@@ -129,7 +180,13 @@ export function CatalogPage() {
showTotal: (t) => `${t.toLocaleString('pt-BR')} produtos`,
onChange: (p) => setPage(p),
}}
onRow={(row) => ({
style: { cursor: 'pointer' },
onDoubleClick: () => setSelectedIdErp(row.idErp),
})}
/>
<ProductDetailDrawer idErp={selectedIdErp} onClose={() => setSelectedIdErp(null)} />
</div>
);
}

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

@@ -1,12 +1,38 @@
import { Button, Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd';
import {
Button,
Descriptions,
Grid,
Tag,
Table,
Typography,
Spin,
Alert,
Space,
Divider,
Modal,
Tooltip,
message,
Badge,
} from 'antd';
import { useState } from 'react';
import type { TableColumnsType } from 'antd';
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
import { Link, useNavigate, useParams } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
import type { CtrTitulo, NotaFiscal, PedidoSummary, TopProduto } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useClientDetail } from '../../lib/queries/clients';
import { useClientOrders } from '../../lib/queries/orders';
import {
useClientDetail,
useClientCtr,
useClientCtrList,
useClientNotasFiscais,
useClientOrdersHistory,
useClientTopProdutos,
} from '../../lib/queries/clients';
import { ClientContacts } from '../../components/contacts/ClientContacts';
const { Title } = Typography;
const { useBreakpoint } = Grid;
const { Title, Text } = Typography;
const ACTIVITY_COLOR: Record<string, string> = {
active: 'success',
@@ -19,11 +45,19 @@ const ACTIVITY_LABEL: Record<string, string> = {
inactive: 'Inativo',
};
const orderColumns: TableColumnsType<PedidoSummary> = [
const SITUA_COLOR: Record<number, string> = {
0: 'default',
1: 'warning',
2: 'processing',
3: 'error',
4: 'success',
};
function orderColumns(withFonte: boolean): TableColumnsType<PedidoSummary> {
const cols: TableColumnsType<PedidoSummary> = [
{
title: 'Nº',
title: 'Nº Pedido',
dataIndex: 'numPedSar',
width: 120,
render: (num: string, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
@@ -33,46 +67,162 @@ const orderColumns: TableColumnsType<PedidoSummary> = [
{
title: 'Status',
dataIndex: 'situa',
width: 140,
render: (s: number) => {
const colorMap: Record<number, string> = {
1: 'warning',
2: 'processing',
3: 'error',
4: 'success',
};
return <Tag color={colorMap[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>;
},
width: 130,
render: (s: number) => (
<Tag color={SITUA_COLOR[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>
),
},
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right',
align: 'right' as const,
render: (v: string) =>
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Data',
dataIndex: 'dtPedido',
width: 130,
width: 110,
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
];
if (withFonte) {
cols.push({
title: 'Origem',
dataIndex: 'fonte',
width: 70,
render: (f: string) => (
<Tag color={f === 'sar' ? 'blue' : 'default'} style={{ fontSize: 11 }}>
{f === 'sar' ? 'SAR' : 'ERP'}
</Tag>
),
});
}
return cols;
}
export function ClientDetailPage() {
const { id } = useParams({ from: '/clientes/$id' });
const idNum = Number(id);
const navigate = useNavigate();
const screens = useBreakpoint();
const isMobile = !screens.md;
const [addContactOpen, setAddContactOpen] = useState(false);
const [ordersModalOpen, setOrdersModalOpen] = useState(false);
const [produtosModalOpen, setProdutosModalOpen] = useState(false);
const [notasModalOpen, setNotasModalOpen] = useState(false);
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(idNum);
const { data: orders, isLoading: ordersLoading } = useClientOrders(idNum);
const { data: ctr } = useClientCtr(idNum);
const { data: ctrList = [], isLoading: ctrListLoading } = useClientCtrList(idNum, 60);
const { data: orders = [], isLoading: ordersLoading } = useClientOrdersHistory(idNum, 50);
const { data: topProdutos = [], isLoading: produtosLoading } = useClientTopProdutos(idNum, 30);
const { data: notas = [], isLoading: notasLoading } = useClientNotasFiscais(idNum, 30);
const [msgApi, msgCtx] = message.useMessage();
if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
if (clientError || !client)
return <Alert type="error" message="Cliente não encontrado." style={{ margin: 24 }} />;
return <Alert type="error" title="Cliente não encontrado." style={{ margin: 24 }} />;
const today: string = new Date().toISOString().slice(0, 10);
const ordersPreview = orders.slice(0, 5);
const produtosPreview = topProdutos.slice(0, 5);
const notasPreview = notas.slice(0, 5);
const ctrPreview = ctrList.slice(0, 8);
const notaColumns: TableColumnsType<NotaFiscal> = [
{
title: 'NF',
dataIndex: 'numero',
width: 90,
render: (v: number, r: NotaFiscal) => `${v}-${r.serie}`,
},
{
title: 'Emissão',
dataIndex: 'dtEmissao',
width: 100,
render: (v: string) => new Date(v + 'T12:00:00').toLocaleDateString('pt-BR'),
},
{
title: 'Valor',
dataIndex: 'vlNf',
width: 130,
align: 'right' as const,
render: (v: number) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Chave NF-e',
dataIndex: 'chaveAcessoNfe',
render: (chave: string | null) =>
chave ? (
<Space size={4}>
<Text
style={{ fontSize: 11, fontFamily: 'monospace', color: '#666' }}
ellipsis={{ tooltip: chave }}
>
{chave}
</Text>
<Tooltip title="Copiar chave">
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() =>
void navigator.clipboard
.writeText(chave)
.then(() => void msgApi.success('Chave copiada!'))
}
/>
</Tooltip>
</Space>
) : (
'—'
),
},
];
const produtoColumns: TableColumnsType<TopProduto> = [
{
title: 'Produto',
dataIndex: 'descricao',
ellipsis: true,
},
{
title: 'Qtd Total',
dataIndex: 'qtdTotal',
width: 100,
align: 'right' as const,
render: (v: number, r: TopProduto) =>
`${v.toLocaleString('pt-BR', { maximumFractionDigits: 0 })} ${r.unidade ?? ''}`.trim(),
},
{
title: 'Pedidos',
dataIndex: 'numPedidos',
width: 80,
align: 'center' as const,
},
{
title: 'Último Preço',
dataIndex: 'ultimoPreco',
width: 120,
align: 'right' as const,
render: (v: number) =>
v > 0 ? v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—',
},
{
title: 'Última Compra',
dataIndex: 'ultimaCompra',
width: 120,
render: (v: string) => new Date(v + 'T12:00:00').toLocaleDateString('pt-BR'),
},
];
return (
<div style={{ padding: 24 }}>
<div style={{ padding: 24, maxWidth: '100%', overflowX: 'hidden' }}>
<Space align="center" style={{ marginBottom: 16 }} wrap>
<Link to="/clientes"> Clientes</Link>
<Title level={3} style={{ margin: 0 }}>
@@ -81,6 +231,9 @@ export function ClientDetailPage() {
<Tag color={ACTIVITY_COLOR[client.activityStatus]}>
{ACTIVITY_LABEL[client.activityStatus]}
</Tag>
<Button icon={<UserAddOutlined />} onClick={() => setAddContactOpen(true)}>
Novo Contato
</Button>
<Button
type="primary"
onClick={() => void navigate({ to: '/pedidos/novo', search: { clientId: id } })}
@@ -89,7 +242,7 @@ export function ClientDetailPage() {
</Button>
</Space>
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
<Descriptions bordered size="small" column={isMobile ? 1 : 2} style={{ marginBottom: 24 }}>
<Descriptions.Item label="Razão Social">{client.nome}</Descriptions.Item>
<Descriptions.Item label="CNPJ / CPF">{client.cgcpf ?? '—'}</Descriptions.Item>
<Descriptions.Item label="E-mail">{client.email ?? '—'}</Descriptions.Item>
@@ -98,7 +251,7 @@ export function ClientDetailPage() {
{client.telefone ?? '—'}
</Descriptions.Item>
{client.endereco && (
<Descriptions.Item label="Endereço" span={2}>
<Descriptions.Item label="Endereço" span={isMobile ? 1 : 2}>
{client.endereco}
{client.numEndereco ? `, ${client.numEndereco}` : ''}
{client.bairro ? `${client.bairro}` : ''}
@@ -120,18 +273,315 @@ export function ClientDetailPage() {
</Descriptions.Item>
</Descriptions>
<Divider orientation="left">Últimos Pedidos</Divider>
{/* ── CTR + NF-e lado a lado ── */}
{msgCtx}
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr',
gap: 16,
marginBottom: 24,
}}
>
{/* ── Contas a Receber ── */}
{/* 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
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<Space size={8}>
<Text strong style={{ fontSize: 14 }}>
Contas a Receber
</Text>
{ctr && ctr.qtdAberto > 0 && (
<Tag color={ctr.qtdVencido > 0 ? 'error' : 'warning'}>
{ctr.qtdAberto} título{ctr.qtdAberto > 1 ? 's' : ''} {' '}
{ctr.totalAberto.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
</Tag>
)}
</Space>
</div>
<Divider style={{ margin: '0 0 10px' }} />
<Table<CtrTitulo>
rowKey="idCtr"
size="small"
pagination={false}
loading={ctrListLoading}
dataSource={ctrPreview}
locale={{ emptyText: 'Nenhum título em aberto.' }}
scroll={{ x: 'max-content', y: ctrList.length > 8 ? 260 : undefined }}
style={{ maxWidth: '100%' }}
columns={[
{
title: 'Título / Parcela',
dataIndex: 'numero',
render: (num: string, r: CtrTitulo) => (
<Space size={4}>
<Text style={{ fontSize: 12 }}>
{r.prefixo}-{num}
</Text>
{r.dtVencimento < today ? (
<Badge
status="error"
text={
<Text type="danger" style={{ fontSize: 11 }}>
{Math.ceil(
(new Date(today).getTime() - new Date(r.dtVencimento).getTime()) /
86400000,
)}
d atraso
</Text>
}
/>
) : (
<Badge
status="warning"
text={
<Text type="warning" style={{ fontSize: 11 }}>
a vencer
</Text>
}
/>
)}
</Space>
),
},
{
title: 'Vencimento',
dataIndex: 'dtVencimento',
width: 100,
render: (v: string) => (
<Text type={v < today ? 'danger' : undefined} style={{ fontSize: 12 }}>
{new Date(v + 'T12:00:00').toLocaleDateString('pt-BR')}
</Text>
),
},
{
title: 'Saldo',
dataIndex: 'saldo',
width: 110,
align: 'right' as const,
render: (v: number) => (
<Text strong style={{ fontSize: 12 }}>
{v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
</Text>
),
},
]}
footer={
ctrList.length > 8
? () => (
<Text type="secondary" style={{ fontSize: 12 }}>
Exibindo 8 de {ctrList.length}
{ctrList.length === 60 ? '+' : ''} títulos total em aberto:{' '}
{ctr?.totalAberto.toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})}
</Text>
)
: undefined
}
/>
</div>
{/* ── Notas Fiscais ── */}
<div style={{ minWidth: 0 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<Text strong style={{ fontSize: 14 }}>
Notas Fiscais
</Text>
{notas.length > 5 && (
<Button size="small" type="link" onClick={() => setNotasModalOpen(true)}>
Ver todas ({notas.length}
{notas.length === 30 ? '+' : ''})
</Button>
)}
</div>
<Divider style={{ margin: '0 0 10px' }} />
<Table<NotaFiscal>
rowKey="idNf"
size="small"
pagination={false}
loading={notasLoading}
dataSource={notasPreview}
locale={{ emptyText: 'Nenhuma nota fiscal.' }}
style={{ maxWidth: '100%' }}
scroll={{ x: 'max-content' }}
columns={notaColumns}
/>
</div>
</div>
<Modal
title={
<Space>
<span>Notas Fiscais {client.razao ?? client.nome}</span>
<Text type="secondary" style={{ fontSize: 13 }}>
({notas.length}
{notas.length === 30 ? '+' : ''} notas)
</Text>
</Space>
}
open={notasModalOpen}
onCancel={() => setNotasModalOpen(false)}
footer={null}
width={isMobile ? '95vw' : 900}
centered
destroyOnHidden
>
<Table<NotaFiscal>
rowKey="idNf"
columns={notaColumns}
dataSource={notas}
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
size="small"
scroll={{ y: 460, x: 'max-content' }}
/>
</Modal>
<ClientContacts
idCliente={idNum}
modalOpen={addContactOpen}
onModalClose={() => setAddContactOpen(false)}
/>
{/* ── Produtos Mais Comprados ── */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<Text strong style={{ fontSize: 14 }}>
Produtos Mais Comprados
</Text>
{topProdutos.length > 5 && (
<Button size="small" type="link" onClick={() => setProdutosModalOpen(true)}>
Ver todos ({topProdutos.length}
{topProdutos.length === 30 ? '+' : ''})
</Button>
)}
</div>
<Divider style={{ margin: '0 0 12px' }} />
<Table<TopProduto>
rowKey="idProduto"
columns={produtoColumns}
dataSource={produtosPreview}
loading={produtosLoading}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhum produto encontrado.' }}
style={{ marginBottom: 24, maxWidth: '100%' }}
scroll={{ x: 'max-content' }}
/>
<Modal
title={
<Space>
<span>Produtos {client.razao ?? client.nome}</span>
<Text type="secondary" style={{ fontSize: 13 }}>
({topProdutos.length}
{topProdutos.length === 30 ? '+' : ''} produtos)
</Text>
</Space>
}
open={produtosModalOpen}
onCancel={() => setProdutosModalOpen(false)}
footer={null}
width={isMobile ? '95vw' : 820}
centered
destroyOnHidden
>
<Table<TopProduto>
rowKey="idProduto"
columns={produtoColumns}
dataSource={topProdutos}
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
size="small"
scroll={{ y: 460 }}
/>
</Modal>
{/* ── Últimos Pedidos ── */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<Text strong style={{ fontSize: 14 }}>
Últimos Pedidos
</Text>
{orders.length > 5 && (
<Button size="small" type="link" onClick={() => setOrdersModalOpen(true)}>
Ver todos ({orders.length}
{orders.length === 50 ? '+' : ''})
</Button>
)}
</div>
<Divider style={{ margin: '0 0 12px' }} />
<Table<PedidoSummary>
rowKey="id"
columns={orderColumns}
dataSource={orders ?? []}
columns={orderColumns(false)}
dataSource={ordersPreview}
loading={ordersLoading}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhum pedido encontrado.' }}
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
style={{ marginBottom: 24, maxWidth: '100%' }}
scroll={{ x: 'max-content' }}
/>
<style>{`.row-pending td { background: #fffbe6 !important; }`}</style>
{/* ── Modal Ver Todos Pedidos ── */}
<Modal
title={
<Space>
<span>Pedidos {client.razao ?? client.nome}</span>
<Text type="secondary" style={{ fontSize: 13 }}>
({orders.length}
{orders.length === 50 ? '+' : ''} registros)
</Text>
</Space>
}
open={ordersModalOpen}
onCancel={() => setOrdersModalOpen(false)}
footer={null}
width={isMobile ? '95vw' : 780}
centered
destroyOnHidden
>
<Table<PedidoSummary>
rowKey="id"
columns={orderColumns(true)}
dataSource={orders}
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
size="small"
scroll={{ y: 460 }}
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
/>
</Modal>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import {
Button,
Card,
Col,
Drawer,
Modal,
Dropdown,
Grid,
Row,
@@ -186,7 +186,13 @@ function CustomerMetrics({ stats }: { stats: PortfolioStats }) {
// ─── CustomerPortfolioCard ────────────────────────────────────────────────────
function CustomerPortfolioCard({ stats }: { stats: PortfolioStats }) {
function CustomerPortfolioCard({
stats,
onDetalhar,
}: {
stats: PortfolioStats;
onDetalhar: () => void;
}) {
const mesAtual = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
const total = stats.ativos + stats.emAlerta + stats.inativos;
@@ -277,7 +283,7 @@ function CustomerPortfolioCard({ stats }: { stats: PortfolioStats }) {
)}
</div>
<Space direction="vertical" size={6} style={{ width: '100%', marginBottom: 16 }}>
<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 (
@@ -313,6 +319,7 @@ function CustomerPortfolioCard({ stats }: { stats: PortfolioStats }) {
<Divider style={{ margin: '12px 0' }} />
<Button
block
onClick={onDetalhar}
style={{ borderRadius: 8, fontWeight: 600, color: '#003B8E', borderColor: '#003B8E' }}
>
Detalhar carteira
@@ -397,9 +404,9 @@ function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) {
);
}
// ─── CustomerDetailsDrawer ────────────────────────────────────────────────────
// ─── CustomerDetailsModal ────────────────────────────────────────────────────
function CustomerDetailsDrawer({
function CustomerDetailsModal({
summary,
onClose,
onAnalyze,
@@ -409,6 +416,8 @@ function CustomerDetailsDrawer({
onAnalyze: () => void;
}) {
const navigate = useNavigate();
const screens = useBreakpoint();
const isMobile = !screens.md;
const { data: detail, isLoading: loadingDetail } = useClientDetail(summary?.idCliente);
const { data: orders = [], isLoading: loadingOrders } = useClientOrders(summary?.idCliente);
@@ -439,7 +448,7 @@ function CustomerDetailsDrawer({
};
return (
<Drawer
<Modal
title={
<Space>
<Text strong style={{ fontSize: 16 }}>
@@ -448,17 +457,40 @@ function CustomerDetailsDrawer({
<CustomerStatusBadge status={summary.activityStatus} />
</Space>
}
open
onClose={onClose}
width={520}
placement="right"
styles={{ body: { padding: '16px 24px' } }}
open={!!summary}
onCancel={onClose}
centered
width={isMobile ? '95vw' : 860}
destroyOnHidden
styles={{
body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' },
}}
footer={
<Space wrap>
<Button onClick={onClose}>Fechar</Button>
<Button
icon={<WhatsAppOutlined />}
style={{
background: '#25D366',
borderColor: '#25D366',
color: '#fff',
fontWeight: 600,
}}
onClick={() => window.open(`https://wa.me/55${phone.replace(/\D/g, '')}`, '_blank')}
>
WhatsApp
</Button>
<Button icon={<BarChartOutlined />} onClick={onAnalyze}>
Analisar
</Button>
<Button
icon={<UserOutlined />}
onClick={() =>
void navigate({ to: '/clientes/$id', params: { id: String(summary.idCliente) } })
}
>
Abrir ficha
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
@@ -475,12 +507,14 @@ function CustomerDetailsDrawer({
</Space>
}
>
<Space direction="vertical" size={20} style={{ width: '100%' }}>
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
{/* Identificação */}
<Card
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
styles={{ body: { padding: '14px 16px' } }}
>
<Row gutter={[16, 8]}>
<Col span={16}>
<Text strong style={{ fontSize: 18, color: '#1F2937', display: 'block' }}>
{summary.nome}
</Text>
@@ -494,10 +528,16 @@ function CustomerDetailsDrawer({
{summary.cgcpf}
</Text>
)}
</Col>
<Col span={8} style={{ textAlign: 'right' }}>
<CustomerStatusBadge status={summary.activityStatus} />
</Col>
</Row>
</Card>
{/* Dados cadastrais */}
<div>
{/* Dados cadastrais + comerciais em 2 colunas */}
<Row gutter={[24, 16]}>
<Col span={12}>
<Text
style={{
fontSize: 12,
@@ -511,48 +551,45 @@ function CustomerDetailsDrawer({
>
Dados Cadastrais
</Text>
<Row gutter={[12, 12]}>
<Col span={12}>
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
<div>
<span style={label}>Telefone</span>
<Space size={4}>
<PhoneOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
<Text style={{ fontSize: 13 }}>{phone}</Text>
</Space>
</Col>
<Col span={12}>
</div>
<div>
<span style={label}>E-mail</span>
<Space size={4}>
<MailOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
<Text style={{ fontSize: 13, wordBreak: 'break-all' }}>{summary.email ?? '—'}</Text>
<Text style={{ fontSize: 13, wordBreak: 'break-all' }}>
{summary.email ?? '—'}
</Text>
</Space>
</Col>
</div>
{loadingDetail ? (
<Col span={24}>
<Spin size="small" />
</Col>
) : (
<>
{endereco !== '—' && (
<Col span={24}>
<div>
<span style={label}>Endereço</span>
<Text style={{ fontSize: 13 }}>{endereco}</Text>
</Col>
</div>
)}
{d?.inscricaoEstadual && (
<Col span={12}>
<div>
<span style={label}>Insc. Estadual</span>
<Text style={{ fontSize: 13 }}>{d.inscricaoEstadual}</Text>
</Col>
</div>
)}
</>
)}
</Row>
</div>
</Space>
</Col>
<Divider style={{ margin: '4px 0' }} />
{/* Dados comerciais */}
<div>
<Col span={12}>
<Text
style={{
fontSize: 12,
@@ -566,16 +603,16 @@ function CustomerDetailsDrawer({
>
Dados Comerciais
</Text>
<Row gutter={[12, 12]}>
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
{limiteFormatado !== '—' && (
<Col span={12}>
<div>
<span style={label}>Limite de Crédito</span>
<Text strong style={{ fontSize: 13, color: '#003B8E' }}>
{limiteFormatado}
</Text>
</Col>
</div>
)}
<Col span={12}>
<div>
<span style={label}>Representante</span>
<Text style={{ fontSize: 13 }}>
{summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`}
@@ -585,9 +622,9 @@ function CustomerDetailsDrawer({
</Text>
)}
</Text>
</Col>
</div>
{summary.dtUltimaCompra && (
<Col span={12}>
<div>
<span style={label}>Última Compra</span>
<Space size={4}>
<CalendarOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
@@ -600,10 +637,11 @@ function CustomerDetailsDrawer({
)}
</Text>
</Space>
</Col>
)}
</Row>
</div>
)}
</Space>
</Col>
</Row>
{/* Últimos pedidos */}
{(loadingOrders || orders.length > 0) && (
@@ -626,7 +664,7 @@ function CustomerDetailsDrawer({
{loadingOrders ? (
<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) => (
<div
key={p.id}
@@ -698,35 +736,22 @@ function CustomerDetailsDrawer({
</div>
</>
)}
<Button
block
icon={<WhatsAppOutlined />}
style={{
borderRadius: 8,
background: '#25D366',
borderColor: '#25D366',
color: '#fff',
fontWeight: 600,
}}
onClick={() => window.open(`https://wa.me/55${phone.replace(/\D/g, '')}`, '_blank')}
>
Enviar WhatsApp
</Button>
</Space>
</Drawer>
</Modal>
);
}
// ─── CustomerAnalysisDrawer ───────────────────────────────────────────────────
// ─── CustomerAnalysisModal ───────────────────────────────────────────────────
function CustomerAnalysisDrawer({
function CustomerAnalysisModal({
summary,
onClose,
}: {
summary: ClientSummary | null;
onClose: () => void;
}) {
const screens = useBreakpoint();
const isMobile = !screens.md;
const { data: detail } = useClientDetail(summary?.idCliente);
if (!summary) return null;
@@ -747,25 +772,55 @@ function CustomerAnalysisDrawer({
};
return (
<Drawer
<Modal
title={
<Space>
<BarChartOutlined />
<Text strong>Análise Comercial</Text>
<Text strong>Análise Comercial {summary.nome}</Text>
</Space>
}
open
onClose={onClose}
width={480}
placement="right"
styles={{ body: { padding: '16px 24px' } }}
open={!!summary}
onCancel={onClose}
centered
width={isMobile ? '95vw' : 720}
destroyOnHidden
styles={{ body: { padding: '20px 24px' } }}
footer={
<Space>
<Button
icon={<WhatsAppOutlined />}
style={{
background: '#25D366',
borderColor: '#25D366',
color: '#fff',
fontWeight: 600,
borderRadius: 8,
}}
onClick={() =>
window.open(
`https://wa.me/55${(summary.telefone ?? '').replace(/\D/g, '')}`,
'_blank',
)
}
>
<Space direction="vertical" size={20} style={{ width: '100%' }}>
WhatsApp
</Button>
<Button
icon={<MailOutlined />}
style={{ borderRadius: 8 }}
onClick={() => window.open(`mailto:${summary.email ?? ''}`, '_blank')}
>
E-mail
</Button>
<Button onClick={onClose} style={{ borderRadius: 8 }}>
Fechar
</Button>
</Space>
}
>
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
<div>
<Text strong style={{ fontSize: 17, color: '#1F2937', display: 'block' }}>
{summary.nome}
</Text>
<Space size={8} style={{ marginTop: 4 }}>
<Space size={8}>
<CustomerStatusBadge status={summary.activityStatus} />
{summary.cgcpf && (
<Text style={{ fontSize: 12, color: '#64748B' }}>{summary.cgcpf}</Text>
@@ -867,39 +922,8 @@ function CustomerAnalysisDrawer({
</div>
</div>
)}
<Space>
<Button
icon={<WhatsAppOutlined />}
style={{
background: '#25D366',
borderColor: '#25D366',
color: '#fff',
fontWeight: 600,
borderRadius: 8,
}}
onClick={() =>
window.open(
`https://wa.me/55${(summary.telefone ?? '').replace(/\D/g, '')}`,
'_blank',
)
}
>
WhatsApp
</Button>
<Button
icon={<MailOutlined />}
style={{ borderRadius: 8 }}
onClick={() => window.open(`mailto:${summary.email ?? ''}`, '_blank')}
>
E-mail
</Button>
<Button onClick={onClose} style={{ borderRadius: 8 }}>
Fechar
</Button>
</Space>
</Space>
</Drawer>
</Modal>
);
}
@@ -1042,6 +1066,7 @@ export function ClientsPage() {
const screens = useBreakpoint();
const isMobile = !screens.md;
const { message: msg } = App.useApp();
const navigate = useNavigate();
const stats = usePortfolioStats();
@@ -1062,7 +1087,7 @@ export function ClientsPage() {
limit,
});
const rows = data?.data ?? [];
const rows = useMemo(() => data?.data ?? [], [data]);
const sorted = useMemo(() => {
const r = [...rows];
@@ -1113,7 +1138,9 @@ export function ClientsPage() {
display: 'block',
lineHeight: 1.3,
}}
onClick={() => setDetailSummary(c)}
onClick={() =>
void navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })
}
>
{c.nome}
</Text>
@@ -1291,7 +1318,7 @@ export function ClientsPage() {
icon={<PlusOutlined />}
size="large"
style={{ borderRadius: 8, fontWeight: 600 }}
onClick={() => void msg.info('Cadastro de clientes em breve.')}
onClick={() => void navigate({ to: '/clientes/novo' })}
>
Cadastrar Cliente
</Button>
@@ -1318,7 +1345,7 @@ export function ClientsPage() {
type="primary"
icon={<PlusOutlined />}
style={{ borderRadius: 8, fontWeight: 600 }}
onClick={() => void msg.info('Cadastro de clientes em breve.')}
onClick={() => void navigate({ to: '/clientes/novo' })}
>
Cadastrar Cliente
</Button>
@@ -1436,14 +1463,19 @@ export function ClientsPage() {
{/* ── Portfolio mobile (antes da lista) ─────────────────────────── */}
{isMobile && (
<>
<CustomerPortfolioCard stats={stats} />
<CustomerPortfolioCard
stats={stats}
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
/>
<div style={{ marginBottom: 16 }} />
</>
)}
{/* ── Área principal ────────────────────────────────────────────── */}
<Row gutter={[20, 20]}>
<Col xs={24} lg={17}>
{/* minWidth: 0 — Col é flex item (min-width auto). Sem isto a tabela com
scroll x estoura a coluna e engole o card vizinho. */}
<Col xs={24} lg={17} style={{ minWidth: 0 }}>
{isLoading ? (
<div style={{ textAlign: 'center', padding: 64 }}>
<Spin size="large" />
@@ -1519,14 +1551,17 @@ export function ClientsPage() {
{!isMobile && (
<Col lg={7}>
<CustomerPortfolioCard stats={stats} />
<CustomerPortfolioCard
stats={stats}
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
/>
</Col>
)}
</Row>
{/* ── Drawers ───────────────────────────────────────────────────── */}
{/* ── Modais ───────────────────────────────────────────────────── */}
{detailSummary && (
<CustomerDetailsDrawer
<CustomerDetailsModal
summary={detailSummary}
onClose={() => setDetailSummary(null)}
onAnalyze={() => {
@@ -1536,10 +1571,7 @@ export function ClientsPage() {
/>
)}
{analysisSummary && (
<CustomerAnalysisDrawer
summary={analysisSummary}
onClose={() => setAnalysisSummary(null)}
/>
<CustomerAnalysisModal summary={analysisSummary} onClose={() => setAnalysisSummary(null)} />
)}
{/* FAB mobile */}
@@ -1549,7 +1581,7 @@ export function ClientsPage() {
shape="circle"
icon={<PlusOutlined />}
size="large"
onClick={() => void msg.info('Cadastro de clientes em breve.')}
onClick={() => void navigate({ to: '/clientes/novo' })}
style={{
position: 'fixed',
bottom: 24,

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

@@ -0,0 +1,435 @@
import { useState } from 'react';
import {
Alert,
App,
Button,
Card,
Col,
Form,
Input,
InputNumber,
Row,
Segmented,
Select,
Spin,
Switch,
Typography,
} from 'antd';
import {
ArrowLeftOutlined,
BankOutlined,
CheckCircleOutlined,
EnvironmentOutlined,
PhoneOutlined,
ShopOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useNavigate } from '@tanstack/react-router';
import type { CreateClientNovo } from '@sar/api-interface';
import { useMunicipios, usePautas, useFormasPagamento } from '../../lib/queries/catalog';
import { useCreateClientNovo } from '../../lib/queries/clients';
const { Title, Text } = Typography;
type Pessoa = 'PJ' | 'PF';
function Section({
icon,
title,
children,
}: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}) {
return (
<div style={{ marginBottom: 24 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 16,
paddingBottom: 8,
borderBottom: '1px solid #EBF0F5',
}}
>
<span style={{ color: '#003B8E', fontSize: 16 }}>{icon}</span>
<Text strong style={{ fontSize: 14, color: '#003B8E' }}>
{title}
</Text>
</div>
{children}
</div>
);
}
export function NewClientPage() {
const navigate = useNavigate();
const { message } = App.useApp();
const [form] = Form.useForm();
const [pessoa, setPessoa] = useState<Pessoa>('PJ');
const [municipioSearch, setMunicipioSearch] = useState('');
const [syncError, setSyncError] = useState<string | null>(null);
const { data: municipios = [], isFetching: loadingMun } = useMunicipios(
municipioSearch || undefined,
);
const { data: pautas = [] } = usePautas();
const { data: formas = [] } = useFormasPagamento();
const createMutation = useCreateClientNovo();
async function handleCepBlur(cep: string) {
const digits = cep.replace(/\D/g, '');
if (digits.length !== 8) return;
try {
const res = await fetch(`https://viacep.com.br/ws/${digits}/json/`);
const data = (await res.json()) as {
erro?: boolean;
logradouro?: string;
bairro?: string;
localidade?: string;
uf?: string;
};
if (data.erro) return;
if (data.logradouro) form.setFieldValue('endereco', data.logradouro);
if (data.bairro) form.setFieldValue('bairro', data.bairro);
if (data.localidade && data.uf) {
setMunicipioSearch(data.localidade);
}
} catch {
// silencioso — CEP inválido não bloqueia
}
}
async function onFinish(values: Record<string, unknown>) {
setSyncError(null);
const payload: CreateClientNovo = {
pesso: pessoa === 'PJ' ? 0 : 1,
consfinal: values.consfinal ? 1 : 0,
nome: String(values.nome ?? '').slice(0, 40),
razao: String(values.razao ?? values.nome ?? '').slice(0, 65),
cgcpf: values.cgcpf ? String(values.cgcpf) : undefined,
sufCgcpf: '',
inscr: values.inscr ? String(values.inscr) : '',
indicadorIe: values.indicadorIe as 1 | 2 | 9 | undefined,
endereco: String(values.endereco ?? '').slice(0, 60),
numEndereco: String(values.numEndereco ?? '').slice(0, 10),
bairro: String(values.bairro ?? '').slice(0, 60),
idMunicipio: Number(values.idMunicipio),
cep: String(values.cep ?? '')
.replace(/\D/g, '')
.slice(0, 9),
ddd: String(values.ddd ?? '').slice(0, 4),
telefone: String(values.telefone ?? '').slice(0, 35),
email: String(values.email ?? '').slice(0, 80),
limiteCredito: Number(values.limiteCredito ?? 0),
codFormapag: values.codFormapag ? Number(values.codFormapag) : undefined,
codPauta: values.codPauta ? Number(values.codPauta) : undefined,
};
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) {
setSyncError(result.erroSync);
void message.warning('Cliente cadastrado, mas houve erro na sincronização com o ERP.');
} else {
void message.success(
`Cliente cadastrado com sucesso! Código ERP: ${result.idCorrentErp ?? '—'}`,
);
void navigate({ to: '/clientes' });
}
}
const isPJ = pessoa === 'PJ';
return (
<div style={{ maxWidth: 860, margin: '0 auto', padding: '24px 16px' }}>
{/* Cabeçalho */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 24 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => void navigate({ to: '/clientes' })}
style={{ borderRadius: 8 }}
>
Voltar
</Button>
<div>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
Cadastrar Cliente
</Title>
<Text type="secondary" style={{ fontSize: 13 }}>
Após salvar, o cliente é criado automaticamente no ERP.
</Text>
</div>
</div>
{/* Seletor PJ / PF */}
<div style={{ marginBottom: 24 }}>
<Segmented
size="large"
value={pessoa}
onChange={(v) => {
setPessoa(v as Pessoa);
form.resetFields(['cgcpf', 'inscr', 'indicadorIe', 'consfinal', 'razao']);
}}
options={[
{ value: 'PJ', icon: <BankOutlined />, label: 'CNPJ — Pessoa Jurídica' },
{ value: 'PF', icon: <UserOutlined />, label: 'CPF — Pessoa Física' },
]}
style={{ borderRadius: 10 }}
/>
</div>
<Form
form={form}
layout="vertical"
onFinish={(v) => void onFinish(v as Record<string, unknown>)}
requiredMark="optional"
initialValues={{ consfinal: false, limiteCredito: 0 }}
>
<Card
style={{ borderRadius: 12, border: '1px solid #EBF0F5', marginBottom: 16 }}
styles={{ body: { padding: '20px 24px' } }}
>
{/* ── Identificação ─────────────────────────────────────────── */}
<Section icon={isPJ ? <ShopOutlined /> : <UserOutlined />} title="Identificação">
<Row gutter={[16, 0]}>
{isPJ ? (
<>
<Col xs={24} md={12}>
<Form.Item
name="razao"
label="Razão Social"
rules={[{ required: true, message: 'Informe a razão social' }]}
>
<Input placeholder="Razão Social Ltda" maxLength={65} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
name="nome"
label="Nome Fantasia"
rules={[{ required: true, message: 'Informe o nome fantasia' }]}
>
<Input placeholder="Nome fantasia" maxLength={40} />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="cgcpf" label="CNPJ">
<Input placeholder="00.000.000/0000-00" maxLength={18} />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="inscr" label="Inscrição Estadual">
<Input placeholder="000.000.000" maxLength={18} />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="indicadorIe" label="Indicador IE">
<Select placeholder="Selecione" allowClear>
<Select.Option value={1}>Contribuinte</Select.Option>
<Select.Option value={2}>Isento</Select.Option>
<Select.Option value={9}>Não contribuinte</Select.Option>
</Select>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="consfinal" label="Consumidor Final" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
</>
) : (
<>
<Col xs={24} md={16}>
<Form.Item
name="nome"
label="Nome Completo"
rules={[{ required: true, message: 'Informe o nome' }]}
>
<Input placeholder="Nome completo" maxLength={40} />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="cgcpf" label="CPF">
<Input placeholder="000.000.000-00" maxLength={14} />
</Form.Item>
</Col>
</>
)}
</Row>
</Section>
{/* ── Endereço ──────────────────────────────────────────────── */}
<Section icon={<EnvironmentOutlined />} title="Endereço">
<Row gutter={[16, 0]}>
<Col xs={24} md={6}>
<Form.Item name="cep" label="CEP">
<Input
placeholder="00000-000"
maxLength={9}
onBlur={(e) => void handleCepBlur(e.target.value)}
/>
</Form.Item>
</Col>
<Col xs={24} md={14}>
<Form.Item name="endereco" label="Endereço">
<Input placeholder="Rua / Av." maxLength={60} />
</Form.Item>
</Col>
<Col xs={24} md={4}>
<Form.Item name="numEndereco" label="Número">
<Input placeholder="123" maxLength={10} />
</Form.Item>
</Col>
<Col xs={24} md={10}>
<Form.Item name="bairro" label="Bairro">
<Input placeholder="Bairro" maxLength={60} />
</Form.Item>
</Col>
<Col xs={24} md={14}>
<Form.Item
name="idMunicipio"
label="Município"
rules={[{ required: true, message: 'Selecione o município' }]}
>
<Select
showSearch
placeholder="Digite para buscar..."
filterOption={false}
onSearch={setMunicipioSearch}
notFoundContent={loadingMun ? <Spin size="small" /> : 'Nenhum resultado'}
optionLabelProp="label"
>
{municipios.map((m) => (
<Select.Option
key={m.idMunicipio}
value={m.idMunicipio}
label={`${m.nome}${m.uf}`}
>
{m.nome}{' '}
<Text type="secondary" style={{ fontSize: 12 }}>
{m.uf}
</Text>
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
</Section>
{/* ── Contato ───────────────────────────────────────────────── */}
<Section icon={<PhoneOutlined />} title="Contato">
<Row gutter={[16, 0]}>
<Col xs={8} md={4}>
<Form.Item name="ddd" label="DDD">
<Input placeholder="48" maxLength={4} />
</Form.Item>
</Col>
<Col xs={16} md={8}>
<Form.Item name="telefone" label="Telefone">
<Input placeholder="99999-9999" maxLength={35} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="email" label="E-mail">
<Input placeholder="contato@empresa.com.br" maxLength={80} type="email" />
</Form.Item>
</Col>
</Row>
</Section>
{/* ── Comercial ─────────────────────────────────────────────── */}
<Section icon={<BankOutlined />} title="Comercial">
<Row gutter={[16, 0]}>
<Col xs={24} md={8}>
<Form.Item name="codFormapag" label="Forma de Pagamento">
<Select placeholder="Selecione" allowClear>
{formas.map((f) => (
<Select.Option key={f.codigo} value={f.codigo}>
{f.descricao}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="codPauta" label="Tabela de Preços">
<Select placeholder="Selecione" allowClear>
{pautas.map((p) => (
<Select.Option key={p.idPauta} value={p.idPauta}>
{p.descricao}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="limiteCredito" label="Limite de Crédito">
<InputNumber
style={{ width: '100%' }}
min={0}
step={100}
formatter={(v) => `R$ ${v ?? 0}`.replace(/\B(?=(\d{3})+(?!\d))/g, '.')}
parser={(v) => Number((v ?? '').replace(/R\$\s?\.*/g, '').replace(',', '.'))}
placeholder="0,00"
/>
</Form.Item>
</Col>
</Row>
</Section>
{syncError && (
<Alert
type="warning"
showIcon
message="Falha na sincronização com o ERP"
description={syncError}
style={{ marginBottom: 16 }}
/>
)}
</Card>
{/* Rodapé sticky */}
<div
style={{
position: 'sticky',
bottom: 0,
background: '#fff',
borderTop: '1px solid #EBF0F5',
padding: '12px 24px',
display: 'flex',
justifyContent: 'flex-end',
gap: 12,
borderRadius: '0 0 12px 12px',
}}
>
<Button
onClick={() => void navigate({ to: '/clientes' })}
style={{ borderRadius: 8 }}
disabled={createMutation.isPending}
>
Cancelar
</Button>
<Button
type="primary"
htmlType="submit"
icon={<CheckCircleOutlined />}
loading={createMutation.isPending}
style={{ borderRadius: 8, fontWeight: 600, minWidth: 160 }}
>
{createMutation.isPending ? 'Cadastrando...' : 'Cadastrar Cliente'}
</Button>
</div>
</Form>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,8 @@ import {
} from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
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 { Link, useParams, useNavigate } from '@tanstack/react-router';
import type { PedidoItem, HistoricoPedido } from '@sar/api-interface';
@@ -118,7 +119,7 @@ function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
: SITUA_COLOR[h.situaNova] === 'error'
? 'red'
: 'blue',
children: (
content: (
<div>
<Text strong>{SITUA_LABEL[h.situaNova] ?? String(h.situaNova)}</Text>
{h.situaAnterior != null && (
@@ -252,8 +253,9 @@ export function OrderDetailPage() {
const { data: clientOrders } = useClientOrders(order?.idCliente);
const role = getRoleFromToken();
const canAct = role !== 'rep' && order?.situa === 1;
const canTransmit = role === 'rep' && order?.situa === 0;
const isErp = order?.fonte === 'erp';
const canAct = !isErp && role !== 'rep' && order?.situa === 1;
const canTransmit = !isErp && role === 'rep' && order?.situa === 0;
const canShare =
role === 'rep' &&
(order?.situa === 2 || order?.situa === 4) &&
@@ -262,6 +264,7 @@ export function OrderDetailPage() {
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [chamadoOpen, setChamadoOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const approveMutation = useMutation({
@@ -382,6 +385,11 @@ export function OrderDetailPage() {
Compartilhar
</Button>
)}
{role === 'rep' && !isErp && (order.situa === 2 || order.situa === 4) && (
<Button icon={<CustomerServiceOutlined />} onClick={() => setChamadoOpen(true)}>
Abrir Chamado
</Button>
)}
</Space>
{actionError && (
@@ -417,26 +425,36 @@ export function OrderDetailPage() {
{new Date(order.aprovadoEm).toLocaleString('pt-BR')} cód. {order.aprovadoPor}
</Descriptions.Item>
)}
{order.formaPagamento && (
<Descriptions.Item label="Cond. Pagamento" span={2}>
{order.formaPagamento}
</Descriptions.Item>
)}
<Descriptions.Item label="Total produtos">{fmt(order.totalProdutos)}</Descriptions.Item>
<Descriptions.Item label="Desc. Global">{order.descontoPerc}%</Descriptions.Item>
<Descriptions.Item label="Total">
<Descriptions.Item label="Total" span={isOrcamento ? 1 : 2}>
<Text strong style={{ fontSize: 16 }}>
{fmt(order.total)}
</Text>
</Descriptions.Item>
{order.endEntrega && (
<Descriptions.Item label="End. Entrega" span={isOrcamento ? 3 : 2}>
{order.endEntrega}
</Descriptions.Item>
)}
{order.obs && (
<Descriptions.Item label="Observações" span={2}>
<Descriptions.Item label="Observações" span={isOrcamento ? 3 : 2}>
{order.obs}
</Descriptions.Item>
)}
{order.motivoRecusa && (
<Descriptions.Item label="Motivo Recusa" span={2}>
<Descriptions.Item label="Motivo Recusa" span={isOrcamento ? 3 : 2}>
<Text type="danger">{order.motivoRecusa}</Text>
</Descriptions.Item>
)}
</Descriptions>
<Divider orientation="left">Itens ({order.itens.length})</Divider>
<Divider titlePlacement="left">Itens ({order.itens.length})</Divider>
<Table<PedidoItem>
rowKey="id"
columns={itemColumns}
@@ -448,7 +466,7 @@ export function OrderDetailPage() {
{clientOrders && clientOrders.length > 0 && (
<>
<Divider orientation="left">Outros Pedidos do Cliente</Divider>
<Divider titlePlacement="left">Outros Pedidos do Cliente</Divider>
<Table
rowKey="id"
size="small"
@@ -490,7 +508,7 @@ export function OrderDetailPage() {
</>
)}
<Divider orientation="left">Histórico do Pedido</Divider>
<Divider titlePlacement="left">Histórico do Pedido</Divider>
<HistoryTimeline history={order.historico} />
<ApproveModal
@@ -506,6 +524,17 @@ export function OrderDetailPage() {
onCancel={() => setRejectOpen(false)}
loading={rejectMutation.isPending}
/>
{order && (
<AbrirChamadoModal
open={chamadoOpen}
onClose={() => setChamadoOpen(false)}
idPedido={order.id}
numPedSar={parseInt(order.numPedSar.replace(/\D/g, ''), 10) || undefined}
nomeCliente={order.razaoCliente ?? order.nomeCliente}
idCliente={order.idCliente}
itens={order.itens}
/>
)}
</div>
);
}

View File

@@ -61,16 +61,6 @@ const label: React.CSSProperties = {
marginBottom: 2,
};
function Field({ k, v }: { k: string; v: React.ReactNode }) {
if (v == null || v === '' || v === '—') return null;
return (
<div style={{ marginBottom: 5 }}>
<span style={label}>{k}</span>
<span style={{ fontSize: 11.5, color: INK }}>{v}</span>
</div>
);
}
export function OrderPrintPage() {
const { id } = useParams({ from: '/pedidos/$id/imprimir' });
const navigate = useNavigate();
@@ -213,57 +203,94 @@ export function OrderPrintPage() {
</div>
</div>
{/* ── Cliente + Representante ─────────────────────────────────────── */}
<div style={{ display: 'flex', gap: 0 }}>
<div style={{ flex: 1.4, padding: '16px 28px', borderRight: `1px solid ${LINE}` }}>
{/* ── Representante + Cliente ─────────────────────────────────────── */}
<div
style={{
fontSize: 10,
fontWeight: 800,
color: BLUE,
letterSpacing: '0.1em',
marginBottom: 10,
display: 'flex',
borderBottom: `1px solid ${LINE}`,
background: '#FAFBFD',
}}
>
CLIENTE
{/* Representante */}
<div
style={{
flex: 1,
padding: '10px 20px 10px 28px',
borderRight: `1px solid ${LINE}`,
}}
>
<div
style={{
fontSize: 8.5,
fontWeight: 800,
color: BLUE,
letterSpacing: '0.12em',
textTransform: 'uppercase',
marginBottom: 4,
}}
>
Representante
</div>
<div style={{ fontSize: 13.5, fontWeight: 700, color: INK, marginBottom: 2 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: INK, lineHeight: 1.3 }}>
{tx(order.nomeVendedor) ?? `Cód. ${order.codVendedor}`}
</div>
<div style={{ fontSize: 10, color: MUTED, marginTop: 2 }}>Cód. {order.codVendedor}</div>
</div>
{/* Cliente */}
<div style={{ flex: 2.2, padding: '10px 28px 10px 20px' }}>
<div
style={{
fontSize: 8.5,
fontWeight: 800,
color: BLUE,
letterSpacing: '0.12em',
textTransform: 'uppercase',
marginBottom: 4,
}}
>
Cliente
</div>
<div style={{ fontSize: 12, fontWeight: 700, color: INK, lineHeight: 1.3 }}>
{clienteNome}
</div>
{tx(client?.nome) && tx(client?.razao) && tx(client?.nome) !== tx(client?.razao) && (
<div style={{ fontSize: 11, color: MUTED, marginBottom: 8 }}>{tx(client?.nome)}</div>
<div style={{ fontSize: 10, color: MUTED, lineHeight: 1.3 }}>{tx(client?.nome)}</div>
)}
<div style={{ marginTop: 8 }}>
<Field k={docLabel} v={doc(client?.cgcpf)} />
<Field k="Inscr. Estadual" v={tx(client?.inscricaoEstadual)} />
<Field k="Endereço" v={enderecoCli} />
<Field k="Telefone" v={phone(client?.telefone, client?.ddd)} />
<Field k="E-mail" v={tx(client?.email)} />
</div>
</div>
<div style={{ flex: 1, padding: '16px 28px' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '2px 18px',
marginTop: 4,
fontSize: 10,
fontWeight: 800,
color: BLUE,
letterSpacing: '0.1em',
marginBottom: 10,
color: MUTED,
}}
>
REPRESENTANTE
{doc(client?.cgcpf) !== '—' && (
<span>
<strong style={{ color: INK }}>{docLabel}:</strong> {doc(client?.cgcpf)}
</span>
)}
{tx(client?.inscricaoEstadual) && (
<span>
<strong style={{ color: INK }}>IE:</strong> {tx(client?.inscricaoEstadual)}
</span>
)}
{enderecoCli && <span>{enderecoCli}</span>}
{phone(client?.telefone, client?.ddd) !== '—' && (
<span>
<strong style={{ color: INK }}>Tel:</strong>{' '}
{phone(client?.telefone, client?.ddd)}
</span>
)}
{tx(client?.email) && <span>{tx(client?.email)}</span>}
</div>
<div style={{ fontSize: 13.5, fontWeight: 700, color: INK, marginBottom: 8 }}>
{tx(order.nomeVendedor) ?? `Cód. ${order.codVendedor}`}
</div>
<Field k="Código" v={String(order.codVendedor)} />
<Field k="Data do pedido" v={dateBR(order.dtPedido)} />
<Field k="Nº do pedido" v={order.numPedSar} />
</div>
</div>
{/* ── Itens ───────────────────────────────────────────────────────── */}
<div style={{ padding: '8px 28px 0' }}>
<div style={{ padding: '0 28px 0' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
<thead>
<tr style={{ background: '#F4F7FB' }}>
@@ -372,13 +399,25 @@ export function OrderPrintPage() {
</div>
</div>
{/* ── Observações + rodapé ────────────────────────────────────────── */}
{/* ── Endereço de entrega + Observações ───────────────────────────── */}
{(order.endEntrega || order.obs) && (
<div style={{ padding: '10px 28px 0', display: 'flex', gap: 24, flexWrap: 'wrap' }}>
{order.endEntrega && (
<div style={{ flex: 1, minWidth: 220 }}>
<span style={label}>Endereço de entrega</span>
<div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>
{order.endEntrega}
</div>
</div>
)}
{order.obs && (
<div style={{ padding: '10px 28px 0' }}>
<div style={{ flex: 1, minWidth: 220 }}>
<span style={label}>Observações</span>
<div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>{order.obs}</div>
</div>
)}
</div>
)}
<div
style={{
margin: '18px 28px 0',

View File

@@ -0,0 +1,639 @@
import { useState } from 'react';
import {
App,
Button,
Card,
Col,
DatePicker,
Modal,
Input,
Row,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import type { Dayjs } from 'dayjs';
import {
CopyOutlined,
EyeOutlined,
FileTextOutlined,
SearchOutlined,
ClearOutlined,
ShoppingCartOutlined,
} from '@ant-design/icons';
import type { PedidoErpConsultaItem } from '@sar/api-interface';
import { useOrderErpConsulta, useOrderDetail } from '../../lib/queries/orders';
const { RangePicker } = DatePicker;
const { Title, Text } = Typography;
function fmt(v: number | string) {
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function fmtDate(v: string | null | undefined) {
if (!v) return '—';
return new Date(v).toLocaleDateString('pt-BR');
}
// Status para registros tipo='E' (sig.pedidos entregas)
const SIG_STATUS_ENTREGA: Record<number, { label: string; color: string }> = {
1: { label: 'Pendente', color: 'orange' },
2: { label: 'Liberado', color: 'green' },
3: { label: 'Emitido', color: 'geekblue' },
5: { label: 'Cancelado', color: 'red' },
};
function statusTag(row: PedidoErpConsultaItem) {
const cfg = row.tipo === 'E' ? SIG_STATUS_ENTREGA[row.situa] : undefined;
const label = row.statusDescr ?? cfg?.label ?? String(row.situa);
return (
<Tag
color={cfg?.color ?? 'default'}
style={{ borderRadius: 20, fontWeight: 600, fontSize: 11 }}
>
{label}
</Tag>
);
}
// ─── Modal de detalhes ────────────────────────────────────────────────────────
function ErpConsultaModal({
row,
onClose,
}: {
row: PedidoErpConsultaItem | null;
onClose: () => void;
}) {
const { data: detail, isLoading } = useOrderDetail(
row?.idPedido ? `erp-${row.idPedido}` : undefined,
);
const { message: msg } = App.useApp();
const label: React.CSSProperties = {
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#94A3B8',
marginBottom: 2,
display: 'block',
};
return (
<Modal
title={
row ? (
<Space size={8}>
<Text strong style={{ color: '#003B8E' }}>
Pedido {row.numeroPedido}
</Text>
<Tag
color={row.tipo === 'P' ? 'blue' : 'cyan'}
style={{ fontWeight: 700, borderRadius: 20, margin: 0 }}
>
{row.tipo === 'P' ? 'Pedido' : 'Entrega'}
</Tag>
{row && statusTag(row)}
</Space>
) : (
'Detalhes'
)
}
open={!!row}
onCancel={onClose}
centered
width={860}
destroyOnHidden
styles={{
body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' },
}}
footer={<Button onClick={onClose}>Fechar</Button>}
>
{!row ? null : (
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
{/* ── Cabeçalho ── */}
<Card
styles={{ body: { padding: '14px 16px' } }}
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
>
<Row gutter={[16, 10]}>
<Col span={24}>
<span style={label}>Cliente</span>
<Text strong style={{ display: 'block' }}>
{row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`}
</Text>
{row.nomeCliente && row.razaoCliente && (
<Text type="secondary" style={{ fontSize: 12 }}>
{row.nomeCliente}
</Text>
)}
</Col>
<Col span={8}>
<span style={label}>Data</span>
<Text>{fmtDate(row.data)}</Text>
</Col>
{row.dtPrevent && (
<Col span={8}>
<span style={label}>Prev. Entrega</span>
<Text>{fmtDate(row.dtPrevent)}</Text>
</Col>
)}
<Col span={8}>
<span style={label}>Forma Pagto.</span>
<Text>{row.formaPagamento ?? '—'}</Text>
</Col>
<Col span={8}>
<span style={label}>Total</span>
<Text strong style={{ color: '#003B8E', fontSize: 16 }}>
{fmt(row.total)}
</Text>
</Col>
{row.numPedSar && (
<Col span={8}>
<span style={label}> SAR</span>
<Text style={{ color: '#64748B' }}>{row.numPedSar}</Text>
</Col>
)}
{row.obs && (
<Col span={24}>
<span style={label}>Observações</span>
<Text type="secondary" style={{ fontSize: 12 }}>
{row.obs}
</Text>
</Col>
)}
</Row>
</Card>
{/* ── Dados de Entrega / NF (só tipo E) ── */}
{row.tipo === 'E' && (
<Card
styles={{ body: { padding: '14px 16px' } }}
style={{ borderRadius: 8, border: '1px solid #d1fae5', background: '#f0fdf4' }}
>
<Row gutter={[16, 10]}>
<Col span={8}>
<span style={label}> Entrega</span>
<Text strong className="tabular-nums">
{row.numeroEntrega ?? '—'}
</Text>
</Col>
{row.dataEmissao && (
<Col span={8}>
<span style={label}>Data Emissão</span>
<Text>{fmtDate(row.dataEmissao)}</Text>
</Col>
)}
{row.numeroNf && (
<Col span={8}>
<span style={label}>NF</span>
<Space size={4}>
<FileTextOutlined style={{ color: '#003B8E' }} />
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{row.numeroNf}
</Text>
</Space>
</Col>
)}
{row.chaveAcessoNfe && (
<Col span={24}>
<span style={label}>Chave NF-e</span>
<Space size={6} align="start">
<Text
style={{
fontSize: 11,
color: '#475569',
fontFamily: 'monospace',
wordBreak: 'break-all',
lineHeight: 1.5,
}}
>
{row.chaveAcessoNfe}
</Text>
<Tooltip title="Copiar chave">
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() =>
void navigator.clipboard
.writeText(row.chaveAcessoNfe ?? '')
.then(() => void msg.success('Chave copiada'))
}
/>
</Tooltip>
</Space>
</Col>
)}
</Row>
</Card>
)}
{/* ── Itens do Pedido ── */}
<div>
{isLoading ? (
<div style={{ textAlign: 'center', padding: 32 }}>
<Spin />
</div>
) : !detail?.itens?.length ? (
<Text type="secondary" style={{ fontSize: 13 }}>
Nenhum item encontrado para este registro.
</Text>
) : (
<Table
rowKey="id"
dataSource={detail.itens}
size="small"
pagination={false}
style={{ borderRadius: 8, border: '1px solid #EBF0F5' }}
columns={[
{
title: 'Produto',
key: 'produto',
render: (_: unknown, item) => (
<Space orientation="vertical" size={0}>
<Text style={{ fontSize: 11, color: '#94A3B8' }}>{item.codProduto}</Text>
<Text style={{ fontWeight: 500, fontSize: 13 }}>{item.descProduto}</Text>
</Space>
),
},
{
title: 'Qtd.',
dataIndex: 'qtd',
width: 70,
align: 'right' as const,
render: (v: string) => <Text className="tabular-nums">{Number(v)}</Text>,
},
{
title: 'Preço Un.',
dataIndex: 'precoUnitario',
width: 120,
align: 'right' as const,
render: (v: string, item) => (
<Space orientation="vertical" size={0} style={{ alignItems: 'flex-end' }}>
<Text className="tabular-nums">{fmt(Number(v))}</Text>
{Number(item.descontoPerc) > 0 && (
<Text style={{ fontSize: 11, color: '#f59e0b' }}>
-{Number(item.descontoPerc)}%
</Text>
)}
</Space>
),
},
{
title: 'Total',
dataIndex: 'total',
width: 120,
align: 'right' as const,
render: (v: string) => (
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{fmt(Number(v))}
</Text>
),
},
]}
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={3} align="right">
<Text strong>Total</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<Text strong style={{ color: '#003B8E' }}>
{fmt(detail.total)}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
)}
/>
)}
</div>
</Space>
)}
</Modal>
);
}
// ─── OrdersErpPage ────────────────────────────────────────────────────────────
export function OrdersErpPage() {
const { message: msg } = App.useApp();
const [search, setSearch] = useState('');
const [query, setQuery] = useState('');
const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null);
const [page, setPage] = useState(1);
const [drawerRow, setDrawerRow] = useState<PedidoErpConsultaItem | null>(null);
const limit = 20;
const from = range ? range[0].format('YYYY-MM-DD') : undefined;
const to = range ? range[1].format('YYYY-MM-DD') : undefined;
const { data, isLoading, isFetching } = useOrderErpConsulta({
search: query || undefined,
from,
to,
page,
limit,
});
const rows = data?.data ?? [];
const total = data?.total ?? 0;
const hasFilters = !!query || !!range;
function commitSearch() {
setQuery(search.trim());
setPage(1);
}
function clearFilters() {
setSearch('');
setQuery('');
setRange(null);
setPage(1);
}
const columns: TableColumnsType<PedidoErpConsultaItem> = [
{
title: 'Pedido',
key: 'pedido',
width: 110,
render: (_: unknown, row: PedidoErpConsultaItem) => (
<Space orientation="vertical" size={0}>
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{row.numeroPedido}
</Text>
{row.numPedSar && <Text style={{ fontSize: 11, color: '#94A3B8' }}>{row.numPedSar}</Text>}
</Space>
),
},
{
title: 'Tipo',
dataIndex: 'tipo',
key: 'tipo',
width: 90,
render: (tipo: 'P' | 'E') => (
<Tag color={tipo === 'P' ? 'blue' : 'cyan'} style={{ fontWeight: 700, borderRadius: 20 }}>
{tipo === 'P' ? 'Pedido' : 'Entrega'}
</Tag>
),
},
{
title: 'Data',
dataIndex: 'data',
key: 'data',
width: 110,
render: (v: string) => <Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>,
},
{
title: 'Cliente',
key: 'cliente',
minWidth: 220,
render: (_: unknown, row: PedidoErpConsultaItem) => {
const nome = row.razaoCliente ?? row.nomeCliente;
const sub = row.nomeCliente && row.razaoCliente ? row.nomeCliente : null;
return (
<div>
{nome ? (
<Text strong style={{ fontSize: 13, color: '#1F2937', display: 'block' }}>
{nome}
</Text>
) : (
<Text type="secondary">Cód. {row.idCliente}</Text>
)}
{sub && <Text style={{ fontSize: 11, color: '#64748B', display: 'block' }}>{sub}</Text>}
</div>
);
},
},
{
title: 'Status',
dataIndex: 'situa',
key: 'status',
width: 120,
render: (_: unknown, row: PedidoErpConsultaItem) => statusTag(row),
},
{
title: 'Total',
dataIndex: 'total',
key: 'total',
width: 130,
align: 'right' as const,
render: (v: string) => (
<Text strong className="tabular-nums" style={{ color: '#003B8E', fontSize: 13 }}>
{fmt(v)}
</Text>
),
},
{
title: 'Forma Pagto.',
dataIndex: 'formaPagamento',
key: 'formaPagamento',
width: 140,
render: (v: string | null) => (
<Text style={{ fontSize: 12, color: '#475569' }}>{v ?? '—'}</Text>
),
},
{
title: 'Entrega / NF',
key: 'entrega',
width: 140,
render: (_: unknown, row: PedidoErpConsultaItem) => {
if (row.tipo !== 'E') return <Text type="secondary"></Text>;
return (
<Space orientation="vertical" size={2}>
{row.numeroEntrega && (
<Text className="tabular-nums" style={{ fontSize: 12, color: '#475569' }}>
Entrega {row.numeroEntrega}
</Text>
)}
{row.numeroNf && (
<Space size={4}>
<FileTextOutlined style={{ color: '#003B8E', fontSize: 11 }} />
<Text strong className="tabular-nums" style={{ fontSize: 12, color: '#003B8E' }}>
NF {row.numeroNf}
</Text>
{row.chaveAcessoNfe && (
<Tooltip title={row.chaveAcessoNfe}>
<Button
type="text"
size="small"
icon={<CopyOutlined style={{ fontSize: 10 }} />}
style={{ padding: '0 2px', height: 18, color: '#94A3B8' }}
onClick={(e) => {
e.stopPropagation();
void navigator.clipboard
.writeText(row.chaveAcessoNfe ?? '')
.then(() => void msg.success('Chave copiada'));
}}
/>
</Tooltip>
)}
</Space>
)}
</Space>
);
},
},
{
title: '',
key: 'actions',
width: 56,
render: (_: unknown, row: PedidoErpConsultaItem) => (
<Button
size="small"
type="primary"
icon={<EyeOutlined />}
style={{ borderRadius: 6 }}
disabled={!row.idPedido}
onClick={(e) => {
e.stopPropagation();
setDrawerRow(row);
}}
/>
),
},
];
return (
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
<div style={{ marginBottom: 20 }}>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
Consulta de Pedidos ERP
</Title>
<p style={{ margin: '4px 0 0', color: '#64748B', fontSize: 14 }}>
Pedidos e entregas no ERP. Apenas leitura.
</p>
</div>
<Card
style={{
borderRadius: 10,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
marginBottom: 20,
}}
styles={{ body: { padding: '14px 20px' } }}
>
<Row gutter={[12, 12]} align="middle">
<Col xs={24} md={8}>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
onPressEnter={commitSearch}
onBlur={commitSearch}
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />}
placeholder="Nº pedido ou cliente..."
allowClear
onClear={() => {
setSearch('');
setQuery('');
setPage(1);
}}
/>
</Col>
<Col xs={24} md={8}>
<RangePicker
style={{ width: '100%' }}
value={range}
format="DD/MM/YYYY"
allowClear
placeholder={['Data inicial', 'Data final']}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setRange([dates[0], dates[1]]);
} else {
setRange(null);
}
setPage(1);
}}
/>
</Col>
<Col xs={12} md={4}>
<Button
style={{ width: '100%', borderRadius: 6 }}
icon={<ClearOutlined />}
disabled={!hasFilters}
onClick={clearFilters}
>
Limpar
</Button>
</Col>
<Col>
<Text style={{ fontSize: 12, color: '#94A3B8' }}>
{data?.total !== undefined ? `${total.toLocaleString('pt-BR')} registros` : '…'}
</Text>
</Col>
</Row>
</Card>
{isLoading ? (
<div style={{ textAlign: 'center', padding: 64 }}>
<Spin size="large" />
</div>
) : rows.length === 0 ? (
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: 0 } }}
>
<div style={{ padding: '48px 0', textAlign: 'center' }}>
<ShoppingCartOutlined
style={{ fontSize: 56, color: '#D9E2EC', display: 'block', marginBottom: 16 }}
/>
<Text strong style={{ fontSize: 15, display: 'block' }}>
Nenhum registro encontrado
</Text>
<Text type="secondary">Tente alterar os filtros de data ou busca.</Text>
</div>
</Card>
) : (
<Card
style={{
borderRadius: 10,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 6px rgba(0,0,0,0.06)',
}}
styles={{ body: { padding: 0 } }}
>
<Table<PedidoErpConsultaItem>
rowKey={(row) => `${row.tipo}-${row.numeroPedido}-${row.numeroEntrega ?? 0}`}
columns={columns}
dataSource={rows}
size="middle"
loading={isFetching}
scroll={{ x: 1100 }}
onRow={(row) => ({
onClick: () => row.idPedido && setDrawerRow(row),
style: {
background: row.tipo === 'E' ? '#f0fdf4' : '#fff',
verticalAlign: 'top',
cursor: row.idPedido ? 'pointer' : 'default',
},
})}
pagination={{
current: page,
pageSize: limit,
total,
showSizeChanger: false,
showTotal: (t, [s, e]) => `Mostrando ${s}${e} de ${t} registros`,
onChange: (p) => setPage(p),
style: { padding: '12px 24px' },
}}
style={{ borderRadius: 10, overflow: 'hidden' }}
/>
</Card>
)}
<ErpConsultaModal row={drawerRow} onClose={() => setDrawerRow(null)} />
<style>{`
.ant-table-row:hover td { background: inherit !important; filter: brightness(0.97); }
`}</style>
</div>
);
}

View File

@@ -1,11 +1,12 @@
import { useState, useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
App,
Button,
Card,
Col,
DatePicker,
Drawer,
Modal,
Dropdown,
Grid,
Row,
@@ -21,8 +22,6 @@ import {
import type { TableColumnsType } from 'antd';
import type { MenuProps } from 'antd';
import type { Dayjs } from 'dayjs';
const { RangePicker } = DatePicker;
import {
CheckCircleOutlined,
ClockCircleOutlined,
@@ -43,6 +42,9 @@ import { SITUA_LABEL } from '@sar/api-interface';
import { useOrderList, useOrderDetail } from '../../lib/queries/orders';
import { usePendingOrders } from '../../lib/hooks/usePendingOrders';
import { removePendingOrder, retryPendingOrder } from '../../lib/offline/order-queue';
import { apiFetch } from '../../lib/api-client';
const { RangePicker } = DatePicker;
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
@@ -77,6 +79,52 @@ function periodRange(p: string): { from?: string; to?: string } {
return {};
}
// ─── WhatsApp ─────────────────────────────────────────────────────────────────
function WhatsAppIcon({ size = 14 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
style={{ verticalAlign: '-2px' }}
>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" />
</svg>
);
}
function buildWhatsAppUrl(order: {
id: string;
numPedSar: string;
situa: number;
statusDescr?: string | null;
total: number | string;
dtPedido: string;
razaoCliente?: string | null;
nomeCliente?: string | null;
idCliente: number | string;
}): string {
const nome = order.razaoCliente ?? order.nomeCliente ?? `Cód. ${order.idCliente}`;
const status =
order.statusDescr ??
STATUS[order.situa]?.label ??
SITUA_LABEL[order.situa] ??
String(order.situa);
const pdfUrl = `${window.location.origin}/pedidos/${order.id}/imprimir`;
const text = [
`*Pedido ${order.numPedSar}*`,
`Cliente: ${nome}`,
`Data: ${fmtDate(order.dtPedido)}`,
`Total: ${fmt(order.total)}`,
`Status: ${status}`,
``,
`PDF: ${pdfUrl}`,
].join('\n');
return `https://wa.me/?text=${encodeURIComponent(text)}`;
}
// ─── Status Config ────────────────────────────────────────────────────────────
const STATUS: Record<number, { label: string; color: string; rowBg: string; tagColor: string }> = {
@@ -204,17 +252,27 @@ function OrderActionsMenu({
onView: (id: string) => void;
}) {
const navigate = useNavigate();
const canDetail = order.fonte !== 'erp';
const qc = useQueryClient();
const { modal, message: msg } = App.useApp();
const cancelMutation = useMutation({
mutationFn: () => apiFetch(`/orders/${order.id}/cancel`, { method: 'PATCH' }),
onSuccess: () => {
void msg.success('Orçamento cancelado.');
void qc.invalidateQueries({ queryKey: ['orders'] });
},
onError: (e: unknown) => void msg.error(e instanceof Error ? e.message : 'Erro ao cancelar'),
});
const canCancel = order.situa === 0 && order.fonte !== 'erp';
const items: MenuProps['items'] = [
canDetail
? {
{
key: 'view',
icon: <EyeOutlined />,
label: 'Ver detalhes',
onClick: () => onView(order.id),
}
: { key: 'view', icon: <EyeOutlined />, label: 'Ver detalhes', disabled: true },
},
{
key: 'duplicate',
icon: <CopyOutlined style={{ color: '#0057D9' }} />,
@@ -227,16 +285,35 @@ function OrderActionsMenu({
key: 'pdf',
icon: <FilePdfOutlined />,
label: 'Gerar PDF',
disabled: order.fonte === 'erp',
onClick: () => void navigate({ to: '/pedidos/$id/imprimir', params: { id: order.id } }),
},
{
key: 'whatsapp',
icon: (
<span style={{ color: '#25D366' }}>
<WhatsAppIcon />
</span>
),
label: <span style={{ color: '#25D366', fontWeight: 600 }}>Enviar pelo WhatsApp</span>,
onClick: () => window.open(buildWhatsAppUrl(order), '_blank'),
},
{
key: 'cancel',
icon: <CloseCircleOutlined />,
label: 'Cancelar pedido',
danger: true,
disabled: order.situa === 3,
onClick: () => alert('Cancelamento em breve'),
disabled: !canCancel,
onClick: () => {
if (!canCancel) return;
void modal.confirm({
title: 'Cancelar orçamento?',
content: `O pedido ${order.numPedSar} será marcado como cancelado. Esta ação não pode ser desfeita.`,
okText: 'Cancelar pedido',
okButtonProps: { danger: true },
cancelText: 'Voltar',
onOk: () => cancelMutation.mutate(),
});
},
},
];
@@ -247,10 +324,12 @@ function OrderActionsMenu({
);
}
// ─── OrderDetailDrawer ────────────────────────────────────────────────────────
// ─── OrderDetailModal ─────────────────────────────────────────────────────────
function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () => void }) {
function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
const navigate = useNavigate();
const screens = useBreakpoint();
const isMobile = !screens.md;
const { data, isLoading } = useOrderDetail(id ?? undefined);
const timelineItems = (data?.historico ?? []).map((h) => ({
@@ -262,7 +341,7 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
) : (
<ClockCircleOutlined style={{ color: '#d46b08' }} />
),
children: (
content: (
<span style={{ fontSize: 13 }}>
<Text type="secondary">{new Date(h.changedAt).toLocaleString('pt-BR')}</Text>
{' — '}
@@ -290,16 +369,32 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
};
return (
<Drawer
<Modal
title={data ? `Pedido ${data.numPedSar}` : 'Detalhes do Pedido'}
open={!!id}
onClose={onClose}
width={520}
placement="right"
styles={{ body: { padding: '16px 24px' } }}
onCancel={onClose}
centered
width={isMobile ? '95vw' : 860}
destroyOnHidden
styles={{
body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' },
}}
footer={
<Space>
<Button onClick={onClose}>Fechar</Button>
{data && (
<Button
icon={
<span style={{ color: '#25D366' }}>
<WhatsAppIcon />
</span>
}
style={{ color: '#25D366', borderColor: '#25D366' }}
onClick={() => window.open(buildWhatsAppUrl(data), '_blank')}
>
WhatsApp
</Button>
)}
{data && (
<Button
type="primary"
@@ -314,10 +409,10 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
</Space>
}
>
{isLoading && <Spin style={{ display: 'block', marginTop: 48, textAlign: 'center' }} />}
{isLoading && <Spin style={{ display: 'block', margin: '48px auto' }} />}
{data && (
<Space direction="vertical" size={20} style={{ width: '100%' }}>
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
{/* Status */}
<div>
<OrderStatusBadge situa={data.situa} descr={data.statusDescr} />
@@ -329,15 +424,27 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
>
<Row gutter={[16, 10]}>
<Col span={12}>
<Col span={6}>
<span style={label}>Pedido</span>
<Text strong>{data.numPedSar}</Text>
</Col>
<Col span={12}>
<Col span={6}>
<span style={label}>Data</span>
<Text>{fmtDate(data.dtPedido)}</Text>
</Col>
<Col span={24}>
<Col span={6}>
<span style={label}>Total</span>
<Text strong style={{ color: '#003B8E', fontSize: 16 }}>
{fmt(data.total)}
</Text>
</Col>
{data.descontoPerc && Number(data.descontoPerc) > 0 && (
<Col span={6}>
<span style={label}>Desconto</span>
<Text>{Number(data.descontoPerc).toLocaleString('pt-BR')}%</Text>
</Col>
)}
<Col span={12}>
<span style={label}>Cliente</span>
<Text strong style={{ display: 'block' }}>
{data.razaoCliente ?? data.nomeCliente ?? `Cód. ${data.idCliente}`}
@@ -348,20 +455,14 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
</Text>
)}
</Col>
<Col span={24}>
<Col span={12}>
<span style={label}>Representante</span>
<Text>{data.nomeVendedor ?? `Cód. ${data.codVendedor}`}</Text>
</Col>
<Col span={12}>
<span style={label}>Total</span>
<Text strong style={{ color: '#003B8E', fontSize: 16 }}>
{fmt(data.total)}
</Text>
</Col>
{data.descontoPerc && Number(data.descontoPerc) > 0 && (
<Col span={12}>
<span style={label}>Desconto</span>
<Text>{Number(data.descontoPerc).toLocaleString('pt-BR')}%</Text>
{data.endEntrega && (
<Col span={24}>
<span style={label}>End. Entrega</span>
<Text type="secondary">{data.endEntrega}</Text>
</Col>
)}
{data.obs && (
@@ -379,45 +480,62 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
<Text style={{ ...label, marginBottom: 8 }}>
Itens do Pedido ({data.itens.length})
</Text>
{data.itens.map((item) => (
<div
key={item.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
borderRadius: 8,
background: '#F8FAFC',
border: '1px solid #EBF0F5',
marginBottom: 6,
}}
>
<Space direction="vertical" size={0}>
<Text style={{ fontSize: 12, color: '#64748B' }}>{item.codProduto}</Text>
<Table
rowKey="id"
dataSource={data.itens}
size="small"
pagination={false}
style={{ borderRadius: 8, border: '1px solid #EBF0F5' }}
columns={[
{
title: 'Produto',
key: 'produto',
render: (_: unknown, item) => (
<Space orientation="vertical" size={0}>
<Text style={{ fontSize: 11, color: '#94A3B8' }}>{item.codProduto}</Text>
<Text style={{ fontWeight: 500 }}>{item.descProduto}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{Number(item.qtd)} un × {fmt(Number(item.precoUnitario))}
</Text>
</Space>
<Text strong className="tabular-nums">
{fmt(Number(item.total))}
),
},
{
title: 'Qtd.',
dataIndex: 'qtd',
width: 70,
align: 'right' as const,
render: (v: string) => <Text className="tabular-nums">{Number(v)}</Text>,
},
{
title: 'Preço Un.',
dataIndex: 'precoUnitario',
width: 120,
align: 'right' as const,
render: (v: string) => <Text className="tabular-nums">{fmt(Number(v))}</Text>,
},
{
title: 'Total',
dataIndex: 'total',
width: 120,
align: 'right' as const,
render: (v: string) => (
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{fmt(Number(v))}
</Text>
</div>
))}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
marginTop: 8,
paddingTop: 8,
borderTop: '1px solid #EBF0F5',
}}
>
<Text strong style={{ fontSize: 15, color: '#003B8E' }}>
Total: {fmt(data.total)}
),
},
]}
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={3} align="right">
<Text strong>Total do Pedido</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<Text strong style={{ color: '#003B8E' }}>
{fmt(data.total)}
</Text>
</div>
</Table.Summary.Cell>
</Table.Summary.Row>
)}
/>
</div>
)}
@@ -430,7 +548,7 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
)}
</Space>
)}
</Drawer>
</Modal>
);
}
@@ -479,7 +597,6 @@ function MobileOrderCard({
<Button
size="small"
icon={<EyeOutlined />}
disabled={order.fonte === 'erp'}
onClick={() =>
order.situa === 0
? void navigate({ to: '/pedidos/$id', params: { id: order.id } })
@@ -499,6 +616,18 @@ function MobileOrderCard({
>
Duplicar
</Button>
<Button
size="small"
icon={
<span style={{ color: '#25D366' }}>
<WhatsAppIcon />
</span>
}
style={{ borderRadius: 6 }}
onClick={() => window.open(buildWhatsAppUrl(order), '_blank')}
>
WhatsApp
</Button>
</div>
</Card>
);
@@ -538,7 +667,7 @@ export function OrdersPage() {
limit,
});
const rows = data?.data ?? [];
const rows = useMemo(() => data?.data ?? [], [data]);
const total = data?.total ?? 0;
const hasFilters = !!query || !!situaFilter || !!period || !!range;
@@ -566,20 +695,13 @@ export function OrdersPage() {
title: 'Pedido',
key: 'pedido',
width: 140,
render: (_: unknown, row: PedidoSummary) => {
const label = row.numero ? String(row.numero) : row.numPedSar;
return row.fonte === 'erp' ? (
<Text strong className="tabular-nums" style={{ color: '#1F2937' }}>
{label}
</Text>
) : (
render: (_: unknown, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{label}
{row.numPedSar}
</Text>
</Link>
);
},
),
},
{
title: 'Cliente',
@@ -651,7 +773,6 @@ export function OrdersPage() {
type="primary"
style={{ borderRadius: 6 }}
title="Ver detalhes"
disabled={row.fonte === 'erp'}
onClick={() =>
row.situa === 0
? void navigate({ to: '/pedidos/$id', params: { id: row.id } })
@@ -962,13 +1083,12 @@ export function OrdersPage() {
scroll={{ x: 900 }}
onRow={(row) => ({
onClick: () => {
// Orçamento abre a tela grande de detalhe (com Transmitir); demais, o drawer.
if (row.situa === 0) void navigate({ to: '/pedidos/$id', params: { id: row.id } });
else if (row.fonte !== 'erp') setDrawerOrderId(row.id);
else setDrawerOrderId(row.id);
},
style: {
background: STATUS[row.situa]?.rowBg ?? '#fff',
cursor: row.fonte !== 'erp' ? 'pointer' : 'default',
cursor: 'pointer',
verticalAlign: 'top',
},
})}
@@ -997,7 +1117,7 @@ export function OrdersPage() {
)}
{/* ── Drawer de detalhe ─────────────────────────────────────────── */}
<OrderDetailDrawer id={drawerOrderId} onClose={() => setDrawerOrderId(null)} />
<OrderDetailModal id={drawerOrderId} onClose={() => setDrawerOrderId(null)} />
{/* FAB mobile */}
{isMobile && (

View File

@@ -0,0 +1,427 @@
import { Modal, Tag, Skeleton, Typography, Row, Col, Statistic, Divider } from 'antd';
import {
BarcodeOutlined,
InboxOutlined,
PercentageOutlined,
TagsOutlined,
} from '@ant-design/icons';
import type { PautaPreco } from '@sar/api-interface';
import { useProdutoDetail } from '../../lib/queries/catalog';
import { useCondicoesComerciais, type CondicaoComercial } from '../../lib/condicoes-comerciais';
import { CondicaoTag } from '../../components/CondicaoTag';
const { Text, Title } = Typography;
function fmtPrice(v: string | null | undefined): string {
const n = Number(v ?? 0);
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
}
function fmtNum(v: string | null | undefined, decimals = 3): string {
const n = Number(v ?? 0);
return n > 0
? n.toLocaleString('pt-BR', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})
: '—';
}
// ─── Bloco de seção ───────────────────────────────────────────────────────────
function Section({
icon,
title,
children,
}: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}) {
return (
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<span style={{ color: '#003B8E', fontSize: 16 }}>{icon}</span>
<Text
strong
style={{
fontSize: 13,
color: '#374151',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{title}
</Text>
</div>
{children}
</div>
);
}
// ─── Campo label/valor ────────────────────────────────────────────────────────
function Field({
label,
value,
span = 1,
}: {
label: string;
value: React.ReactNode;
span?: number;
}) {
return (
<div style={{ gridColumn: span > 1 ? 'span 2' : undefined, marginBottom: 12 }}>
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', marginBottom: 2 }}>
{label}
</Text>
<Text style={{ fontSize: 13, color: '#1F2937' }}>{value ?? '—'}</Text>
</div>
);
}
// ─── Card de preço ────────────────────────────────────────────────────────────
function PrecoCard({
label,
value,
highlight,
}: {
label: string;
value: string;
highlight?: boolean;
}) {
const n = Number(value ?? 0);
const empty = n <= 0;
return (
<div
style={{
padding: '10px 14px',
borderRadius: 8,
background: highlight && !empty ? '#F0FDF4' : '#F8FAFC',
border: `1px solid ${highlight && !empty ? '#BBF7D0' : '#EBF0F5'}`,
textAlign: 'center',
}}
>
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', marginBottom: 4 }}>
{label}
</Text>
<Text
strong
style={{
fontSize: 15,
color: empty ? '#CBD5E1' : highlight ? '#16A34A' : '#1F2937',
fontVariantNumeric: 'tabular-nums',
}}
>
{fmtPrice(value)}
</Text>
</div>
);
}
// ─── Lista de pautas ──────────────────────────────────────────────────────────
function PautaPrecoList({ items }: { items: PautaPreco[] }) {
if (items.length === 0) {
return (
<div
style={{
padding: '24px 16px',
textAlign: 'center',
background: '#F8FAFC',
borderRadius: 8,
border: '1px dashed #E2E8F0',
}}
>
<Text type="secondary" style={{ fontSize: 13 }}>
Produto não está em nenhuma pauta ativa.
</Text>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{items.map((item) => (
<div
key={item.idPauta}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 14px',
borderRadius: 8,
background: '#F8FAFC',
border: '1px solid #EBF0F5',
}}
>
<div>
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', lineHeight: 1.2 }}>
Pauta {item.codigo}
</Text>
<Text strong style={{ fontSize: 13, color: '#1F2937' }}>
{item.descricao}
</Text>
</div>
<Text
strong
style={{ fontSize: 16, color: '#16A34A', fontVariantNumeric: 'tabular-nums' }}
>
{fmtPrice(item.preco)}
</Text>
</div>
))}
</div>
);
}
// ─── 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 ──────────────────────────────────────────────────────────
interface Props {
idErp: number | null;
onClose: () => void;
}
export function ProductDetailDrawer({ idErp, onClose }: Props) {
const { data, isLoading } = useProdutoDetail(idErp ?? undefined);
const condicoesDe = useCondicoesComerciais();
const conds = data ? condicoesDe(data) : [];
return (
<Modal
open={idErp != null}
onCancel={onClose}
footer={null}
width={1100}
centered
title={null}
styles={{ body: { padding: 0 } }}
>
{isLoading || !data ? (
<div style={{ padding: 32 }}>
<Skeleton active paragraph={{ rows: 14 }} />
</div>
) : (
<>
{/* ── Cabeçalho ──────────────────────────────────────────── */}
<div
style={{
padding: '24px 32px 20px',
borderBottom: '1px solid #F1F5F9',
background: '#FAFBFC',
borderRadius: '8px 8px 0 0',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<Text style={{ fontSize: 12, color: '#94A3B8', display: 'block', marginBottom: 4 }}>
Código {data.codigo.trim()}
{data.referencia ? ` · Ref. ${data.referencia.trim()}` : ''}
</Text>
<Title level={4} style={{ margin: 0, color: '#1F2937', lineHeight: 1.3 }}>
{data.descricao.trim()}
</Title>
{data.descricaoDetalhada && (
<Text style={{ fontSize: 13, color: '#64748B', display: 'block', marginTop: 4 }}>
{data.descricaoDetalhada.trim()}
</Text>
)}
</div>
<div
style={{
display: 'flex',
gap: 8,
flexShrink: 0,
flexWrap: 'wrap',
justifyContent: 'flex-end',
}}
>
<CondicaoTag conds={conds} />
{data.marca && <Tag color="blue">{data.marca.trim()}</Tag>}
{data.unidade && <Tag>{data.unidade}</Tag>}
<Tag color={data.ativo ? 'green' : 'red'}>{data.ativo ? 'Ativo' : 'Inativo'}</Tag>
</div>
</div>
</div>
{/* ── Corpo ──────────────────────────────────────────────── */}
<div style={{ padding: '28px 32px', maxHeight: '65vh', overflowY: 'auto' }}>
<Row gutter={40}>
{/* Coluna esquerda */}
<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 */}
<Section icon={<TagsOutlined />} title="Classificação">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}>
<Field label="Grupo" value={data.grupo?.trim() ?? '—'} />
<Field label="Subgrupo" value={data.subgrupo?.trim() ?? '—'} />
</div>
</Section>
{/* Preços Base */}
<Section icon={<TagsOutlined />} title="Preços Base">
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 8,
marginBottom: 8,
}}
>
<PrecoCard label="Preço 1" value={data.vlPreco1} highlight />
<PrecoCard label="Preço 2" value={data.vlPreco2 ?? '0'} />
<PrecoCard label="Preço 3" value={data.vlPreco3 ?? '0'} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
<PrecoCard label="Promocional" value={data.precoPromocional ?? '0'} />
<PrecoCard label="Com IPI" value={data.precoComIpi ?? '0'} />
<div
style={{
padding: '10px 14px',
borderRadius: 8,
background: '#F8FAFC',
border: '1px solid #EBF0F5',
textAlign: 'center',
}}
>
<Text
style={{
fontSize: 11,
color: '#94A3B8',
display: 'block',
marginBottom: 4,
}}
>
Alíq. IPI
</Text>
<Text strong style={{ fontSize: 15, color: '#1F2937' }}>
{data.aliqIpi ? `${Number(data.aliqIpi).toLocaleString('pt-BR')}%` : '—'}
</Text>
</div>
</div>
</Section>
{/* Estoque & Logística */}
<Section icon={<InboxOutlined />} title="Estoque &amp; Logística">
<Row gutter={16}>
<Col span={6}>
<Statistic
title="Estoque"
value={data.qtdEstoque != null ? Number(data.qtdEstoque) : undefined}
valueStyle={{
fontSize: 22,
color:
data.qtdEstoque == null
? '#CBD5E1'
: Number(data.qtdEstoque) > 0
? '#1F2937'
: '#EF4444',
}}
formatter={(v) => (v != null ? Number(v).toLocaleString('pt-BR') : '—')}
/>
</Col>
<Col span={6}>
<Statistic
title="Lote múlt."
value={data.loteMulVenda ?? undefined}
valueStyle={{ fontSize: 22, color: '#1F2937' }}
formatter={(v) => (v != null ? String(v) : '—')}
/>
</Col>
<Col span={6}>
<Statistic
title="Peso líq. (kg)"
value={data.pesoLiquido ?? undefined}
valueStyle={{ fontSize: 22, color: '#1F2937' }}
formatter={(v) => fmtNum(String(v))}
/>
</Col>
<Col span={6}>
<Statistic
title="Qtd. volume"
value={data.qtdVolume ?? undefined}
valueStyle={{ fontSize: 22, color: '#1F2937' }}
formatter={(v) => fmtNum(String(v), 0)}
/>
</Col>
</Row>
</Section>
</Col>
{/* Divisor vertical */}
<Col md={0} xs={24}>
<Divider style={{ margin: '4px 0 24px' }} />
</Col>
{/* Coluna direita — Pautas */}
<Col xs={24} md={10}>
<Section
icon={<BarcodeOutlined />}
title={`Preços por Pauta (${data.pautaPrecos.length})`}
>
<PautaPrecoList items={data.pautaPrecos} />
</Section>
</Col>
</Row>
</div>
</>
)}
</Modal>
);
}

View File

@@ -0,0 +1,589 @@
import { useState } from 'react';
import {
Alert,
Badge,
Card,
Col,
DatePicker,
Progress,
Row,
Select,
Space,
Spin,
Statistic,
Table,
Tag,
Tabs,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import { AlertOutlined, BarChartOutlined, RiseOutlined, TeamOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { CarteiraCliente, AbcProduto } from '@sar/api-interface';
import { useReportMeta, useReportCarteira, useReportAbc } from '../../lib/queries/reports';
const { Title, Text } = Typography;
const { Option } = Select;
function fmt(v: number | string): string {
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function fmtDate(v: string | null | undefined): string {
if (!v) return '—';
return new Date(v).toLocaleDateString('pt-BR');
}
// ─── Aba 1: Desempenho vs. Meta ───────────────────────────────────────────────
function TabMeta() {
const now = dayjs();
const [mes, setMes] = useState(now.month() + 1);
const [ano, setAno] = useState(now.year());
const { data, isLoading, isError, error } = useReportMeta({ mes, ano });
const realizado = Number(data?.realizado ?? 0);
const meta = Number(data?.meta ?? 0);
const percentual = Number(data?.percentual ?? 0);
const comissao = Number(data?.comissaoEstimada ?? 0);
const bateuMeta = meta > 0 && realizado >= meta;
const progressColor = percentual >= 100 ? '#52c41a' : percentual >= 70 ? '#faad14' : '#003B8E';
const meses = [
'Jan',
'Fev',
'Mar',
'Abr',
'Mai',
'Jun',
'Jul',
'Ago',
'Set',
'Out',
'Nov',
'Dez',
];
return (
<div>
<Space style={{ marginBottom: 20 }}>
<Select value={mes} onChange={setMes} style={{ width: 90 }}>
{meses.map((m, i) => (
<Option key={i + 1} value={i + 1}>
{m}
</Option>
))}
</Select>
<DatePicker
picker="year"
value={dayjs().year(ano)}
onChange={(d) => d && setAno(d.year())}
style={{ width: 100 }}
allowClear={false}
/>
</Space>
{isLoading ? (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin size="large" />
</div>
) : isError ? (
<Alert
type="error"
showIcon
message="Erro ao carregar desempenho"
description={error instanceof Error ? error.message : 'Erro desconhecido'}
/>
) : (
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: '24px 28px' } }}
>
<Row gutter={[32, 24]} align="middle">
<Col xs={24} md={12}>
<Text
type="secondary"
style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.08em' }}
>
Realizado {meses[mes - 1]}/{ano}
</Text>
<div style={{ marginTop: 4 }}>
<Text
strong
style={{
fontSize: 32,
color: bateuMeta ? '#52c41a' : '#003B8E',
lineHeight: 1,
}}
>
{fmt(realizado)}
</Text>
{bateuMeta && (
<Tag
color="success"
style={{ marginLeft: 10, fontWeight: 700, borderRadius: 20 }}
>
Meta batida!
</Tag>
)}
</div>
<Text type="secondary" style={{ fontSize: 13 }}>
de {fmt(meta)} de meta
</Text>
<Progress
percent={Math.min(percentual, 100)}
style={{ marginTop: 12 }}
strokeColor={progressColor}
format={() => `${percentual.toFixed(1)}%`}
/>
</Col>
<Col xs={24} md={12}>
<Row gutter={[16, 16]}>
<Col span={12}>
<Statistic
title="Pedidos Transmitidos"
value={data?.totalPedidos ?? 0}
styles={{ content: { color: '#003B8E' } }}
/>
</Col>
<Col span={12}>
<Statistic
title="Comissão Estimada"
value={fmt(comissao)}
styles={{ content: { color: '#52c41a', fontSize: 20 } }}
/>
</Col>
<Col span={12}>
<Statistic
title="Falta para Meta"
value={meta > realizado ? fmt(meta - realizado) : 'Atingida'}
styles={{
content: { color: meta > realizado ? '#faad14' : '#52c41a', fontSize: 18 },
}}
/>
</Col>
<Col span={12}>
<Statistic
title="% Atingido"
value={`${percentual.toFixed(1)}%`}
styles={{ content: { color: progressColor, fontSize: 18 } }}
/>
</Col>
</Row>
</Col>
</Row>
</Card>
{meta === 0 && (
<Card
style={{ borderRadius: 8, border: '1px solid #fde68a', background: '#fffbeb' }}
styles={{ body: { padding: '12px 16px' } }}
>
<Space>
<AlertOutlined style={{ color: '#d97706' }} />
<Text style={{ color: '#92400e' }}>
Nenhuma meta cadastrada para {meses[mes - 1]}/{ano}. Fale com seu supervisor.
</Text>
</Space>
</Card>
)}
</Space>
)}
</div>
);
}
// ─── Aba 2: Carteira de Clientes ─────────────────────────────────────────────
type FiltroCarteira = 'todos' | 'inativos30' | 'inativos60' | 'semPedido';
function TabCarteira() {
const [filtro, setFiltro] = useState<FiltroCarteira>('todos');
const { data, isLoading, isError, error } = useReportCarteira();
const clientes = data?.data ?? [];
const filtered = clientes.filter((c: CarteiraCliente) => {
if (filtro === 'inativos30') return c.diasSemPedido != null && c.diasSemPedido >= 30;
if (filtro === 'inativos60') return c.diasSemPedido != null && c.diasSemPedido >= 60;
if (filtro === 'semPedido') return c.totalPedidos === 0;
return true;
});
function statusBadge(c: CarteiraCliente) {
if (c.totalPedidos === 0)
return <Badge color="#94A3B8" text={<Text style={{ fontSize: 12 }}>Sem pedidos</Text>} />;
if (c.diasSemPedido != null && c.diasSemPedido >= 60)
return (
<Badge
color="#ef4444"
text={<Text style={{ fontSize: 12, color: '#ef4444' }}>Inativo 60d+</Text>}
/>
);
if (c.diasSemPedido != null && c.diasSemPedido >= 30)
return (
<Badge
color="#f59e0b"
text={<Text style={{ fontSize: 12, color: '#f59e0b' }}>Inativo 30d+</Text>}
/>
);
return (
<Badge color="#22c55e" text={<Text style={{ fontSize: 12, color: '#22c55e' }}>Ativo</Text>} />
);
}
const columns: TableColumnsType<CarteiraCliente> = [
{
title: 'Cliente',
key: 'cliente',
render: (_: unknown, c: CarteiraCliente) => (
<Space orientation="vertical" size={0}>
<Text strong style={{ fontSize: 13 }}>
{c.razao ?? c.nome ?? `Cód. ${c.idCliente}`}
</Text>
{c.razao && c.nome && (
<Text type="secondary" style={{ fontSize: 11 }}>
{c.nome}
</Text>
)}
</Space>
),
},
{
title: 'Status',
key: 'status',
width: 140,
render: (_: unknown, c: CarteiraCliente) => statusBadge(c),
},
{
title: 'Último Pedido',
dataIndex: 'ultimoPedido',
width: 130,
render: (v: string | null) => (
<Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>
),
},
{
title: 'Dias Sem Pedido',
dataIndex: 'diasSemPedido',
width: 140,
align: 'right' as const,
render: (v: number | null) =>
v != null ? (
<Text
strong
className="tabular-nums"
style={{ color: v >= 60 ? '#ef4444' : v >= 30 ? '#f59e0b' : '#22c55e' }}
>
{v}d
</Text>
) : (
<Text type="secondary"></Text>
),
},
{
title: 'Pedidos',
dataIndex: 'totalPedidos',
width: 90,
align: 'right' as const,
render: (v: number) => <Text className="tabular-nums">{v}</Text>,
},
{
title: 'Ticket Médio',
dataIndex: 'ticketMedio',
width: 130,
align: 'right' as const,
render: (v: string) => (
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{fmt(v)}
</Text>
),
},
];
if (isError) {
return (
<Alert
type="error"
showIcon
message="Erro ao carregar carteira de clientes"
description={error instanceof Error ? error.message : 'Erro desconhecido'}
/>
);
}
return (
<div>
{data && (
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{[
{ key: 'todos', label: 'Total', value: data.total, color: '#003B8E' },
{ key: 'inativos30', label: 'Inativos 30d+', value: data.inativos30, color: '#f59e0b' },
{ key: 'inativos60', label: 'Inativos 60d+', value: data.inativos60, color: '#ef4444' },
{ key: 'semPedido', label: 'Sem Pedido', value: data.semPedido, color: '#94A3B8' },
].map((item) => (
<Col key={item.key} xs={12} md={6}>
<Card
hoverable
onClick={() => setFiltro(item.key as FiltroCarteira)}
style={{
borderRadius: 8,
border: filtro === item.key ? `2px solid ${item.color}` : '1px solid #EBF0F5',
cursor: 'pointer',
}}
styles={{ body: { padding: '12px 16px' } }}
>
<Statistic
title={item.label}
value={item.value}
styles={{ content: { color: item.color, fontSize: 24 } }}
/>
</Card>
</Col>
))}
</Row>
)}
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: 0 } }}
>
<Table<CarteiraCliente>
rowKey="idCliente"
columns={columns}
dataSource={filtered}
loading={isLoading}
size="middle"
pagination={{ pageSize: 15, showSizeChanger: false }}
scroll={{ x: 700 }}
style={{ borderRadius: 10, overflow: 'hidden' }}
/>
</Card>
</div>
);
}
// ─── Aba 3: Curva ABC ─────────────────────────────────────────────────────────
const CURVA_COLOR: Record<string, string> = { A: '#003B8E', B: '#f59e0b', C: '#94A3B8' };
function TabAbc() {
const now = dayjs();
const [mes, setMes] = useState<number | undefined>(now.month() + 1);
const [ano, setAno] = useState<number | undefined>(now.year());
const { data, isLoading, isError, error } = useReportAbc({ mes, ano });
const meses = [
'Jan',
'Fev',
'Mar',
'Abr',
'Mai',
'Jun',
'Jul',
'Ago',
'Set',
'Out',
'Nov',
'Dez',
];
const columns: TableColumnsType<AbcProduto> = [
{
title: 'Curva',
dataIndex: 'curva',
width: 70,
align: 'center' as const,
render: (v: string) => (
<Tag
color={CURVA_COLOR[v]}
style={{ fontWeight: 800, borderRadius: 20, fontSize: 13, padding: '0 10px' }}
>
{v}
</Tag>
),
},
{
title: 'Produto',
key: 'produto',
render: (_: unknown, p: AbcProduto) => (
<Space orientation="vertical" size={0}>
{p.codProduto && <Text style={{ fontSize: 11, color: '#94A3B8' }}>{p.codProduto}</Text>}
<Text style={{ fontSize: 13, fontWeight: 500 }}>{p.descProduto}</Text>
</Space>
),
},
{
title: 'Total Faturado',
dataIndex: 'totalFaturado',
width: 150,
align: 'right' as const,
render: (v: string, p: AbcProduto) => (
<Space orientation="vertical" size={0} style={{ alignItems: 'flex-end' }}>
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{fmt(v)}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{Number(p.participacao).toFixed(1)}% do total
</Text>
</Space>
),
},
{
title: 'Qtd. Vendida',
dataIndex: 'qtdVendida',
width: 110,
align: 'right' as const,
render: (v: string) => (
<Text className="tabular-nums">
{Number(v).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}
</Text>
),
},
{
title: 'Desc. Médio',
dataIndex: 'descontoMedio',
width: 110,
align: 'right' as const,
render: (v: string) => (
<Text className="tabular-nums" style={{ color: Number(v) > 5 ? '#f59e0b' : '#475569' }}>
{Number(v).toFixed(1)}%
</Text>
),
},
{
title: 'Comissão',
dataIndex: 'comissaoTotal',
width: 120,
align: 'right' as const,
render: (v: string) => (
<Text className="tabular-nums" style={{ color: '#22c55e', fontWeight: 600 }}>
{fmt(v)}
</Text>
),
},
];
if (isError) {
return (
<Alert
type="error"
showIcon
message="Erro ao carregar curva ABC"
description={error instanceof Error ? error.message : 'Erro desconhecido'}
/>
);
}
return (
<div>
<Space style={{ marginBottom: 20 }}>
<Select
value={mes}
onChange={(v) => setMes(v)}
allowClear
placeholder="Mês"
style={{ width: 100 }}
onClear={() => setMes(undefined)}
>
{meses.map((m, i) => (
<Option key={i + 1} value={i + 1}>
{m}
</Option>
))}
</Select>
<DatePicker
picker="year"
value={ano ? dayjs().year(ano) : null}
onChange={(d) => setAno(d?.year())}
style={{ width: 100 }}
placeholder="Ano"
allowClear
/>
{data && (
<Text type="secondary" style={{ fontSize: 13 }}>
Período: <strong>{data.periodo}</strong> Total:{' '}
<strong>{fmt(data.totalFaturado)}</strong>
</Text>
)}
</Space>
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: 0 } }}
>
<Table<AbcProduto>
rowKey={(r) => r.codProduto ?? r.descProduto}
columns={columns}
dataSource={data?.data ?? []}
loading={isLoading}
size="middle"
pagination={{ pageSize: 20, showSizeChanger: false }}
scroll={{ x: 800 }}
rowClassName={(r) => (r.curva === 'A' ? 'row-curva-a' : '')}
style={{ borderRadius: 10, overflow: 'hidden' }}
/>
</Card>
<style>{`
.row-curva-a td { background: #f0f5ff !important; }
`}</style>
</div>
);
}
// ─── RelatoriosPage ───────────────────────────────────────────────────────────
export function RelatoriosPage() {
const tabs = [
{
key: 'meta',
label: (
<Space>
<RiseOutlined />
Desempenho vs. Meta
</Space>
),
children: <TabMeta />,
},
{
key: 'carteira',
label: (
<Space>
<TeamOutlined />
Carteira de Clientes
</Space>
),
children: <TabCarteira />,
},
{
key: 'abc',
label: (
<Space>
<BarChartOutlined />
Curva ABC
</Space>
),
children: <TabAbc />,
},
];
return (
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
Relatórios
</Title>
<Text type="secondary" style={{ fontSize: 14 }}>
Análise de desempenho, carteira e mix de produtos.
</Text>
</div>
<Tabs defaultActiveKey="meta" items={tabs} size="large" />
</div>
);
}

View File

@@ -1,18 +1,55 @@
import { Card, Col, Flex, Progress, Row, Skeleton, Space, Table, Tag, Typography } from 'antd';
import { useState } from 'react';
import {
Button,
Card,
Col,
Flex,
Progress,
Row,
Segmented,
Skeleton,
Space,
Table,
Tag,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faArrowTrendUp,
faBullseye,
faChartBar,
faCircleExclamation,
faClipboardList,
} from '@fortawesome/free-solid-svg-icons';
import { Link } from '@tanstack/react-router';
import type { MetaItem, PedidoSummary } from '@sar/api-interface';
import { WhatsAppOutlined } from '@ant-design/icons';
import { Link, useNavigate } from '@tanstack/react-router';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
Tooltip as ChartTooltip,
Legend,
} from 'chart.js';
import { Chart } from 'react-chartjs-2';
import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth';
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
ChartTooltip,
Legend,
);
const { Title, Text } = Typography;
const SITUA_COLOR: Record<number, string> = {
@@ -34,17 +71,19 @@ function greeting(): string {
}
function today(): string {
return new Date().toLocaleDateString('pt-BR', {
day: 'numeric',
month: 'long',
});
return new Date().toLocaleDateString('pt-BR', { day: 'numeric', month: 'long' });
}
function num(v: number, dec = 0): string {
return v.toLocaleString('pt-BR', { minimumFractionDigits: dec, maximumFractionDigits: dec });
}
// Célula "realizado / meta" — realizado em destaque (verde se bateu), meta abaixo.
function waLink(whatsapp: string): string {
const digits = whatsapp.replace(/\D/g, '');
const number = digits.startsWith('55') ? digits : `55${digits}`;
return `https://wa.me/${number}`;
}
function MetaCell({
real,
meta,
@@ -131,6 +170,273 @@ const metaColumns: TableColumnsType<MetaItem> = [
},
];
// ─── Gráfico anual ────────────────────────────────────────────────────────────
const MESES = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
function GraficoAnual({
dados,
ano,
mesAtual,
}: {
dados: MesAno[];
ano: number;
mesAtual: number;
}) {
const labels = MESES;
const barColors = dados.map((d) => {
if (d.mes > mesAtual) return 'rgba(203,213,225,0.5)'; // futuro — cinza claro
if (d.mes === mesAtual) return d.atingiu ? 'rgba(56,158,13,0.75)' : 'rgba(0,59,142,0.75)'; // mês atual
return d.atingiu ? 'rgba(56,158,13,0.85)' : 'rgba(0,59,142,0.65)'; // passado
});
const borderColors = dados.map((d) => (d.mes === mesAtual ? '#1a1a1a' : 'transparent'));
const chartData = {
labels,
datasets: [
{
type: 'bar' as const,
label: 'Realizado',
data: dados.map((d) => (d.mes <= mesAtual ? d.valor : null)),
backgroundColor: barColors,
borderColor: borderColors,
borderWidth: dados.map((d) => (d.mes === mesAtual ? 2 : 0)),
borderRadius: 4,
order: 2,
},
{
type: 'line' as const,
label: 'Meta',
data: dados.map((d) => (d.meta > 0 ? d.meta : null)),
borderColor: '#f5222d',
backgroundColor: 'transparent',
borderWidth: 2,
borderDash: [5, 4],
pointRadius: dados.map((d) => (d.meta > 0 ? 4 : 0)),
pointBackgroundColor: dados.map((d) =>
d.meta > 0 && d.mes <= mesAtual ? (d.atingiu ? '#52c41a' : '#f5222d') : '#f5222d',
),
order: 1,
tension: 0.1,
},
],
};
const options = {
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: {
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', { style: 'currency', currency: 'BRL' })}`;
},
},
},
},
scales: {
x: { grid: { display: false } },
y: {
ticks: {
callback: (v: number | string) =>
Number(v).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
notation: 'compact',
}),
font: { size: 10 },
},
grid: { color: '#f0f0f0' },
},
},
};
return (
<Card
title={
<Space>
<FontAwesomeIcon icon={faChartBar} style={{ color: 'var(--jcs-blue)' }} />
Realizado vs Meta {ano}
</Space>
}
>
<div style={{ height: 240 }}>
<Chart type="bar" data={chartData} options={options} />
</div>
</Card>
);
}
// ─── Não positivados ──────────────────────────────────────────────────────────
type OrdemNP = 'longe' | 'recentes';
function NaoPositivados({ clientes, total }: { clientes: ClienteNaoPositivado[]; total: number }) {
const navigate = useNavigate();
const [ordem, setOrdem] = useState<OrdemNP>('longe');
const [expandido, setExpandido] = useState(false);
const PAGE = 10;
const sorted = [...clientes].sort((a, b) => {
if (ordem === 'longe') {
return (b.diasSemPedido ?? 999) - (a.diasSemPedido ?? 999);
}
// 'recentes': compraram antes primeiro, ordenados por último pedido desc
if (a.comprouAntes && !b.comprouAntes) return -1;
if (!a.comprouAntes && b.comprouAntes) return 1;
const da = a.ultimoPedido ?? '';
const db = b.ultimoPedido ?? '';
return db.localeCompare(da);
});
const visible = expandido ? sorted : sorted.slice(0, PAGE);
const restante = total - PAGE;
const columns: TableColumnsType<ClienteNaoPositivado> = [
{
title: 'Cliente',
key: 'cliente',
render: (_: unknown, c: ClienteNaoPositivado) => (
<Text
strong
style={{ cursor: 'pointer', color: 'var(--jcs-blue)' }}
onClick={() =>
void navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })
}
>
{c.razao ?? c.nome}
</Text>
),
},
{
title: 'Último pedido',
key: 'ultimo',
width: 160,
render: (_: unknown, c: ClienteNaoPositivado) => {
if (!c.comprouAntes) {
return (
<Tag color="default" style={{ borderRadius: 20 }}>
nunca comprou
</Tag>
);
}
const dias = c.diasSemPedido ?? 999;
const cor = dias >= 60 ? 'error' : dias >= 30 ? 'warning' : 'default';
return (
<Space orientation="vertical" size={0}>
<Tag color={cor} className="tabular-nums" style={{ borderRadius: 20 }}>
{dias}d sem pedido
</Tag>
{c.ultimoPedido && (
<Text type="secondary" style={{ fontSize: 11 }}>
{new Date(c.ultimoPedido).toLocaleDateString('pt-BR')}
</Text>
)}
</Space>
);
},
},
{
title: '',
key: 'acoes',
width: 96,
render: (_: unknown, c: ClienteNaoPositivado) => (
<Space size={6}>
<Button
size="small"
style={{ borderRadius: 6 }}
onClick={() =>
void navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })
}
>
Ficha
</Button>
{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>
),
},
];
return (
<Card
title={
<Space>
<FontAwesomeIcon icon={faCircleExclamation} style={{ color: 'var(--orange)' }} />
Não positivados no mês
{total > 0 && (
<Tag color="orange" style={{ borderRadius: 20, fontWeight: 700 }}>
{total}
</Tag>
)}
</Space>
}
extra={
<Segmented<OrdemNP>
size="small"
value={ordem}
onChange={setOrdem}
options={[
{ label: 'Mais longe', value: 'longe' },
{ label: 'Compraram antes', value: 'recentes' },
]}
/>
}
>
{clientes.length === 0 ? (
<Text type="secondary">Todos os clientes positivados este mês. Ótimo trabalho!</Text>
) : (
<Flex vertical gap={12}>
<Table<ClienteNaoPositivado>
rowKey="idCliente"
columns={columns}
dataSource={visible}
size="small"
pagination={false}
showHeader={false}
style={{ borderRadius: 8 }}
/>
{!expandido && total > PAGE && (
<Button
type="link"
style={{ alignSelf: 'flex-start', padding: 0 }}
onClick={() => setExpandido(true)}
>
Ver mais {restante} cliente{restante !== 1 ? 's' : ''}
</Button>
)}
{expandido && total > PAGE && (
<Button
type="link"
style={{ alignSelf: 'flex-start', padding: 0 }}
onClick={() => setExpandido(false)}
>
Recolher
</Button>
)}
</Flex>
)}
</Card>
);
}
// ─── RepPainel ────────────────────────────────────────────────────────────────
export function RepPainel() {
const { data, isLoading } = useRepDashboard();
const { data: user } = useCurrentUser();
@@ -140,13 +446,10 @@ export function RepPainel() {
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
<Skeleton active paragraph={{ rows: 2 }} />
<Row gutter={[24, 24]}>
<Col xs={24} md={12}>
<Col xs={24} md={14}>
<Skeleton active />
</Col>
<Col xs={12} md={6}>
<Skeleton active />
</Col>
<Col xs={12} md={6}>
<Col xs={24} md={10}>
<Skeleton active />
</Col>
</Row>
@@ -154,8 +457,20 @@ export function RepPainel() {
);
}
const { meta, metasPorGrupo, comissao, pedidosMes, pedidosRecentes, clientesInativos, syncedAt } =
data;
const {
meta,
metasPorGrupo = [],
pedidosMes,
pedidosRecentes = [],
naoPositivados = [],
totalNaoPositivados = 0,
historicoMensal = [],
syncedAt,
} = data;
const now = new Date();
const anoAtual = now.getFullYear();
const mesAtual = now.getMonth() + 1;
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
@@ -166,21 +481,21 @@ export function RepPainel() {
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
{today()}
{clientesInativos.length > 0 && (
{totalNaoPositivados > 0 && (
<>
{' '}
·{' '}
<span style={{ color: 'var(--orange)' }}>
{clientesInativos.length} clientes inativos
{totalNaoPositivados} clientes sem pedido no s
</span>
</>
)}
</Text>
</Flex>
{/* Linha 1 — Meta + KPIs */}
{/* Linha 1 — Meta + Pedidos do mês */}
<Row gutter={[24, 24]}>
<Col xs={24} md={12}>
<Col xs={24} md={14}>
<Card style={{ height: '100%' }}>
<Flex vertical gap={16}>
<Flex justify="space-between" align="flex-start">
@@ -205,7 +520,7 @@ export function RepPainel() {
percent={Math.min(meta.pct, 100)}
showInfo={false}
strokeColor="var(--jcs-blue)"
trailColor="var(--jcs-blue-light)"
railColor="var(--jcs-blue-light)"
/>
{meta.falta > 0 ? (
<Text style={{ fontSize: 'var(--text-md)' }}>
@@ -221,8 +536,8 @@ export function RepPainel() {
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Col xs={24} md={10}>
<Card style={{ height: '100%' }}>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
PEDIDOS NO MÊS
@@ -231,32 +546,14 @@ export function RepPainel() {
{pedidosMes}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
<FontAwesomeIcon icon={faArrowTrendUp} /> últimos 30 dias
<FontAwesomeIcon icon={faArrowTrendUp} /> mês corrente
</Text>
</Space>
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
COMISSÃO ACUMULADA
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(comissao.total)}
</Title>
{comissao.flex > 0 && (
<Text type="success" style={{ fontSize: 'var(--text-sm)' }}>
FLEX: {fmt(comissao.flex)}
</Text>
)}
</Space>
</Card>
</Col>
</Row>
{/* Metas por Grupo — acompanhamento multi-medida do mês */}
{/* Metas por Grupo */}
{metasPorGrupo.length > 0 && (
<Card
title={
@@ -332,67 +629,16 @@ export function RepPainel() {
</Card>
)}
{/* Linha 2 — Clientes inativos + Pedidos recentes */}
{/* Gráfico anual */}
<GraficoAnual dados={historicoMensal} ano={anoAtual} mesAtual={mesAtual} />
{/* Linha 2 — Não positivados + Pedidos recentes */}
<Row gutter={[24, 24]}>
<Col xs={24} lg={12}>
<Card
title={
<Space>
<FontAwesomeIcon icon={faCircleExclamation} style={{ color: 'var(--orange)' }} />
Clientes esfriando
</Space>
}
extra={
clientesInativos.length > 0 ? (
<Text type="secondary">{clientesInativos.length} clientes</Text>
) : null
}
>
{clientesInativos.length === 0 ? (
<Text type="secondary">Nenhum cliente inativo. Ótimo trabalho!</Text>
) : (
<Flex vertical gap={12}>
{clientesInativos.map((c) => (
<Flex
key={c.idCliente}
justify="space-between"
align="center"
style={{
padding: 'var(--space-sm) var(--space-md)',
borderRadius: 12,
background: c.diasSemCompra > 60 ? '#fff7e6' : 'var(--bg-surface-alt)',
}}
>
<Space orientation="vertical" size={0}>
<Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
<Text strong>{c.nome}</Text>
</Link>
{c.ultimaCompraValor && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Última compra:{' '}
<span className="tabular-nums">
{Number(c.ultimaCompraValor).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})}
</span>
</Text>
)}
</Space>
<Tag
color={c.diasSemCompra > 60 ? 'orange' : 'default'}
className="tabular-nums"
>
{c.diasSemCompra >= 999 ? 'nunca comprou' : `${c.diasSemCompra}d`}
</Tag>
</Flex>
))}
</Flex>
)}
</Card>
<Col xs={24} lg={14}>
<NaoPositivados clientes={naoPositivados} total={totalNaoPositivados} />
</Col>
<Col xs={24} lg={12}>
<Col xs={24} lg={10}>
<Card
title={
<Space>

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

@@ -0,0 +1,248 @@
import {
App,
Card,
Col,
DatePicker,
Divider,
Form,
Input,
Modal,
Row,
Space,
Spin,
Tag,
Tooltip,
Typography,
} from 'antd';
import { MailOutlined, PhoneOutlined, WhatsAppOutlined } from '@ant-design/icons';
import type { Contato } from '@sar/api-interface';
import { useClientContacts, useCreateContato } from '../../lib/queries/clients';
const { Text } = Typography;
function ContactActions({ contato }: { contato: Contato }) {
const wa = contato.whatsapp?.trim() || contato.celular?.trim();
const tel = contato.telefone?.trim();
const mail = contato.email?.trim();
return (
<Space size={4} wrap>
{wa && (
<Tooltip title={`WhatsApp: ${wa}`}>
<a href={`https://wa.me/55${wa.replace(/\D/g, '')}`} target="_blank" rel="noreferrer">
<Tag
icon={<WhatsAppOutlined />}
color="#25D366"
style={{ cursor: 'pointer', margin: 0 }}
>
{wa}
</Tag>
</a>
</Tooltip>
)}
{tel && !wa && (
<Tooltip title={`Telefone: ${tel}`}>
<Tag icon={<PhoneOutlined />} color="blue" style={{ margin: 0 }}>
{tel}
</Tag>
</Tooltip>
)}
{mail && (
<Tooltip title={mail}>
<a href={`mailto:${mail}`}>
<Tag icon={<MailOutlined />} color="default" style={{ margin: 0 }}>
{mail.length > 22 ? mail.slice(0, 20) + '…' : mail}
</Tag>
</a>
</Tooltip>
)}
</Space>
);
}
interface Props {
idCliente: number;
modalOpen?: boolean;
onModalClose?: () => void;
}
export function ClientContacts({ idCliente, modalOpen = false, onModalClose }: Props) {
const { message } = App.useApp();
const open = modalOpen;
const setOpen = (v: boolean) => {
if (!v) onModalClose?.();
};
const [form] = Form.useForm();
const { data: contacts = [], isLoading } = useClientContacts(idCliente);
const createMutation = useCreateContato(idCliente);
async function handleSave() {
const values = await form.validateFields();
const payload = {
nome: String(values.nome),
empresa: values.empresa || undefined,
cargo: values.cargo || undefined,
departamento: values.departamento || undefined,
dtAniversario: values.dtAniversario
? (values.dtAniversario as { format: (f: string) => string }).format('YYYY-MM-DD')
: undefined,
telefone: values.telefone || undefined,
celular: values.celular || undefined,
whatsapp: values.whatsapp || undefined,
email: values.email || undefined,
anotacoes: values.anotacoes || undefined,
};
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) {
void message.warning(`Contato salvo, mas não sincronizou com ERP: ${result.erroSync}`);
} else {
void message.success(`Contato cadastrado! Código ERP: ${result.idContatoErp ?? '—'}`);
}
setOpen(false);
form.resetFields();
}
return (
<>
<Divider titlePlacement="left" style={{ marginBottom: 12 }}>
Contatos
</Divider>
{isLoading ? (
<Spin style={{ display: 'block', margin: '16px auto' }} />
) : contacts.length === 0 ? (
<Typography.Text
type="secondary"
style={{ display: 'block', marginBottom: 24, textAlign: 'center' }}
>
Nenhum contato cadastrado.
</Typography.Text>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 12,
marginBottom: 24,
}}
>
{contacts.map((c: Contato) => (
<Card
key={c.idContato}
size="small"
styles={{ body: { padding: '10px 12px' } }}
style={{ borderRadius: 8 }}
>
<Text strong style={{ fontSize: 13, display: 'block', marginBottom: 2 }}>
{c.nome}
</Text>
{(c.cargo || c.departamento) && (
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 6 }}>
{[c.cargo, c.departamento].filter(Boolean).join(' · ')}
</Text>
)}
<ContactActions contato={c} />
{c.dtAniversario && (
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 4 }}>
Aniversário: {new Date(c.dtAniversario + 'T12:00:00').toLocaleDateString('pt-BR')}
</Text>
)}
</Card>
))}
</div>
)}
<Modal
title="Novo Contato"
open={open}
onOk={() => void handleSave()}
onCancel={() => {
setOpen(false);
form.resetFields();
}}
confirmLoading={createMutation.isPending}
okText="Cadastrar"
cancelText="Cancelar"
width={580}
destroyOnHidden
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }} requiredMark="optional">
<Form.Item
name="nome"
label="Nome"
rules={[{ required: true, message: 'Informe o nome' }]}
>
<Input placeholder="Nome do contato" maxLength={100} />
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="cargo" label="Cargo">
<Input placeholder="Comprador, Gerente..." maxLength={100} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="departamento" label="Departamento">
<Input placeholder="Compras, Financeiro..." maxLength={100} />
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="whatsapp" label="WhatsApp">
<Input
placeholder="48999990000"
maxLength={20}
prefix={<WhatsAppOutlined style={{ color: '#25D366' }} />}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="celular" label="Celular">
<Input placeholder="48999990000" maxLength={20} />
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="telefone" label="Telefone fixo">
<Input placeholder="4833330000" maxLength={20} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="email" label="E-mail">
<Input placeholder="contato@empresa.com.br" maxLength={150} type="email" />
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="dtAniversario" label="Aniversário">
<DatePicker
format="DD/MM/YYYY"
style={{ width: '100%' }}
placeholder="dd/mm/aaaa"
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="anotacoes" label="Anotações">
<Input.TextArea rows={2} placeholder="Observações sobre o contato..." />
</Form.Item>
</Form>
</Modal>
</>
);
}

View File

@@ -9,12 +9,14 @@ import { AuthTokenResponseSchema } from '@sar/api-interface';
type DevUser = { key: string; userId: string; role: string; label: string };
// userId = cod_vendedor como string; idEmpresa = empresa no ERP (dev default = 1)
// Em dev, o backend força DEV_REP_CODE=29 independente do userId enviado.
// userId = cod_vendedor como string; idEmpresa fica a cargo do backend (DEV_EMPRESA_ID).
// 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[] = [
{ key: 'rep-29', userId: '29', role: 'rep', label: 'Representante (cód. 29)' },
{ key: 'sup-29', userId: '29', role: 'supervisor', label: 'Supervisor (cód. 29)' },
{ key: 'mgr-29', userId: '29', role: 'manager', label: 'Gerente (cód. 29)' },
{ key: 'rep-29', userId: '29', role: 'rep', label: 'Pavei — Representante (cód. 29)' },
{ key: 'sup-191', userId: '191', role: 'supervisor', label: 'Sidnei — Supervisor (cód. 191)' },
{ key: 'mgr-156', userId: '156', role: 'manager', label: 'Lucas — Gerente/Admin (cód. 156)' },
];
export function DevLogin({ onLogin }: { onLogin: () => void }) {
@@ -27,7 +29,7 @@ export function DevLogin({ onLogin }: { onLogin: () => void }) {
try {
const raw = await apiFetch('/auth/dev/token', {
method: 'POST',
body: { userId: user.userId, idEmpresa: 1, role: user.role },
body: { userId: user.userId, role: user.role },
});
const { accessToken } = AuthTokenResponseSchema.parse(raw);
authStore.set(accessToken);

View File

@@ -1,29 +1,36 @@
import { useState, type ReactNode } from 'react';
import { Alert, Button, Flex, Tooltip } from 'antd';
import { useEffect, type ReactNode } from 'react';
import { Alert, App, Button, Flex, Grid, Tooltip } from 'antd';
import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
import { useNavigate } from '@tanstack/react-router';
import { Topbar } from './Topbar';
import { Sidebar } from './Sidebar';
import { BottomNav } from './BottomNav';
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
import { registerMessageApi } from '../../lib/feedback';
const { useBreakpoint } = Grid;
interface AppShellProps {
children: ReactNode;
}
/**
* AppShell canônico do SAR — topbar 80 + sidebar 260 + área de conteúdo.
* Layout para cockpits Sandra/Daniel/Alice (desktop-first).
* Variante mobile (Rafael) com bottom nav virá em ShellMobile separado.
*/
export function AppShell({ children }: AppShellProps) {
const [, setSidebarOpen] = useState(true);
const navigate = useNavigate();
const isOnline = useNetworkStatus();
const screens = useBreakpoint();
const { message } = App.useApp();
// sidebar a partir de lg (992px) — abaixo disso usa bottom nav
const isDesktop = !!screens.lg;
useOfflineSync();
// Disponibiliza o message (com tema) para os caches do TanStack Query
useEffect(() => {
registerMessageApi(message);
}, [message]);
return (
<Flex vertical style={{ minHeight: '100vh', background: 'var(--bg-body)' }}>
<Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}>
{!isOnline && (
<Alert
type="warning"
@@ -34,22 +41,28 @@ export function AppShell({ children }: AppShellProps) {
style={{ padding: '6px 16px', fontSize: 13 }}
/>
)}
<Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} />
<Flex flex={1}>
<Sidebar />
<Topbar />
<Flex flex={1} style={{ overflow: 'hidden' }}>
{isDesktop && <Sidebar />}
<main
style={{
flex: 1,
padding: 'var(--space-xl)',
overflow: 'auto',
maxWidth: '100%',
padding: isDesktop ? 'var(--space-xl)' : 'var(--space-md)',
paddingBottom: isDesktop
? 'var(--space-xl)'
: 'calc(var(--layout-bottom-nav-height) + var(--space-lg))',
overflowY: 'auto',
overflowX: 'hidden',
minWidth: 0,
}}
>
{children}
</main>
</Flex>
{/* FAB — Novo Pedido */}
{!isDesktop && <BottomNav />}
{/* FAB — Novo Pedido: acima do bottom nav em mobile */}
<Tooltip title="Novo Pedido" placement="left">
<Button
type="primary"
@@ -58,8 +71,8 @@ export function AppShell({ children }: AppShellProps) {
onClick={() => void navigate({ to: '/pedidos/novo' })}
style={{
position: 'fixed',
bottom: 32,
right: 32,
bottom: isDesktop ? 32 : 'calc(var(--layout-bottom-nav-height) + 16px)',
right: 20,
width: 52,
height: 52,
fontSize: 22,

View File

@@ -0,0 +1,119 @@
import { useLocation, useNavigate } from '@tanstack/react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faChartLine,
faClipboardList,
faClipboardCheck,
faUsers,
faAddressCard,
faBoxesStacked,
faFileInvoiceDollar,
faTags,
} from '@fortawesome/free-solid-svg-icons';
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons';
import { authStore } from '../../lib/auth-store';
interface NavItem {
key: string;
icon: IconDefinition;
label: string;
}
function getRole(): string {
const token = authStore.get();
if (!token) return 'rep';
try {
const payload = JSON.parse(atob(token.split('.')[1] ?? ''));
return (payload.role as string) ?? 'rep';
} catch {
return 'rep';
}
}
const REP_ITEMS: NavItem[] = [
{ key: '/', icon: faChartLine, label: 'Painel' },
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
{ key: '/pedidos', icon: faClipboardList, label: 'Pedidos' },
{ key: '/catalogo', icon: faBoxesStacked, label: 'Catálogo' },
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
];
const SUPERVISOR_ITEMS: NavItem[] = [
{ key: '/', icon: faChartLine, label: 'Painel' },
{ key: '/aprovacoes', icon: faClipboardCheck, label: 'Aprovações' },
{ key: '/pedidos', icon: faClipboardList, label: 'Pedidos' },
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
];
const GERENTE_ITEMS: NavItem[] = [
{ key: '/ger', icon: faChartLine, label: 'Painel' },
{ key: '/ger/equipe', icon: faUsers, label: 'Equipe' },
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
{ key: '/ger/politicas', icon: faTags, label: 'Políticas' },
];
function getItems(role: string): NavItem[] {
if (role === 'manager' || role === 'admin') return GERENTE_ITEMS;
if (role === 'supervisor') return SUPERVISOR_ITEMS;
return REP_ITEMS;
}
export function BottomNav() {
const navigate = useNavigate();
const location = useLocation();
const role = getRole();
const items = getItems(role);
return (
<nav
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 'var(--layout-bottom-nav-height)',
background: 'var(--bg-surface)',
borderTop: '1px solid var(--border-subtle)',
display: 'flex',
zIndex: 'var(--z-fixed)' as unknown as number,
boxShadow: '0 -2px 12px rgba(0,0,0,0.06)',
}}
>
{items.map((item) => {
const isActive =
item.key === '/' || item.key === '/ger'
? location.pathname === item.key
: location.pathname.startsWith(item.key);
return (
<button
key={item.key}
onClick={() => void navigate({ to: item.key })}
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 3,
border: 'none',
background: 'none',
cursor: 'pointer',
padding: '6px 4px',
color: isActive ? 'var(--jcs-blue)' : 'var(--text-muted)',
fontSize: 10,
fontWeight: isActive ? 600 : 400,
fontFamily: 'var(--font-family-base)',
transition: 'color var(--duration-fast) var(--easing-standard)',
WebkitTapHighlightColor: 'transparent',
}}
>
<FontAwesomeIcon icon={item.icon} style={{ fontSize: 20 }} />
{item.label}
</button>
);
})}
</nav>
);
}

View File

@@ -4,61 +4,92 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faChartLine,
faClipboardList,
faClipboardCheck,
faFunnelDollar,
faCalendarDays,
faUsers,
faAddressCard,
faBoxesStacked,
faGear,
faPercent,
faFileInvoiceDollar,
faTags,
faHeadset,
} from '@fortawesome/free-solid-svg-icons';
import type { ItemType } from 'antd/es/menu/interface';
import { authStore } from '../../lib/auth-store';
/**
* Sidebar canônica do SAR (260px fixa — brand.md).
* Itens mockados para Rafael cockpit. Variantes por cockpit virão depois.
*/
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const items: ItemType[] = [
{
key: '/',
icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />,
label: 'Painel',
},
{
key: '/agenda',
icon: <FontAwesomeIcon icon={faCalendarDays} fixedWidth />,
label: 'Agenda e Rotas',
},
{
key: '/clientes',
icon: <FontAwesomeIcon icon={faUsers} fixedWidth />,
label: 'Clientes',
},
{
key: '/catalogo',
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
label: 'Catálogo',
},
function getRole(): string {
const token = authStore.get();
if (!token) return 'rep';
try {
const payload = JSON.parse(atob(token.split('.')[1] ?? ''));
return (payload.role as string) ?? 'rep';
} catch {
return 'rep';
}
}
const REP_ITEMS: ItemType[] = [
{ 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: '/catalogo',
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
label: 'Catálogo',
},
{
key: '/pedidos',
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
label: 'Pedidos',
},
{
key: '/comissao',
icon: <FontAwesomeIcon icon={faPercent} fixedWidth />,
label: 'Comissão / FLEX',
key: '/pedidos-erp',
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
label: 'Consulta ERP',
},
{ key: '/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
{
key: '/relatorios',
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
label: 'Relatórios',
},
];
const SUPERVISOR_ITEMS: ItemType[] = [
{ key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
{
key: '/aprovacoes',
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
label: 'Aprovações',
},
{ key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
{
key: '/pedidos',
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
label: 'Pedidos',
},
{
type: 'divider',
key: '/catalogo',
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
label: 'Catálogo',
},
{
key: '/relatorios',
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
label: 'Relatórios',
},
];
const GERENTE_ITEMS: ItemType[] = [
{ key: '/ger', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
{ key: '/ger/equipe', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Equipe' },
{
key: '/clientes',
icon: <FontAwesomeIcon icon={faAddressCard} fixedWidth />,
label: 'Clientes',
},
{
key: '/relatorios',
@@ -66,12 +97,30 @@ export function Sidebar() {
label: 'Relatórios',
},
{
key: '/configuracoes',
icon: <FontAwesomeIcon icon={faGear} fixedWidth />,
label: 'Configurações',
key: '/ger/politicas',
icon: <FontAwesomeIcon icon={faTags} fixedWidth />,
label: 'Políticas Comerciais',
},
{ key: '/ger/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
];
function getItems(role: string): ItemType[] {
if (role === 'manager' || role === 'admin') return GERENTE_ITEMS;
if (role === 'supervisor') return SUPERVISOR_ITEMS;
return REP_ITEMS;
}
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const role = getRole();
const items = getItems(role);
const selectedKey = items.find((item) => {
const key = (item as { key?: string }).key ?? '';
return key === '/' ? location.pathname === '/' : location.pathname.startsWith(key);
})?.key as string | undefined;
return (
<aside
style={{
@@ -88,7 +137,7 @@ export function Sidebar() {
>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
selectedKeys={selectedKey ? [selectedKey] : [location.pathname]}
items={items}
onClick={({ key }) => navigate({ to: key as string })}
style={{

View File

@@ -1,38 +1,34 @@
import { Avatar, Badge, Button, Dropdown, Flex, Input, Typography } from 'antd';
import { Avatar, Badge, Button, Dropdown, Flex, Grid, Typography } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faBell,
faMagnifyingGlass,
faBars,
faRightFromBracket,
} from '@fortawesome/free-solid-svg-icons';
import { faBell, faBuilding, faRightFromBracket } from '@fortawesome/free-solid-svg-icons';
import { useNavigate } from '@tanstack/react-router';
import { brandTokens } from '../../lib/theme';
import { FoundationStatus } from './FoundationStatus';
import { usePendingCount } from '../../lib/queries/notifications';
import { useCurrentUser } from '../../lib/queries/auth';
import { useCompany } from '../../lib/queries/company';
import { authStore } from '../../lib/auth-store';
interface TopbarProps {
onToggleSidebar?: () => void;
}
const { useBreakpoint } = Grid;
/**
* Topbar canônica do SAR (80px de altura — brand.md).
* Apple-inspired clean: logo à esquerda, search central, notif + perfil à direita.
* Variantes por cockpit: search desabilitada para Rafael mobile (vai pro bottom nav).
*/
function logout() {
authStore.clear();
window.location.reload();
}
export function Topbar({ onToggleSidebar }: TopbarProps) {
export function Topbar() {
const navigate = useNavigate();
const screens = useBreakpoint();
const isDesktop = !!screens.lg;
const { data: pendingData } = usePendingCount();
const pendingCount = pendingData?.count ?? 0;
const { data: user } = useCurrentUser();
const { data: company } = useCompany();
const nomeFantasia = company?.nomeFantasia?.trim() ?? null;
const razaoSocial = company?.razaoSocial?.trim() ?? null;
const nomeEmpresa = nomeFantasia ?? razaoSocial ?? '—';
const mostrarRazao = nomeFantasia && razaoSocial && nomeFantasia !== razaoSocial;
const initials = user?.nome
? user.nome
.split(' ')
@@ -75,26 +71,20 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
align="center"
justify="space-between"
style={{
height: 'var(--layout-topbar-height)',
padding: '0 var(--space-lg)',
height: isDesktop ? 'var(--layout-topbar-height)' : 56,
padding: isDesktop ? '0 var(--space-lg)' : '0 var(--space-md)',
background: 'var(--bg-surface)',
borderBottom: '1px solid var(--border-subtle)',
position: 'sticky',
top: 0,
zIndex: 'var(--z-sticky)',
flexShrink: 0,
}}
>
{/* Lado esquerdo: hamburger (mobile) + logo */}
<Flex align="center" gap={16}>
<Button
type="text"
icon={<FontAwesomeIcon icon={faBars} />}
onClick={onToggleSidebar}
aria-label="Alternar menu"
style={{ display: 'inline-flex' }}
/>
<Flex align="center" gap={12}>
<img src="/sar-icon.png" alt="SAR" style={{ height: 40, width: 'auto' }} />
{/* Logo */}
<Flex align="center" gap={isDesktop ? 12 : 8}>
<img src="/sar-icon.png" alt="SAR" style={{ height: isDesktop ? 40 : 32, width: 'auto' }} />
{isDesktop && (
<Flex vertical gap={0}>
<span
style={{
@@ -118,24 +108,54 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
Força de Vendas
</span>
</Flex>
</Flex>
)}
</Flex>
{/* Centro: search (Supervisor/Admin) */}
<Flex flex={1} justify="center" style={{ maxWidth: 480, margin: '0 var(--space-2xl)' }}>
<Input
size="large"
placeholder="Buscar cliente, pedido, produto..."
prefix={
<FontAwesomeIcon icon={faMagnifyingGlass} style={{ color: 'var(--text-muted)' }} />
}
style={{ borderRadius: 12 }}
aria-label="Buscar"
{/* Centro: empresa ativa */}
<Flex
flex={1}
justify="center"
style={{
margin: `0 ${isDesktop ? 'var(--space-2xl)' : 'var(--space-sm)'}`,
overflow: 'hidden',
}}
>
<Flex align="center" gap={isDesktop ? 10 : 6} style={{ overflow: 'hidden' }}>
<FontAwesomeIcon
icon={faBuilding}
style={{ color: 'var(--text-muted)', fontSize: isDesktop ? 16 : 13, flexShrink: 0 }}
/>
<Flex vertical gap={0} style={{ overflow: 'hidden' }}>
<Typography.Text
ellipsis
style={{
fontSize: isDesktop ? 'var(--text-xl)' : 'var(--text-sm)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--text-main)',
lineHeight: 1.2,
}}
>
{nomeEmpresa}
</Typography.Text>
{isDesktop && mostrarRazao && (
<Typography.Text
style={{
fontSize: 'var(--text-2xs)',
color: 'var(--text-muted)',
lineHeight: 1.3,
whiteSpace: 'nowrap',
}}
>
{razaoSocial}
</Typography.Text>
)}
</Flex>
</Flex>
</Flex>
{/* Lado direito: novo pedido + status fundação + notificações + perfil */}
<Flex align="center" gap={16}>
{/* Lado direito */}
<Flex align="center" gap={isDesktop ? 16 : 8}>
{isDesktop && (
<Button
type="primary"
icon={<PlusOutlined />}
@@ -144,23 +164,25 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
>
Novo Pedido
</Button>
<FoundationStatus />
)}
{isDesktop && <FoundationStatus />}
<Badge count={pendingCount} color={brandTokens.red} offset={[-4, 4]}>
<Button
type="text"
size="large"
size={isDesktop ? 'large' : 'middle'}
icon={<FontAwesomeIcon icon={faBell} />}
aria-label="Notificações"
/>
</Badge>
<Dropdown menu={{ items: userMenuItems }} trigger={['click']} placement="bottomRight">
<Avatar
size={40}
size={isDesktop ? 40 : 32}
style={{
background: 'var(--jcs-blue-light)',
color: 'var(--jcs-blue)',
fontWeight: 'var(--font-weight-semibold)',
cursor: 'pointer',
flexShrink: 0,
}}
>
{initials}

View File

@@ -0,0 +1,64 @@
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
import { usePromocoesVigentes, useRegrasVigentes } 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);
}

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

@@ -1,10 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import {
FormaPagamentoSchema,
MunicipioSchema,
PautaSchema,
ProdutoListResponseSchema,
ProdutoDetailSchema,
type FormaPagamento,
type Municipio,
type ProdutoListQuery,
type ProdutoListResponse,
type ProdutoDetail,
@@ -13,6 +15,19 @@ import {
import { z } from 'zod';
import { apiFetch } from '../api-client';
export function useMunicipios(q?: string) {
return useQuery<Municipio[]>({
queryKey: ['catalog', 'municipios', q ?? ''],
queryFn: async () => {
const qs = q ? `?q=${encodeURIComponent(q)}` : '';
const res = await apiFetch(`/catalog/municipios${qs}`);
return z.array(MunicipioSchema).parse(res);
},
staleTime: 24 * 60 * 60 * 1000,
enabled: !q || q.length >= 2,
});
}
export function usePautas() {
return useQuery<Pauta[]>({
queryKey: ['catalog', 'pautas'],

View File

@@ -1,11 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ClientListResponseSchema,
ClientDetailSchema,
ClientListResponseSchema,
ClientNovoResultSchema,
ContatoResultSchema,
ContatoSchema,
CtrSummarySchema,
CtrTituloSchema,
NotaFiscalSchema,
PedidoSummarySchema,
TopProdutoSchema,
type ClientDetail,
type ClientListQuery,
type ClientListResponse,
type ClientDetail,
type ClientNovoResult,
type Contato,
type ContatoResult,
type CtrSummary,
type CtrTitulo,
type CreateClientNovo,
type CreateContato,
type NotaFiscal,
type PedidoSummary,
type TopProduto,
} from '@sar/api-interface';
import { z } from 'zod';
import { apiFetch } from '../api-client';
export const CLIENT_KEYS = {
@@ -42,3 +61,101 @@ export function useClientDetail(id: number | string | undefined) {
enabled: !!id,
});
}
export function useClientTopProdutos(idCliente: number | undefined, limit = 30) {
return useQuery<TopProduto[]>({
queryKey: ['clients', 'top-produtos', idCliente, limit],
queryFn: async () => {
const res = await apiFetch(`/clients/${idCliente}/top-produtos?limit=${limit}`);
return z.array(TopProdutoSchema).parse(res);
},
enabled: !!idCliente,
});
}
export function useClientOrdersHistory(idCliente: number | undefined, limit = 50) {
return useQuery<PedidoSummary[]>({
queryKey: ['clients', 'orders-history', idCliente, limit],
queryFn: async () => {
const res = await apiFetch(`/clients/${idCliente}/orders-history?limit=${limit}`);
return z.array(PedidoSummarySchema).parse(res);
},
enabled: !!idCliente,
});
}
export function useClientCtrList(idCliente: number | undefined, limit = 60) {
return useQuery<CtrTitulo[]>({
queryKey: ['clients', 'ctr-list', idCliente, limit],
queryFn: async () => {
const res = await apiFetch(`/clients/${idCliente}/ctr-list?limit=${limit}`);
return z.array(CtrTituloSchema).parse(res);
},
enabled: !!idCliente,
});
}
export function useClientNotasFiscais(idCliente: number | undefined, limit = 30) {
return useQuery<NotaFiscal[]>({
queryKey: ['clients', 'notas', idCliente, limit],
queryFn: async () => {
const res = await apiFetch(`/clients/${idCliente}/notas?limit=${limit}`);
return z.array(NotaFiscalSchema).parse(res);
},
enabled: !!idCliente,
});
}
export function useClientCtr(idCliente: number | undefined) {
return useQuery<CtrSummary>({
queryKey: ['clients', 'ctr', idCliente],
queryFn: async () => {
const res = await apiFetch(`/clients/${idCliente}/ctr`);
return CtrSummarySchema.parse(res);
},
enabled: !!idCliente,
});
}
export function useClientContacts(idCliente: number | undefined) {
return useQuery<Contato[]>({
queryKey: ['clients', 'contacts', idCliente],
queryFn: async () => {
const res = await apiFetch(`/clients/${idCliente}/contacts`);
return z.array(ContatoSchema).parse(res);
},
enabled: !!idCliente,
});
}
export function useCreateContato(idCliente: number | undefined) {
const queryClient = useQueryClient();
return useMutation<ContatoResult, Error, CreateContato>({
mutationFn: async (data) => {
const res = await apiFetch(`/clients/${idCliente}/contacts`, {
method: 'POST',
body: data,
});
return ContatoResultSchema.parse(res);
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['clients', 'contacts', idCliente] });
},
});
}
export function useCreateClientNovo() {
const queryClient = useQueryClient();
return useMutation<ClientNovoResult, Error, CreateClientNovo>({
mutationFn: async (data) => {
const res = await apiFetch('/clients/novo', {
method: 'POST',
body: data,
});
return ClientNovoResultSchema.parse(res);
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: CLIENT_KEYS.all });
},
});
}

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

@@ -0,0 +1,171 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
ManagerDashboardSchema,
EquipeResponseSchema,
AlcadaDescontosResponseSchema,
PromocoesResponseSchema,
RegrasDescontoResponseSchema,
GruposProdutoResponseSchema,
type ManagerDashboard,
type EquipeResponse,
type AlcadaDescontosResponse,
type PromocoesResponse,
type UpsertDescontoBody,
type CreatePromocaoBody,
type UpdatePromocaoBody,
type RegrasDescontoResponse,
type GruposProdutoResponse,
type CreateRegraDescontoBody,
type UpdateRegraDescontoBody,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
export function useManagerDashboard(mes?: number, ano?: number) {
const params = new URLSearchParams();
if (mes) params.set('mes', String(mes));
if (ano) params.set('ano', String(ano));
const qs = params.size > 0 ? `?${params.toString()}` : '';
return useQuery<ManagerDashboard>({
queryKey: ['dashboard', 'manager', mes, ano],
queryFn: async () => {
const raw = await apiFetch(`/dashboard/manager${qs}`);
return ManagerDashboardSchema.parse(raw);
},
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
});
}
export function useEquipe() {
return useQuery<EquipeResponse>({
queryKey: ['equipe'],
queryFn: async () => {
const raw = await apiFetch('/equipe');
return EquipeResponseSchema.parse(raw);
},
staleTime: 5 * 60 * 1000,
});
}
export function usePoliticasDescontos() {
return useQuery<AlcadaDescontosResponse>({
queryKey: ['politicas', 'descontos'],
queryFn: async () => {
const raw = await apiFetch('/politicas/descontos');
return AlcadaDescontosResponseSchema.parse(raw);
},
staleTime: 10 * 60 * 1000,
});
}
export function usePromocoes() {
return useQuery<PromocoesResponse>({
queryKey: ['politicas', 'promocoes'],
queryFn: async () => {
const raw = await apiFetch('/politicas/promocoes');
return PromocoesResponseSchema.parse(raw);
},
staleTime: 5 * 60 * 1000,
});
}
export function useUpsertDesconto() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: UpsertDescontoBody) =>
apiFetch('/politicas/descontos', { method: 'POST', body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }),
});
}
export function useCreatePromocao() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: CreatePromocaoBody) =>
apiFetch('/politicas/promocoes', { method: 'POST', body }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
},
});
}
export function useUpdatePromocao() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: UpdatePromocaoBody }) =>
apiFetch(`/politicas/promocoes/${id}`, { method: 'PATCH', body }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
},
});
}
export function useDeletePromocao() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiFetch(`/politicas/promocoes/${id}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
},
});
}
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

@@ -1,7 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import {
PedidoErpConsultaResponseSchema,
PedidoListResponseSchema,
PedidoDetailSchema,
type PedidoErpConsultaQuery,
type PedidoErpConsultaResponse,
type PedidoListQuery,
type PedidoListResponse,
type PedidoDetail,
@@ -34,7 +37,11 @@ export function useOrderDetail(id: string | undefined) {
queryKey: ['orders', id],
enabled: !!id,
queryFn: async () => {
const res = await apiFetch(`/orders/${id}`);
// Pedidos ERP têm id 'erp-{idPedido}' — endpoint separado sem ParseUUIDPipe
const path = id?.startsWith('erp-')
? `/orders/erp/${id.replace('erp-', '')}`
: `/orders/${id}`;
const res = await apiFetch(path);
return PedidoDetailSchema.parse(res);
},
});
@@ -51,3 +58,22 @@ export function useClientOrders(idCliente: number | undefined) {
},
});
}
export function useOrderErpConsulta(params: Partial<PedidoErpConsultaQuery> = {}, enabled = true) {
const search = new URLSearchParams();
if (params.search) search.set('search', params.search);
if (params.from) search.set('from', params.from);
if (params.to) search.set('to', params.to);
if (params.page) search.set('page', String(params.page));
if (params.limit) search.set('limit', String(params.limit));
const qs = search.toString();
return useQuery<PedidoErpConsultaResponse>({
queryKey: ['orders-erp-consulta', params],
enabled,
queryFn: async () => {
const res = await apiFetch(`/orders/erp-consulta${qs ? `?${qs}` : ''}`);
return PedidoErpConsultaResponseSchema.parse(res);
},
});
}

View File

@@ -0,0 +1,33 @@
import { useQuery } from '@tanstack/react-query';
import {
RegrasVigentesResponseSchema,
PromocoesVigentesResponseSchema,
type RegrasVigentesResponse,
type PromocoesVigentesResponse,
} 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.
export function useRegrasVigentes() {
return useQuery<RegrasVigentesResponse>({
queryKey: ['politicas', 'regras-vigentes'],
queryFn: async () => {
const raw = await apiFetch('/politicas/regras/vigentes');
return RegrasVigentesResponseSchema.parse(raw);
},
staleTime: 5 * 60 * 1000,
});
}
// Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep
export function usePromocoesVigentes() {
return useQuery<PromocoesVigentesResponse>({
queryKey: ['politicas', 'promocoes-vigentes'],
queryFn: async () => {
const raw = await apiFetch('/politicas/promocoes/vigentes');
return PromocoesVigentesResponseSchema.parse(raw);
},
staleTime: 5 * 60 * 1000,
});
}

View File

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

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.
* Defaults conservadores: refetch on focus desabilitado (Visual DNA: "sereno"),
* 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({
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: {
queries: {
staleTime: 30_000, // 30s — Socket.IO atualiza antes na maioria dos casos

View File

@@ -5,7 +5,7 @@ import {
Outlet,
notFound,
} from '@tanstack/react-router';
import { Typography } from 'antd';
import { Button, Result, Typography } from 'antd';
import { AppShell } from '../components/layout/AppShell';
import { RepPainel } from '../cockpits/rep/RepPainel';
import { ClientsPage } from '../cockpits/rep/ClientsPage';
@@ -13,10 +13,20 @@ import { ClientDetailPage } from '../cockpits/rep/ClientDetailPage';
import { OrdersPage } from '../cockpits/rep/OrdersPage';
import { OrderDetailPage } from '../cockpits/rep/OrderDetailPage';
import { OrderPrintPage } from '../cockpits/rep/OrderPrintPage';
import { NewClientPage } from '../cockpits/rep/NewClientPage';
import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
import { CatalogPage } from '../cockpits/rep/CatalogPage';
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
import { CarteirePage } from '../cockpits/rep/CarteirePage';
import { FunilPage } from '../cockpits/rep/FunilPage';
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
import { GerPainel } from '../cockpits/ger/GerPainel';
import { EquipePage } from '../cockpits/ger/EquipePage';
import { PoliticasPage } from '../cockpits/ger/PoliticasPage';
import { ChamadosPage } from '../cockpits/rep/ChamadosPage';
import { GerChamadosPage } from '../cockpits/ger/GerChamadosPage';
import { authStore } from './auth-store';
function getRoleFromToken(): string {
@@ -32,7 +42,26 @@ function getRoleFromToken(): string {
function HomeRoute() {
const role = getRoleFromToken();
return role === 'supervisor' || role === 'manager' ? <SupervisorPainel /> : <RepPainel />;
if (role === 'manager' || role === 'admin') return <GerPainel />;
if (role === 'supervisor') return <SupervisorPainel />;
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() {
@@ -55,6 +84,7 @@ const rootRoute = createRootRoute({
</AppShell>
),
notFoundComponent: NotFoundPage,
errorComponent: RouteErrorPage,
});
const indexRoute = createRoute({
@@ -75,6 +105,12 @@ const clientesRoute = createRoute({
component: ClientsPage,
});
const novoClienteRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/clientes/novo',
component: NewClientPage,
});
const clienteDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/clientes/$id',
@@ -111,29 +147,94 @@ const catalogoRoute = createRoute({
component: CatalogPage,
});
const pedidosErpRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/pedidos-erp',
component: OrdersErpPage,
});
const relatoriosRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/relatorios',
component: RelatoriosPage,
});
const carteiraRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/clientes/carteira',
component: CarteirePage,
});
const funilRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/funil',
component: FunilPage,
});
const aprovacoes = createRoute({
getParentRoute: () => rootRoute,
path: '/aprovacoes',
component: ApprovalQueuePage,
});
const gerPainelRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ger',
component: GerPainel,
});
const equipeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ger/equipe',
component: EquipePage,
});
const politicasRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ger/politicas',
component: PoliticasPage,
});
const chamadosRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/chamados',
component: ChamadosPage,
});
const gerChamadosRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ger/chamados',
component: GerChamadosPage,
});
const routeTree = rootRoute.addChildren([
indexRoute,
rafaelRoute,
clientesRoute,
novoClienteRoute,
clienteDetailRoute,
pedidosRoute,
novoOrderRoute,
pedidoDetailRoute,
pedidoPrintRoute,
catalogoRoute,
pedidosErpRoute,
relatoriosRoute,
carteiraRoute,
funilRoute,
aprovacoes,
gerPainelRoute,
equipeRoute,
politicasRoute,
chamadosRoute,
gerChamadosRoute,
]);
export const router = createRouter({
routeTree,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
defaultErrorComponent: RouteErrorPage,
});
declare module '@tanstack/react-router' {

View File

@@ -9,7 +9,8 @@ export default defineConfig(() => ({
cacheDir: '../../node_modules/.vite/apps/web',
server: {
port: 4200,
host: 'localhost',
host: '0.0.0.0',
allowedHosts: true,
// 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,
// mesmo origin via Nginx (/api/* → backend).
@@ -22,7 +23,7 @@ export default defineConfig(() => ({
},
preview: {
port: 4200,
host: 'localhost',
host: '0.0.0.0',
},
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
@@ -43,6 +44,7 @@ export default defineConfig(() => ({
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
passWithNoTests: true,
reporters: ['default'],
coverage: {
reportsDirectory: '../../coverage/apps/web',

View File

@@ -20,7 +20,6 @@ services:
# subdir por major-version pra suportar pg_upgrade --link sem
# boundary issues. Ref: docker-library/postgres#1259.
- sar-postgres-data:/var/lib/postgresql
- ./scripts/postgres-init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U sar -d sar_master']
interval: 10s

View File

@@ -0,0 +1,265 @@
# Plano de Migração — Modelo de Dados Canônico SAR
**Data:** 2026-06-22
**Autor:** Julian (Product Owner SAR)
**Status:** Proposta para avaliação
---
## 1. Contexto e Problema
### Situação atual
O SAR foi iniciado consultando diretamente as **views do ERP JCS** (`vw_clientes`, `vw_pedidos_erp`, `vw_produtos`, etc.) que existem dentro do banco de dados do cliente (`libreplast@192.168.0.43`). Isso funcionou para o cliente piloto, mas cria uma dependência estrutural que inviabiliza a expansão do produto.
### Problema identificado
| Sintoma | Consequência |
|---|---|
| SAR acessa views do ERP via SQL raw | Cada novo ERP exige reescrever queries |
| Schema `sar` vive *dentro* do banco ERP | Sem isolamento; SAR depende de acesso ao servidor do cliente |
| Views são específicas do ERP JCS | ERP diferente = produto diferente |
| Sem camada de abstração | Impossível oferecer o SAR como SaaS multi-tenant real |
### Diagnóstico técnico (levantamento 2026-06-22)
```
Módulos 100% acoplados ao ERP: auth, catalog, clients
Módulos parcialmente acoplados: orders (leitura), dashboard
Módulos já independentes: notifications, workspace
Views ERP consumidas: 11 views + 1 tabela direta
Tabelas próprias SAR existentes: 6 models Prisma
```
---
## 2. Solução Proposta
### Modelo Canônico + Connectors
```
┌─────────────────────────────────────────────────────┐
│ APP SAR │
│ (frontend + API NestJS) │
│ Lê APENAS tabelas próprias do SAR │
└──────────────────────┬──────────────────────────────┘
│ Prisma ORM
┌─────────────────────────────────────────────────────┐
│ Banco de Dados SAR │
│ (PostgreSQL isolado por workspace) │
│ tabelas canônicas: clientes, produtos, pedidos... │
└──────────┬──────────────────────┬───────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────────────────┐
│ Connector │ │ Connector │
│ ERP JCS │ │ ERP X (futuro) │
│ (PostgreSQL) │ │ (REST API / CSV / outro) │
└──────────────────┘ └──────────────────────────────┘
```
**Princípio:** O ERP é uma **fonte de dados**, não o sistema de registro do SAR. Cada cliente tem um connector específico para seu ERP. O SAR opera exclusivamente sobre seu próprio modelo de dados.
### O que não muda
Os dados transacionais do SAR já estão corretos e **permanecem sem alteração**:
- `pedidos`, `pedido_itens`, `historico_pedido`
- `alcada_desconto`, `meta_representante`
- `push_subscription`
---
## 3. Tabelas Canônicas a Criar
### 3.1 Entidades mestres (sincronizadas do ERP)
| Tabela SAR | Substitui | Campos principais |
|---|---|---|
| `sar.clientes` | `vw_clientes` | codigo, razao_social, nome_fantasia, cnpj, cidade, uf, ativo, representante_codigo |
| `sar.representantes` | `vw_representantes` | codigo, nome, email, senha_hash, tipo (rep/supervisor), ativo |
| `sar.produtos` | `vw_produtos` | codigo, descricao, unidade, ncm, ativo |
| `sar.estoques` | `vw_estoque` | produto_codigo, deposito, saldo (snapshot com timestamp) |
| `sar.pautas` | `vw_pautas` | codigo, descricao, ativa |
| `sar.pauta_produtos` | `vw_pauta_produtos` | pauta_codigo, produto_codigo, preco, desconto_max |
| `sar.formas_pagamento` | `vw_formas_pagamento` | codigo, descricao, parcelas, ativa |
| `sar.municipios` | `vw_municipios` | ibge, nome, uf |
| `sar.empresa` | `gestao.empresa` | cnpj, razao_social, nome_fantasia, endereco, logo_url |
### 3.2 Histórico ERP (read-only, sincronizado)
| Tabela SAR | Substitui | Propósito |
|---|---|---|
| `sar.pedidos_erp` | `vw_pedidos_erp` | Histórico de pedidos anteriores ao SAR |
| `sar.pedido_itens_erp` | `vw_peditens_erp` | Itens dos pedidos ERP |
### 3.3 Controle de sincronização
| Tabela SAR | Propósito |
|---|---|
| `sar.sync_log` | Registro de cada execução de sync (início, fim, erros, registros processados) |
| `sar.sync_config` | Configuração do connector por workspace (tipo ERP, credenciais, frequência) |
---
## 4. Plano de Execução por Fases
### Fase 0 — Banco de Dados Isolado `[Fundação]`
**Objetivo:** Separar o banco SAR do banco ERP do cliente.
**Ações:**
- Provisionar banco PostgreSQL exclusivo para o SAR (`sar_db`)
- Configurar variável `DATABASE_URL` apontando para o novo banco
- Manter `ERP_DB_*` apenas no connector (não no app principal)
- Estrutura multi-tenant: um schema por workspace ou banco por workspace (já previsto na stack)
**Resultado:** SAR pode funcionar mesmo sem acesso ao ERP; connector roda separado.
**Estimativa:** 23 dias
---
### Fase 1 — Schema Canônico `[Estrutura]`
**Objetivo:** Criar todos os models Prisma das tabelas canônicas.
**Ações:**
- Adicionar 11 models ao `schema.prisma` (seção 3.1 e 3.2)
- Adicionar 2 models de controle de sync (seção 3.3)
- Gerar e aplicar migrations
- Nenhuma mudança no código da API ainda
**Resultado:** Tabelas existem no banco, prontas para receber dados.
**Estimativa:** 34 dias
---
### Fase 2 — Sync Service (Connector ERP JCS) `[Motor]`
**Objetivo:** Criar a camada que lê o ERP e popula as tabelas canônicas.
**Ações:**
- Criar módulo `SyncModule` no NestJS
- Implementar `ErpJcsConnector` com leitura das 11 views existentes
- Sync completo na inicialização do workspace
- Sync incremental por cron (ex: a cada 5 minutos para estoque, 30 min para cadastros)
- Registrar execuções em `sync_log`
- Endpoint admin `/api/v1/sync/trigger` para forçar sync manual
**Estratégia de sync:**
```
┌────────────────────────────────────────────────────┐
│ Frequências recomendadas │
│ │
│ A cada 5 min: estoques (alta volatilidade) │
│ A cada 30 min: produtos, pautas, preços │
│ A cada 60 min: clientes, representantes │
│ A cada 6h: pedidos_erp (histórico) │
│ 1x por dia: municipios, empresa, formas_pgto │
└────────────────────────────────────────────────────┘
```
**Resultado:** Tabelas SAR ficam populadas e atualizadas automaticamente.
**Estimativa:** 58 dias
---
### Fase 3 — Migração dos Módulos `[Execução]`
**Objetivo:** Substituir todos os `$queryRaw` por queries Prisma nas tabelas canônicas.
**Prioridade e ordem:**
| Ordem | Módulo | Complexidade | Motivo da prioridade |
|---|---|---|---|
| 1 | `catalog` | Baixa | Somente leitura, sem lógica de negócio complexa |
| 2 | `clients` | Média | Base do dashboard e pedidos |
| 3 | `auth` | Baixa | Bem delimitado; lê apenas `representantes` |
| 4 | `dashboard` | Média | Depende de clients migrado |
| 5 | `orders` (leitura) | Média | Parte escrita já usa Prisma corretamente |
**Processo por módulo:**
1. Com o sync rodando, os dados já estão nas tabelas canônicas
2. Substituir `$queryRaw` por `prisma.cliente.findMany(...)` etc.
3. Manter o contrato de resposta da API (frontend não muda)
4. Teste comparativo: mesmo resultado via ERP vs. via tabela SAR
5. Remover a query antiga após validação
**Resultado:** Zero dependência de views ERP no código da API.
**Estimativa:** 58 dias
---
### Fase 4 — Segundo Connector (Validação da Abstração) `[Prova]`
**Objetivo:** Onboarding de um cliente com ERP diferente do JCS.
**Ações:**
- Identificar segundo ERP (ex: TOTVS, Sankhya, outro)
- Criar `ErpXConnector` implementando a mesma interface do `ErpJcsConnector`
- Mapear campos do ERP X para o schema canônico SAR
- Sem nenhuma mudança no app SAR
**Resultado:** Prova de conceito real de que a arquitetura é ERP-agnóstica.
**Estimativa:** 46 dias (variável conforme ERP)
---
## 5. Cronograma Estimado
```
Semana 1: Fase 0 (banco isolado) + Fase 1 (schema Prisma)
Semana 2: Fase 2 (Sync Service — maior esforço)
Semana 3: Fase 3 (migração dos módulos)
Semana 4: Fase 4 (segundo connector) + testes + documentação
Total estimado: 4 semanas (1925 dias úteis)
```
---
## 6. Riscos e Mitigações
| Risco | Probabilidade | Impacto | Mitigação |
|---|---|---|---|
| Divergência de dados entre ERP e tabela SAR durante sync | Médio | Alto | Sync comparativo com log de diferenças; alertas |
| Latência do sync afeta experiência do rep | Baixo | Médio | Sync de estoque a cada 5 min é suficiente para força de vendas |
| ERP do cliente sem conectividade temporária | Médio | Baixo | SAR continua funcionando com dados do último sync |
| Campos do segundo ERP sem equivalência no schema canônico | Médio | Médio | Schema canônico flexível com campo `metadata jsonb` por entidade |
| Custo de manutenção de dois bancos | Baixo | Baixo | Banco SAR é pequeno; ERP continua como está |
---
## 7. Benefícios Esperados
| Benefício | Impacto |
|---|---|
| **Multi-ERP real** | Onboarding de qualquer cliente independente do ERP |
| **Isolamento de falhas** | Queda do ERP não derruba o SAR |
| **Performance** | Queries no banco SAR são mais rápidas (schema otimizado) |
| **Funciona offline** | Rep consulta dados mesmo sem VPN/internet no cliente |
| **Multi-tenant limpo** | Banco SAR separado por workspace desde o início |
| **Evolução independente** | SAR pode adicionar campos sem depender do ERP |
---
## 8. Decisão
O projeto SAR **não precisa ser refeito do zero**. A infraestrutura (NestJS, autenticação, frontend React, ciclo de pedidos, alçadas, metas, push notifications) está correta e é reaproveitada integralmente.
A mudança é **cirúrgica e incremental**:
- Adicionar banco e tabelas canônicas (Fases 0 e 1)
- Construir o motor de sync (Fase 2)
- Substituir queries raw módulo a módulo (Fase 3)
O risco de **não fazer** essa migração agora é maior: quanto mais o produto crescer sobre as views do ERP JCS, mais caro fica refatorar depois.
---
*Documento gerado em 2026-06-22. Para dúvidas: jcsinfo@gmail.com*

View File

@@ -6,3 +6,8 @@ export * from './lib/product.contract';
export * from './lib/dashboard.contract';
export * from './lib/notifications.contract';
export * from './lib/company.contract';
export * from './lib/report.contract';
export * from './lib/politicas.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({
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,
});

View File

@@ -62,3 +62,148 @@ export const ClientListResponseSchema = z.object({
limit: z.number().int().positive(),
});
export type ClientListResponse = z.infer<typeof ClientListResponseSchema>;
// ─── Contato ─────────────────────────────────────────────────────────────────
export const ContatoSchema = z.object({
idContato: z.number().int(),
idEmpresa: z.number().int(),
idCorrent: z.number().int(),
idChatwoot: z.number().int().nullable(),
nome: z.string(),
empresa: z.string().nullable(),
cargo: z.string().nullable(),
departamento: z.string().nullable(),
dtAniversario: z.string().nullable(),
telefone: z.string().nullable(),
ramal: z.string().nullable(),
celular: z.string().nullable(),
whatsapp: z.string().nullable(),
email: z.string().nullable(),
ativo: z.number().int(),
anotacoes: z.string().nullable(),
nomeCliente: z.string().nullable(),
razaoCliente: z.string().nullable(),
codVendedor: z.number().int().nullable(),
});
export type Contato = z.infer<typeof ContatoSchema>;
export const CreateContatoSchema = z.object({
nome: z.string().min(1).max(100),
empresa: z.string().max(100).optional(),
cargo: z.string().max(100).optional(),
departamento: z.string().max(100).optional(),
dtAniversario: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Data deve ser YYYY-MM-DD')
.optional(),
telefone: z.string().max(20).optional(),
ramal: z.string().max(10).optional(),
celular: z.string().max(20).optional(),
whatsapp: z.string().max(20).optional(),
email: z.string().max(150).optional(),
anotacoes: z.string().optional(),
});
export type CreateContato = z.infer<typeof CreateContatoSchema>;
export const ContatoResultSchema = z.object({
id: z.string().uuid(),
idContatoErp: z.number().int().nullable(),
sincronizado: z.boolean(),
erroSync: z.string().nullable(),
});
export type ContatoResult = z.infer<typeof ContatoResultSchema>;
// ─── Produtos mais comprados por cliente ─────────────────────────────────────
export const TopProdutoSchema = z.object({
idProduto: z.number().int(),
codProduto: z.string(),
descricao: z.string(),
unidade: z.string().nullable(),
qtdTotal: z.number(),
numPedidos: z.number().int(),
ultimaCompra: z.string(),
ultimoPreco: z.number(),
});
export type TopProduto = z.infer<typeof TopProdutoSchema>;
// ─── Contas a Receber (resumo por cliente) ───────────────────────────────────
export const CtrSummarySchema = z.object({
qtdAberto: z.number().int(),
totalAberto: z.number(),
qtdVencido: z.number().int(),
totalVencido: z.number(),
maiorAtrasoDias: z.number().int(),
});
export type CtrSummary = z.infer<typeof CtrSummarySchema>;
// ─── Contas a Receber (lista de títulos em aberto) ────────────────────────────
export const CtrTituloSchema = z.object({
idCtr: z.number().int(),
numero: z.string(),
prefixo: z.string(),
dtEmissao: z.string(),
dtVencimento: z.string(),
saldo: z.number(),
});
export type CtrTitulo = z.infer<typeof CtrTituloSchema>;
// ─── Notas Fiscais por cliente ───────────────────────────────────────────────
export const NotaFiscalSchema = z.object({
idNf: z.number().int(),
numero: z.number().int(),
serie: z.string(),
dtEmissao: z.string(),
vlNf: z.number(),
chaveAcessoNfe: z.string().nullable(),
});
export type NotaFiscal = z.infer<typeof NotaFiscalSchema>;
// ─── Municipio ───────────────────────────────────────────────────────────────
export const MunicipioSchema = z.object({
idMunicipio: z.number().int(),
nome: z.string(),
uf: z.string(),
codigoIbge: z.number().int().nullable(),
});
export type Municipio = z.infer<typeof MunicipioSchema>;
// ─── Criar Cliente Novo ───────────────────────────────────────────────────────
// pesso: 0=PJ (CNPJ), 1=PF (CPF) — campo do ERP, não renomear
// indicadorIe: 1=contribuinte, 2=isento, 9=não contribuinte
export const CreateClientNovoSchema = z.object({
pesso: z.union([z.literal(0), z.literal(1)]),
consfinal: z.union([z.literal(0), z.literal(1)]).default(0),
nome: z.string().min(1).max(40),
razao: z.string().min(1).max(65),
cgcpf: z.string().max(18).optional(),
sufCgcpf: z.string().max(3).default(''),
inscr: z.string().max(18).default(''),
indicadorIe: z.union([z.literal(1), z.literal(2), z.literal(9)]).optional(),
endereco: z.string().max(60).default(''),
numEndereco: z.string().max(10).default(''),
bairro: z.string().max(60).default(''),
idMunicipio: z.number().int().positive(),
cep: z.string().max(9).default(''),
ddd: z.string().max(4).default(''),
telefone: z.string().max(35).default(''),
email: z.string().max(80).default(''),
limiteCredito: z.number().nonnegative().default(0),
codFormapag: z.number().int().optional(),
codPauta: z.number().int().optional(),
});
export type CreateClientNovo = z.infer<typeof CreateClientNovoSchema>;
export const ClientNovoResultSchema = z.object({
id: z.string().uuid(),
idCorrentErp: z.number().int().nullable(),
sincronizado: z.boolean(),
erroSync: z.string().nullable(),
});
export type ClientNovoResult = z.infer<typeof ClientNovoResultSchema>;

View File

@@ -3,13 +3,16 @@ import { PedidoSummarySchema } from './order.contract';
// ADR 0006 revogado: OrderSummary → PedidoSummary, ids numéricos.
export const ClienteInativoSchema = z.object({
export const ClienteNaoPositivadoSchema = z.object({
idCliente: z.number().int(),
nome: z.string(),
diasSemCompra: z.number().int(),
ultimaCompraValor: z.string().nullable(),
razao: z.string().nullable(),
diasSemPedido: z.number().int(), // 999 = nunca comprou
ultimoPedido: z.string().nullable(), // ISO date YYYY-MM-DD
comprouAntes: z.boolean(),
whatsapp: z.string().nullable(),
});
export type ClienteInativo = z.infer<typeof ClienteInativoSchema>;
export type ClienteNaoPositivado = z.infer<typeof ClienteNaoPositivadoSchema>;
// Dimensão de meta. O ERP (vw_metas.tipo) define como o cliente acompanha metas:
// GL = global, GR = por grupo. Motor único; outras dimensões (marca/subgrupo/
@@ -22,7 +25,7 @@ export type MetaDimensao = z.infer<typeof MetaDimensaoSchema>;
export const MetaItemSchema = z.object({
codigo: z.number().int().nullable(),
rotulo: z.string(),
pedidos: z.number().int(), // qtd de pedidos faturados no grupo (realizado)
pedidos: z.number().int(),
valorMeta: z.number(),
valorReal: z.number(),
qtdMeta: z.number(),
@@ -31,11 +34,19 @@ export const MetaItemSchema = z.object({
pesoReal: z.number(),
fatorMeta: z.number(),
fatorReal: z.number(),
pct: z.number(), // % de valor (real/meta) — base da barra de progresso
falta: z.number(), // valor faltante p/ a meta
pct: z.number(),
falta: z.number(),
});
export type MetaItem = z.infer<typeof MetaItemSchema>;
export const MesAnoSchema = z.object({
mes: z.number().int().min(1).max(12),
valor: z.number(),
meta: z.number(),
atingiu: z.boolean(),
});
export type MesAno = z.infer<typeof MesAnoSchema>;
export const RepDashboardSchema = z.object({
meta: z.object({
atingido: z.number(),
@@ -43,17 +54,13 @@ export const RepDashboardSchema = z.object({
pct: z.number(),
falta: z.number(),
}),
// Dimensão detectada do ERP e detalhamento por grupo (vazio quando global).
metaDimensao: MetaDimensaoSchema.default('global'),
metasPorGrupo: z.array(MetaItemSchema).default([]),
comissao: z.object({
fixa: z.number(),
flex: z.number(),
total: z.number(),
}),
pedidosMes: z.number().int(),
pedidosRecentes: z.array(PedidoSummarySchema),
clientesInativos: z.array(ClienteInativoSchema),
naoPositivados: z.array(ClienteNaoPositivadoSchema),
totalNaoPositivados: z.number().int(),
historicoMensal: z.array(MesAnoSchema).default([]),
syncedAt: z.iso.datetime(),
});
export type RepDashboard = z.infer<typeof RepDashboardSchema>;
@@ -77,3 +84,44 @@ export const SupervisorDashboardSchema = z.object({
syncedAt: z.iso.datetime(),
});
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;
export const PositivacaoRepSchema = z.object({
codVendedor: z.number().int(),
nomeVendedor: z.string().nullable(),
totalClientes: z.number().int(),
clientesPositivados: z.number().int(),
pctPositivacao: z.number(),
});
export type PositivacaoRep = z.infer<typeof PositivacaoRepSchema>;
export const RankingRepSchema = z.object({
codVendedor: z.number().int(),
nomeVendedor: z.string().nullable(),
pedidos: z.number().int(),
clientesAtendidos: z.number().int(),
faturamento: z.number(),
pctMeta: z.number(),
});
export type RankingRep = z.infer<typeof RankingRepSchema>;
// 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>;
export const ManagerDashboardSchema = z.object({
faturamentoMes: z.number(),
pedidosMes: z.number().int(),
ticketMedio: z.number(),
totalReps: z.number().int(),
promocoesAtivas: z.number().int(),
metaTotal: z.number(),
metasPorGrupo: z.array(MetaItemSchema).default([]),
rankingReps: z.array(RankingRepSchema),
positivacaoReps: z.array(PositivacaoRepSchema),
positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]),
syncedAt: z.iso.datetime(),
});
export type ManagerDashboard = z.infer<typeof ManagerDashboardSchema>;

View File

@@ -0,0 +1,18 @@
import { z } from 'zod';
export const RepStatsSchema = z.object({
codVendedor: z.number().int(),
nomeVendedor: z.string().nullable(),
pedidosMes: z.number().int(),
faturamentoMes: z.number(),
ticketMedio: z.number(),
pctMeta: z.number(),
});
export type RepStats = z.infer<typeof RepStatsSchema>;
export const EquipeResponseSchema = z.object({
reps: z.array(RepStatsSchema),
totalReps: z.number().int(),
syncedAt: z.iso.datetime(),
});
export type EquipeResponse = z.infer<typeof EquipeResponseSchema>;

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 const UnsubscribePayloadSchema = z.object({
endpoint: z.string().url(),
});
export type UnsubscribePayload = z.infer<typeof UnsubscribePayloadSchema>;
export const PendingCountResponseSchema = z.object({
count: z.number().int().min(0),
});

View File

@@ -31,7 +31,7 @@ export const SITUA_LABEL: Record<number, string> = {
// ─── PedidoItem ───────────────────────────────────────────────────────────────
export const PedidoItemSchema = z.object({
id: z.string().uuid(),
id: z.string(), // UUID para pedidos SAR; '{idPedido}-{ordem}' para ERP
idProduto: z.number().int(),
codProduto: z.string().nullable(),
descProduto: z.string().nullable(),
@@ -87,6 +87,8 @@ export const PedidoDetailSchema = PedidoSummarySchema.extend({
acrescimo: z.string(),
comissao: z.string(),
pedFlex: z.string(),
formaPagamento: z.string().nullable().optional(),
endEntrega: z.string().nullable().optional(),
aprovadoPor: z.number().int().nullable(),
aprovadoEm: z.iso.datetime().nullable(),
motivoRecusa: z.string().nullable(),
@@ -99,12 +101,16 @@ export type PedidoDetail = z.infer<typeof PedidoDetailSchema>;
// ─── 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({
idCliente: z.coerce.number().int().optional(),
situa: z.coerce.number().int().optional(),
numPedSar: z.string().optional(),
from: z.string().optional(),
to: z.string().optional(),
from: DateOnlySchema.optional(),
to: DateOnlySchema.optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
@@ -137,6 +143,7 @@ export const CreatePedidoSchema = z.object({
idPauta: z.number().int().optional(),
codFormapag: z.number().int().optional(),
obs: z.string().optional(),
endEntrega: z.string().optional(),
idempotencyKey: z.string().optional(),
itens: z.array(CreatePedidoItemSchema).min(1),
});
@@ -152,3 +159,46 @@ export const RecusarPedidoSchema = z.object({
motivo: z.string().min(1),
});
export type RecusarPedido = z.infer<typeof RecusarPedidoSchema>;
// ─── ERP Consulta ─────────────────────────────────────────────────────────────
export const PedidoErpConsultaItemSchema = z.object({
numeroPedido: z.number().int(),
data: z.string(),
dtPrevent: z.string().nullable(),
dataEmissao: z.string().nullable(),
situa: z.number().int(),
statusDescr: z.string().nullable(),
idCliente: z.number().int(),
nomeCliente: z.string().nullable(),
razaoCliente: z.string().nullable(),
codVendedor: z.number().int(),
formaPagamento: z.string().nullable(),
total: z.string(),
obs: z.string().nullable(),
tipo: z.enum(['P', 'E']),
numPedSar: z.string().nullable(),
numeroEntrega: z.number().int().nullable(),
numeroNf: z.number().int().nullable(),
chaveAcessoNfe: z.string().nullable(),
sitPed: z.number().int().nullable(),
idPedido: z.number().int().nullable(),
});
export type PedidoErpConsultaItem = z.infer<typeof PedidoErpConsultaItemSchema>;
export const PedidoErpConsultaQuerySchema = z.object({
search: z.string().optional(),
from: DateOnlySchema.optional(),
to: DateOnlySchema.optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
export type PedidoErpConsultaQuery = z.infer<typeof PedidoErpConsultaQuerySchema>;
export const PedidoErpConsultaResponseSchema = z.object({
data: z.array(PedidoErpConsultaItemSchema),
total: z.number().int().nonnegative(),
page: z.number().int().positive(),
limit: z.number().int().positive(),
});
export type PedidoErpConsultaResponse = z.infer<typeof PedidoErpConsultaResponseSchema>;

View File

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

View File

@@ -0,0 +1,144 @@
import { z } from 'zod';
export const AlcadaDescontoItemSchema = z.object({
codVendedor: z.number().int(),
codGrupo: z.number().int(),
limitePerc: z.number(),
nomeVendedor: z.string().nullable(),
});
export type AlcadaDescontoItem = z.infer<typeof AlcadaDescontoItemSchema>;
export const AlcadaDescontosResponseSchema = z.object({
descontos: z.array(AlcadaDescontoItemSchema),
});
export type AlcadaDescontosResponse = z.infer<typeof AlcadaDescontosResponseSchema>;
export const UpsertDescontoBodySchema = z.object({
codVendedor: z.number().int().positive(),
codGrupo: z.number().int().default(0),
limitePerc: z.number().min(0).max(100),
});
export type UpsertDescontoBody = z.infer<typeof UpsertDescontoBodySchema>;
export const PromocaoSchema = z.object({
id: z.number().int(),
descricao: z.string(),
codProduto: z.string().nullable(),
grpProd: z.string().nullable(),
descPct: z.number(),
dataInicio: z.string(),
dataFim: z.string(),
ativa: z.boolean(),
createdAt: z.string(),
createdBy: z.string(),
});
export type Promocao = z.infer<typeof PromocaoSchema>;
export const PromocoesResponseSchema = z.object({
promocoes: z.array(PromocaoSchema),
});
export type PromocoesResponse = z.infer<typeof PromocoesResponseSchema>;
export const CreatePromocaoBodySchema = z.object({
descricao: z.string().min(1).max(255),
codProduto: z.string().optional(),
grpProd: z.string().optional(),
descPct: z.number().min(0).max(100),
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
dataFim: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
});
export type CreatePromocaoBody = z.infer<typeof CreatePromocaoBodySchema>;
export const UpdatePromocaoBodySchema = CreatePromocaoBodySchema.partial().extend({
ativa: z.boolean().optional(),
});
export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>;
// ─── Regras de Desconto ───────────────────────────────────────────────────────
// Regra criada pelo Gerente: % de desconto liberado por grupo e/ou subgrupo de
// produto e/ou representante, com vigência. Campos nulos = "todos".
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(),
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>;
export const CreateRegraDescontoBodySchema = z.object({
descricao: z.string().min(1).max(200),
codGrupo: z.number().int().nullable().optional(),
codSubgrupo: z.number().int().nullable().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>;
export const UpdateRegraDescontoBodySchema = CreateRegraDescontoBodySchema.partial().extend({
ativa: z.boolean().optional(),
});
export type UpdateRegraDescontoBody = z.infer<typeof UpdateRegraDescontoBodySchema>;
// 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(),
grpProd: 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(),
codSubgrupo: z.number().int().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

@@ -20,6 +20,7 @@ export const ProdutoSummarySchema = z.object({
ativo: z.number().int(),
qtdEstoque: z.string().nullable(),
listaParauta: z.number().int().nullable(),
loteMulVenda: z.number().int().nullable(),
});
export const PautaSchema = z.object({
@@ -42,6 +43,14 @@ export type ProdutoSummary = z.infer<typeof ProdutoSummarySchema>;
// ─── Produto Detail ───────────────────────────────────────────────────────────
export const PautaPrecoSchema = z.object({
idPauta: z.number().int(),
codigo: z.number().int(),
descricao: z.string(),
preco: z.string(),
});
export type PautaPreco = z.infer<typeof PautaPrecoSchema>;
export const ProdutoDetailSchema = ProdutoSummarySchema.extend({
referencia: z.string().nullable(),
descricaoDetalhada: z.string().nullable(),
@@ -50,9 +59,9 @@ export const ProdutoDetailSchema = ProdutoSummarySchema.extend({
aliqIpi: z.string().nullable(),
pesoLiquido: z.string().nullable(),
qtdVolume: z.string().nullable(),
loteMulVenda: z.number().int().nullable(),
precoComIpi: z.string().nullable(),
precoPromocional: z.string().nullable(),
pautaPrecos: z.array(PautaPrecoSchema),
});
export type ProdutoDetail = z.infer<typeof ProdutoDetailSchema>;

View File

@@ -0,0 +1,73 @@
import { z } from 'zod';
// ─── Desempenho vs. Meta ─────────────────────────────────────────────────────
export const ReportMetaQuerySchema = z.object({
mes: z.coerce.number().int().min(1).max(12),
ano: z.coerce.number().int().min(2000).max(2100),
});
export type ReportMetaQuery = z.infer<typeof ReportMetaQuerySchema>;
export const ReportMetaResponseSchema = z.object({
mes: z.number(),
ano: z.number(),
realizado: z.string(),
meta: z.string(),
percentual: z.string(),
comissaoEstimada: z.string(),
totalPedidos: z.number(),
});
export type ReportMetaResponse = z.infer<typeof ReportMetaResponseSchema>;
// ─── Carteira de Clientes ────────────────────────────────────────────────────
export const CarteiraClienteSchema = z.object({
idCliente: z.number(),
nome: z.string().nullable(),
razao: z.string().nullable(),
ativo: z.number(),
ultimoPedido: z.string().nullable(),
diasSemPedido: z.number().nullable(),
totalPedidos: z.number(),
ticketMedio: z.string(),
faturamentoTotal: z.string(),
participacaoPct: z.number(),
});
export type CarteiraCliente = z.infer<typeof CarteiraClienteSchema>;
export const ReportCarteiraResponseSchema = z.object({
data: z.array(CarteiraClienteSchema),
total: z.number(),
inativos30: z.number(),
inativos60: z.number(),
semPedido: z.number(),
faturamentoRepTotal: z.string(),
});
export type ReportCarteiraResponse = z.infer<typeof ReportCarteiraResponseSchema>;
// ─── Curva ABC de Produtos ───────────────────────────────────────────────────
export const ReportAbcQuerySchema = z.object({
mes: z.coerce.number().int().min(1).max(12).optional(),
ano: z.coerce.number().int().min(2000).max(2100).optional(),
});
export type ReportAbcQuery = z.infer<typeof ReportAbcQuerySchema>;
export const AbcProdutoSchema = z.object({
codProduto: z.string().nullable(),
descProduto: z.string(),
totalFaturado: z.string(),
qtdVendida: z.string(),
participacao: z.string(),
curva: z.enum(['A', 'B', 'C']),
descontoMedio: z.string(),
comissaoTotal: z.string(),
});
export type AbcProduto = z.infer<typeof AbcProdutoSchema>;
export const ReportAbcResponseSchema = z.object({
data: z.array(AbcProdutoSchema),
totalFaturado: z.string(),
periodo: z.string(),
});
export type ReportAbcResponse = z.infer<typeof ReportAbcResponseSchema>;

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",
"dev:api": "nx run api: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:down": "docker compose -f docker-compose.dev.yml down",
"dev:logs": "docker compose -f docker-compose.dev.yml logs -f",
@@ -110,7 +110,7 @@
"lru-cache": "^11.5.0",
"nestjs-cls": "^5.4.3",
"nestjs-pino": "^4.6.1",
"nestjs-zod": "^4.3.1",
"nestjs-zod": "^5.4.0",
"pg": "^8.21.0",
"pino": "^9.14.0",
"pino-http": "^10.5.0",

Some files were not shown because too many files have changed in this diff Show More