Compare commits

..

63 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
a3c68f9f05 feat(mvp-rep): formas de pagamento do ERP + suporte offline completo
Formas de pagamento:
- Endpoint GET /catalog/payment-methods lendo vw_formas_pagamento
  filtrado por ativa=1 e integrar_sar=1
- FormaPagamento schema/type no shared api-interface
- Hook useFormasPagamento (staleTime 1h) substituindo lista hardcoded

Offline (FR-4.2 / NFR-2.1–2.4):
- IndexedDB queue: lib/offline/idb.ts + order-queue.ts sem deps externos
- NewOrderPage detecta !navigator.onLine → enqueueOrder() → toast + reset
- useOfflineSync: auto-sync ao reconectar (POST orders + PATCH transmit)
- usePendingOrders: fila reativa via CustomEvents
- AppShell: banner offline + useOfflineSync() global
- OrdersPage: seção de pedidos pendentes com retry/descartar
- sw.js: network-first para API GETs cacheáveis + stale-while-revalidate
  para assets + app shell navigate fallback

Docs:
- architecture.md: documento de decisões de arquitetura do SAR MVP

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 21:30:23 +00:00
1647871a39 feat(web+api): redesign ClientsPage/OrdersPage e corrige dados empresa 9001
Web — ClientsPage:
- Redesign completo: métricas reais via usePortfolioStats (4 queries count),
  donut Chart.js com totais reais, tabela sem ellipsis, coluna Cliente com
  nome fantasia/razão/CNPJ completos, drawer de detalhes e análise comercial,
  cards mobile, filtros de status/busca em tempo real.
- Dados reais: substitui mock por useClientList/useClientDetail/useClientOrders;
  remove tipos fictícios (prospect/lead, cidade, totalComprado).

Web — OrdersPage:
- Métricas reais via useOrderStats (contagens por situa, não da página atual).
- Coluna Cliente sem truncamento (minWidth: 240).
- Cabeçalho, filtros e layout alinhados ao padrão da ClientsPage.

API — orders.service.ts:
- Normalização situa SIG→SAR: SIG usa 5=Cancelado; SAR usa 3=Cancelado.
  sigToSar(5→3) no mapper; sarToSig(3→5) no filtro SQL.

API — clients.service.ts:
- dt_ultima_compra corrigida: JOIN duplo (vw_pedidos_erp + sar.pedidos) com
  GREATEST() — clientes com histórico ERP mas sem pedido SAR deixam de
  aparecer todos como Inativo.
- Filtro de activityStatus movido para SQL — total e paginação corretos.
- findOne() atualizado com o mesmo JOIN duplo.

Infra — .env:
- DEV_EMPRESA_ID: 1 → 9001 — API aponta para dados reais da empresa SIG.
  Ex: pedido nº 141022 passa de R$1.765,48 para R$2.454,90.

Docs — sarweb_views.sql:
- Documenta as views reais em schema sar; remove schema sarweb inexistente.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 14:08:56 +00:00
70d5a2d1e4 fix(web): após finalizar pedido redireciona para lista de pedidos
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 19:05:13 +00:00
2d4f342697 feat(orders): nome do cliente na lista de pedidos via LEFT JOIN vw_clientes
- JOIN de vw_pedidos_erp com vw_clientes pelo id_cliente + id_empresa
- Campos nomeCliente e razaoCliente adicionados ao PedidoSummary (contrato)
- Tabela, cards mobile e drawer exibem razão social (fallback para nome)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 19:00:22 +00:00
fb6df551b7 feat(web): redesign NewOrderPage e OrdersPage + botão Novo Pedido global
NewOrderPage:
- Layout de página única com cards (remove wizard em steps)
- AutoComplete de cliente com busca na API
- Badge de confirmação ao selecionar cliente
- Select de Pauta (API real) e Condição de Pagamento (mock)
- Campos Contato e Nº OC
- AutoComplete de produto por catálogo com pauta aplicada
- Soma qty automaticamente se produto já está no carrinho
- Tabela de itens com qty/desconto editáveis inline
- Rodapé fixo com total e botão Finalizar verde

OrdersPage:
- Cards de métricas (total, vendido, pendentes, aprovados, ticket médio)
- Filtros por status e período (hoje / 7d / 30d)
- Tabela com row-click colorido por status
- Drawer lateral com detalhes, itens e timeline de histórico
- Menu de ações por linha (ver, duplicar, PDF, cancelar)
- Cards mobile responsivos

Layout global:
- Botão Novo Pedido na Topbar (sempre visível)
- FAB verde fixo (bottom-right) no AppShell

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 18:48:01 +00:00
7fad03475e fix(web): corrige link Catálogo na sidebar — /produtos → /catalogo
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 18:13:05 +00:00
a00a5c6a53 feat(auth): endpoint /auth/me, cockpits renomeados e menu de logout
- GET /api/v1/auth/me retorna perfil real do ERP (vw_representantes)
- Contrato UserProfile adicionado ao shared api-interface
- Hook useCurrentUser() no frontend consome o endpoint
- Cockpit rafael → rep, sandra → supervisor (pastas e componentes)
- Topbar exibe iniciais do usuário e dropdown com nome, role e "Sair"
- Logout limpa token e recarrega para voltar ao DevLogin

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 17:48:24 +00:00
20b0793227 fix(web): substitui Space direction por orientation em todos os arquivos
AntD 6 deprecou direction em favor de orientation.
14 ocorrências em ClientsPage, NewOrderPage, RafaelPainel e SandraPainel.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 15:00:02 +00:00
f9d5f8a84c fix(web): corrige warnings do console — AntD deprecations, keys duplicadas, 404
- DevLogin: Space orientation (era direction), Alert title (era message),
  keys únicas por role (rep-29/sup-29/mgr-29), loading key alinhado
- router: notFoundComponent configurado — elimina aviso do TanStack Router

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 14:58:02 +00:00
e7cbadcf7e feat(catalog): filtro de preço e seletor de pauta
- Catálogo só mostra produtos com preço preenchido (vl_preco1 > 0) por default
- Novo endpoint GET /catalog/pautas — retorna as 6 pautas do representante logado
- GET /catalog?idPauta=N — usa preço da pauta selecionada (vw_pauta_produtos)
- CatalogPage: dropdown "Selecionar pauta de preços" com as pautas do rep
- product.contract: adiciona PautaSchema e idPauta no ProdutoListQuerySchema

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 14:55:18 +00:00
1f8a9d872a feat(web): clientes e catálogo funcionando com dados do ERP
- clients.ts, catalog.ts: corrige bug res.ok/res.json() — apiFetch retorna JSON direto
- catalog.service.ts: corrige nomes de coluna da vw_produtos (descr_det, lista_pauta,
  remove preco_com_ipi inexistente)
- CatalogPage.tsx: nova tela — código, descrição, grupo, marca, preço, estoque
- router.tsx: adiciona rota /catalogo

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 14:46:25 +00:00
24408ecd83 feat(erp): pedidos e dashboard leem histórico do ERP (vw_pedidos_erp)
- OrdersService.list: substitui sar.pedidos por vw_pedidos_erp — 44k pedidos
  históricos do rep 29 visíveis; sar.pedidos continua sendo a tabela de escrita
  para novos pedidos SAR que serão integrados ao ERP
- DashboardService: atingido/pedidosMes/recentes/inativos todos via vw_pedidos_erp;
  supervisor usa vw_pedidos_erp para pedidosDia
- PedidoSummarySchema: id relaxado de uuid() para string(); adiciona numero,
  statusDescr e fonte ('sar'|'erp')
- orders.ts: corrige bug — apiFetch retorna JSON diretamente, não Response;
  remove res.ok/res.json() incorretos
- OrdersPage: coluna Nº mostra numero do ERP; statusDescr no badge
- DevLogin: atualiza para PAVEI COMERCIO cod 29

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 14:30:51 +00:00
2abe5e8697 feat(infra): conecta ao banco ERP libreplast e fixa rep 29 como usuário dev
- sar-erp-schema.sql: corrige grupo.nome (era descricao), tp_pauta inexistente
  em pauxpro, COALESCE(id_empresa,1) em vw_clientes para bancos single-tenant,
  e nome do cliente via COALESCE(NULLIF(TRIM(nome),''), TRIM(razao))
- WorkspacePrismaPool: PrismaPg({ schema: 'sar' }) + options search_path=sar
  para ORM e queries raw funcionarem no schema correto
- JwtAuthGuard: força DEV_REP_CODE/DEV_EMPRESA_ID em não-prod — filtro
  global sem tocar em nenhum service
- env.schema: adiciona DEV_REP_CODE e DEV_EMPRESA_ID com defaults 29 e 1

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 14:07:04 +00:00
f41d9c2f16 feat(api): dashboard lê meta e comissão diretamente do ERP
meta geral do mês (tipo='G'): vw_metas WHERE TRIM(tipo)='G'
taxa de comissão: vw_representantes.taxa_com
flex: vw_representantes.permitir_flex + sar.meta_representante.taxaFlex
fix: query inativos_por_rep corrigida — subconsulta por cliente, outer por rep

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 11:55:21 +00:00
6cdb4c578e feat(infra): adiciona vw_metas ao schema SAR — metas do ERP (gestao.metavenda)
View sar.vw_metas expõe gestao.metavenda com joins descritivos:
- nome_vendedor (gestao.vendedor)
- desc_grupo / desc_subgrupo (gestao.grupo)
- nome_marca (gestao.marca)
- Campos calculados: ano e mes extraídos de mes_ano (date)

Campo tipo (char 2) controla o escopo da meta:
  G/GE=geral, GR=grupo, SG=subgrupo, MA=marca, PR=produto, AC=classe ABC

DashboardService usará tipo='G' (ou equivalente) para calcular %
atingido vs meta; os demais tipos ficam disponíveis para detalhamento
futuro. Taxas de comissão/flex vêm de vw_representantes (taxa_com),
não da tabela sar.meta_representante (que guarda apenas overrides SAR).

Renumera seções 4-16 → 5-17 para acomodar vw_metas como seção 4.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 11:45:55 +00:00
f363d22d90 feat(infra): adiciona vw_sarcfg ao schema SAR — regras de negócio por empresa
View sar.vw_sarcfg lê gestao.sarcfg + gestao.sarconfig e expõe:
- Flags booleanas decodificadas do bitmask bloq_preco_pedido (bits 0-5):
  blog_preco, blog_desconto, blog_limite_credito, blog_novo_cliente,
  blog_preco_promocional, blog_formapag_cliente
- Configurações gerais: ativar_prod_pauta, preco_padrao, preco_com_ipi,
  origem_descmax, cod_pauta1/2/3
- Configs sarconfig (key-value): bloq_pauta_preco, dias_bloq_credito,
  dias_bloq_comunicacao, tipo_bloq_comunicacao

Renumera seções de views: 1-15 → 1-16 (vw_sarcfg inserida como #1).
OrdersService usará esta view para aplicar restrições por empresa.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 11:38:55 +00:00
b0b60d7a14 refactor(erp): integração direta com banco ERP — schema sar
Revoga ADR 0006 (BD-por-workspace separado). O SAR agora conecta ao
banco PostgreSQL do ERP (módulo SIG) e usa o schema `sar` para tudo.

PRISMA
- Remove: Client, Product, Order, OrderItem, OrderStatusHistory,
  RepTarget, RepDiscountLimit, PushSubscription (modelos isolados)
- Adiciona: Pedido, PedidoItem, HistoricoPedido, AlcadaDesconto,
  MetaRepresentante, PushSubscription (mapeados para sar.*)
- IDs: id_cliente/cod_vendedor/id_empresa são INTEGER (ERP)
- situa: Int (1=Pendente 2=Aprovado 3=Cancelado 4=Faturado)
- JWT: workspace_id:string → id_empresa:number
- URL: inclui ?schema=sar para Prisma rotear ao schema ERP

SERVICES
- ClientsService: $queryRawUnsafe contra sar.vw_clientes + sar.pedidos
- CatalogService: $queryRawUnsafe contra sar.vw_produtos + sar.vw_estoque
- OrdersService: Prisma models Pedido/PedidoItem/HistoricoPedido/AlcadaDesconto
- DashboardService: MetaRepresentante + queries raw para inativos
- NotificationsService: PushSubscription com codVendedor + idEmpresa

CONTRATOS (api-interface)
- client.contract: campos ERP (idCliente, nome, cgcpf, cod_vendedor…)
- order.contract: PedidoSummary/PedidoDetail/CreatePedido + SITUA_LABEL
- product.contract: ProdutoSummary/ProdutoDetail (vw_produtos)
- auth.contract: workspaceId:string → idEmpresa:number

WEB
- Todos os cockpits e queries atualizados para os novos tipos

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 21:51:16 +00:00
246eb28bb1 feat(infra): schema SAR no banco do ERP — views SIG + tabelas de escrita
Cria scripts/sar-erp-schema.sql com tudo no schema sar:
- 15 views de leitura (vw_clientes, vw_produtos, vw_estoque, vw_pautas,
  vw_representantes, vw_empresas, vw_ctr, vw_pedidos_erp, etc.) que
  espelham gestao.* e sig.* sem modificar o ERP
- Tabelas de escrita SAR: pedidos, pedido_itens, historico_pedido,
  alcada_desconto, meta_representante, push_subscription
- Índices e grants comentados prontos para prod

Arquitetura: SAR on-prem no mesmo PostgreSQL do ERP (módulo SIG).
Substitui ADR 0006 (BD-por-workspace separado) — workspace = id_empresa.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 20:51:24 +00:00
a1a852c44d feat(c6): notificações e push — Web Push VAPID, badge dinâmico, Share API
FR-6.1/6.2: Sandra recebe push quando pedido entra em pending_approval;
Rafael recebe quando pedido é aprovado ou recusado. Service worker registrado
em background (PWA-ready via public/sw.js).

FR-6.3: Badge na Topbar busca GET /notifications/pending-count (supervisores
veem count de pending_approval; reps veem 0). Intervalo de 30s.

FR-6.4: Botão Compartilhar no OrderDetailPage para pedidos approved/invoiced
(apenas reps). Usa navigator.share() com texto formatado para WhatsApp.

Infra: modelo PushSubscription (Prisma), NotificationsModule (subscribe/
unsubscribe/pending-count + PushService VAPID), VAPID keys em .env,
integração no OrdersService (create → supervisores, approve/reject → repId).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 12:31:13 +00:00
e3587e680a feat(infra): script de provisionamento de workspace — C9
pnpm workspace:provision --id <id> [--name <nome>] [--with-seed]
Cria banco sar_workspace_{id}, habilita extensões, aplica todas as
migrations e opcionalmente popula dados demo. Sem master DB necessário
— JwtAuthGuard resolve a URL pela convenção de nome (ADR 0006).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 01:06:43 +00:00
da2f1020d1 fix(web): remove prefixos /api/v1 hardcoded nas queries ping e clients
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 01:00:45 +00:00
56ca650962 fix(web): remove prefixo duplicado /api/v1 no DevLogin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:57:22 +00:00
acab5b8a55 fix(web): adiciona prefixo /api/v1 no apiFetch — proxy Vite não roteava
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:55:42 +00:00
a9c15ac4ec fix(api): mapeia campos completos de OrderSummary no DashboardService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:46:32 +00:00
93bf906eec fix(api): adiciona guard prisma CLS no DashboardService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:45:04 +00:00
36103eaa87 feat(dashboard): painel Sandra — aprovações, pedidos do dia, inativos por rep (C8)
GET /dashboard/supervisor com fila de aprovações, KPIs do dia vs semana
anterior e top 3 reps com mais clientes inativos. SandraPainel com polling
30s. Rota / role-aware: rep → RafaelPainel, supervisor/manager → SandraPainel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:40:08 +00:00
6028bf1ba9 feat(dashboard): painel Rafael — meta, comissão, inativos, pedidos recentes (C7)
GET /dashboard/rep retorna meta mensal, comissão (fixa + FLEX), clientes
inativos >30 dias e pedidos dos últimos 7 dias. RepTarget model com migration.
RafaelPainel conectado à API real via useRepDashboard().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:29:31 +00:00
356c8e3c2c feat(orders): fluxo de aprovação — approve/reject endpoints + UIs (C5)
PATCH /orders/:id/approve e /reject com alçada role-gated; OrderDetailPage
com modais de aprovação e recusa; ApprovalQueuePage para Sandra; badge de
pendências na Sidebar; DevLogin com 4 perfis (rep, supervisor, gerente).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:01:14 +00:00
6769a0d82a feat(c4): lançamento de pedido — catálogo, alçada por linha, POST /orders
- Prisma: Product + RepDiscountLimit + productCategory em OrderItem + migration
- Seed: 28 produtos (5 categorias) + alçadas user-001 (default 10%, bebidas 8%, perecíveis 5%)
- @sar/api-interface: ProductSummarySchema, ProductDetailSchema, ProductSyncRequestSchema, CreateOrderSchema
- API: CatalogModule (GET /catalog, GET /catalog/:id, POST /catalog/sync)
- API: POST /orders — valida alçada por linha/produto (OQ-2), idempotency-key (FR-4.3), desnorm cliente
- Web: NewOrderPage (3 steps: catálogo → desconto/obs → confirmação)
- Web: botão Novo Pedido na ClientDetailPage (desabilitado se financialStatus=blocked)
- Web: rota /pedidos/novo com search param clientId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:45:11 +00:00
c36451dd33 feat(c3): consulta de pedidos — schema, api, web (OrdersModule + ClientDetailPage)
- Prisma: Order, OrderItem, OrderStatusHistory + migration
- Seed: 17 pedidos em 7 clientes com itens, histórico e desnorm de clientes
- @sar/api-interface: contratos Zod (OrderSummary, OrderDetail, OrderListQuery, etc.)
- API: GET /orders, GET /orders/:id, GET /clients/:id/orders (últimos 10)
- Web: OrdersPage (lista + filtro status/número + pending_approval highlighted)
- Web: ClientDetailPage (ficha completa + últimos 10 pedidos)
- Web: /pedidos e /pedidos/$id adicionados ao router; ClientDetailPage substitui placeholder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:31:18 +00:00
14c8350216 feat(api,web): c2 consulta de clientes — list + search + auth flow
prisma: modelo Client + migração 20260527225728_add_client + seed dev (10 clientes)
api: GET /clients (list, busca, filtro atividade/financeiro, paginação) + GET /clients/:id
     rep vê carteira própria; supervisor/admin vê tudo; activityStatus calculado de lastOrderAt
@sar/api-interface: ClientSummarySchema, ClientDetailSchema, ClientListResponseSchema
web: ClientsPage (tabela AntD, busca, filtro), DevLogin (token dev), authStore, Bearer no apiFetch
oq-4 resolvida: creditLimit gerenciado no SAR

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 23:08:57 +00:00
2a8be3fd82 feat(api): master-login stub + WorkspacePrismaPool (Frente E)
- Prisma 7: prisma.config.ts com datasource.url (API correta); schema gerado em CJS
- WorkspacePrismaPool: LRU cache (max 10) de PrismaClient por workspace (ADR 0006)
  PrismaPg adapter + pg.Pool por workspace; getOrCreate/health/onModuleDestroy
- JwtAuthGuard: global APP_GUARD, jose HS256, popula CLS com workspace_id/userId/prisma
  @Public() decorator marca ping/health/dev-auth como rotas abertas
- DevAuthController: POST /auth/dev/token — emite JWT dev (404 em produção)
- AuthTokenResponseSchema + DevTokenRequestSchema em @sar/api-interface
- WorkspacePoolHealthIndicator: health/ready reporta amostra LRU top-3 (nunca O(N))
- .npmrc: hoist @prisma/client-runtime-utils (requerido pelo Prisma 7 isolated mode)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:36:00 +00:00
bca2e3ebb3 docs(docs): prd MVP SAR finalizado — Rafael + Sandra (C1–C9, 45 FRs)
Fast path sobre Phase 1+2. Escopo: consulta de clientes, histórico de
pedidos, lançamento offline com Idempotency-Key e aprovação de desconto.
Reviewer gate aplicado: 3 fixes (offline/crédito, falha de sync, OQ-2).
6 OQs abertas; OQ-1/OQ-4 bloqueiam C2/C4 até primeiro cliente confirmar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:07:53 +00:00
70ecfdc927 docs(config): atualiza design log com Frente D
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 19:55:28 +00:00
fdbf40cd1a chore(config): frente D — ESLint boundaries + Husky + commitlint + gitleaks
Higiene de PR antes da primeira feature de domínio.

- Tags Nx canônicas (scope/type/domain) em todos os 5 projetos, incluindo e2e
- depConstraints ESLint: scope:api|web|shared + type:app|e2e|feature|util|data
- Husky 9 + lint-staged: eslint --max-warnings=0 + prettier --check em pre-commit
- commitlint @conventional: tipo obrigatório, scope enum warn, body ilimitado
- gitleaks via Docker: zero leaks no tree completo; allowlist .agents/,.claude/,tmp/
- tmp/ adicionado ao .gitignore (relatórios de scan locais)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 19:52:05 +00:00
29321f54c0 feat(web): ping API ponta-a-ponta via TanStack Query + Zod contract
Fecha loop B+C — Web consome @sar/api-interface em runtime, não só build.

- Vite proxy /api → localhost:3000 (zero CORS em dev, mesma URL em prod via Nginx)
- api-client.ts: fetch wrapper parseando RFC 9457 problem+json em ApiError
- useApiPing: TanStack Query + PingResponseSchema.parse — drift servidor falha alto
- FoundationStatus pill na Topbar (verde/vermelho/cinza + Tooltip com requestId)

Validado via curl proxy:4200 → 200 ok contratual; /nope → 404 problem+json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:14:40 +00:00
159 changed files with 27347 additions and 530 deletions

View File

@@ -45,6 +45,14 @@ OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=1.0 OTEL_TRACES_SAMPLER_ARG=1.0
SENTRY_DSN= SENTRY_DSN=
# Web Push VAPID (C6) — gerar com: node -e "const wp=require('web-push'); const k=wp.generateVAPIDKeys(); console.log(k)"
# Em prod: Vault injeta. Em dev: opcional — push fica desabilitado se vazio.
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_CONTACT=mailto:noreply@sar.dev
# Chave pública VAPID para o front-end (mesmo valor de VAPID_PUBLIC_KEY)
VITE_VAPID_PUBLIC_KEY=
# Feature flags (DEV: bypass. Prod: GrowthBook self-host) # Feature flags (DEV: bypass. Prod: GrowthBook self-host)
GROWTHBOOK_API_HOST=http://localhost:3100 GROWTHBOOK_API_HOST=http://localhost:3100
GROWTHBOOK_CLIENT_KEY= GROWTHBOOK_CLIENT_KEY=

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ node_modules
dist dist
build build
out out
tmp
.nx .nx
.next .next
.turbo .turbo

32
.gitleaks.toml Normal file
View File

@@ -0,0 +1,32 @@
# Gitleaks — SAR Força de Vendas
# Documentação: https://github.com/gitleaks/gitleaks
title = "SAR gitleaks config"
[extend]
useDefault = true # herda todas as regras padrão
[allowlist]
description = "Arquivos e padrões seguros conhecidos"
paths = [
# Arquivos de exemplo — contêm placeholders, nunca segredos reais
".env.example",
".env.test",
# Lock files gerados pelo pnpm — nunca contêm segredos
"pnpm-lock.yaml",
# Ferramentas de agente (BMad skills, Claude config) — docs/templates, não código de produto
'''.agents/''',
'''.claude/''',
# Arquivos temporários / relatórios de CI gerados localmente
'''tmp/''',
]
regexes = [
# Hashes de commit no design log e docs
'''[0-9a-f]{7,40}''',
# UUIDs canônicos usados em testes (requestId, workspaceId)
'''[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}''',
# Valores placeholder explícitos em .env.example
'''(your-|change-me|placeholder|CHANGE_ME|YOUR_)''',
]

2
.husky/commit-msg Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env sh
pnpm exec commitlint --edit "$1"

21
.husky/pre-commit Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env sh
# SAR pre-commit: lint-staged → gitleaks
pnpm exec lint-staged
# Gitleaks — detecta segredos antes de empurrar pro Gitea.
# Roda via Docker para não exigir instalação local.
# Fallback silencioso se Docker não estiver disponível (CI tem o binário nativo).
if command -v docker > /dev/null 2>&1 && docker info > /dev/null 2>&1; then
docker run --rm \
-v "$(pwd)":/path \
-w /path \
zricethezav/gitleaks:latest detect \
--config .gitleaks.toml \
--source . \
--no-git \
--redact \
--exit-code 1
else
echo "[pre-commit] Docker indisponível — gitleaks pulado (rode manualmente antes de push)"
fi

1
.npmrc
View File

@@ -6,4 +6,5 @@ shamefully-hoist=false
public-hoist-pattern[]=*types* public-hoist-pattern[]=*types*
public-hoist-pattern[]=*eslint* public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier* public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=@prisma/client-runtime-utils
node-linker=isolated node-linker=isolated

View File

@@ -0,0 +1,206 @@
# Investigation: Pedidos — Valores incorretos e vinculação clientes × vendedor
## Hand-off Brief
1. **O que aconteceu.** Pedidos exibem valores/status errados e a vinculação cliente↔vendedor está inconsistente; suspeita confirmada de 5 bugs distintos, sendo o mais grave o mismatch de códigos `situa` entre ERP e SAR.
2. **Onde o caso está.** Cinco problemas confirmados ou fortemente deduzidos; `vw_pedidos_erp` (view central usada pelo service) **não está definida em nenhum arquivo de código** — sua definição existe apenas no banco.
3. **O que é necessário agora.** Verificar a DDL de `vw_pedidos_erp` diretamente no banco (`\d+ sarweb.vw_pedidos_erp`) e confirmar se ela normaliza `situa` — isso resolve ou agrava o Bug #1.
## Case Info
| Campo | Valor |
| ---------------- | ------------------------------------------------------------------ |
| Ticket | N/A |
| Data | 2026-05-30 |
| Status | Active |
| Sistema | Node 24 / NestJS 11 / Prisma 7 / PostgreSQL (SIG+GERENTE schemas) |
| Fontes | orders.service.ts, clients.service.ts, sarweb_views.sql, schema.prisma, .env, jwt-auth.guard.ts |
## Problem Statement
Usuário relata: "pedidos com valores errados e vinculação clientes × vendedor"; complementou que a empresa usa `id_empresa = 9001` (schema SIG) e a empresa gerencial é a `1` (schema GERENTE), e que às vezes dados estão duplicando nas telas.
## Evidence Inventory
| Fonte | Status | Notas |
| --------------------------------------- | ---------- | -------------------------------------------------------- |
| `orders.service.ts` | Available | Lido completo — queries brutas sem esquema qualificado |
| `clients.service.ts` | Available | Lido completo |
| `sarweb_views.sql` | Available | Define `vw_pedidos`, NÃO define `vw_pedidos_erp` |
| `schema.prisma` | Available | Schema `sar` no PG; tabela `pedidos` é SAR-only |
| `jwt-auth.guard.ts` | Available | Em dev usa `DEV_EMPRESA_ID` e `DEV_REP_CODE` do `.env` |
| `.env` | Available | `DEV_EMPRESA_ID=1`, `DEV_REP_CODE=29` |
| DDL de `vw_pedidos_erp` no banco | **Missing** | Não está no repo — requer `\d+` direto no PG |
| Logs do PG / EXPLAIN com id_empresa=9001| **Missing** | Requer execução manual |
## Confirmed Findings
### Finding 1: `DEV_EMPRESA_ID=1` mas a empresa real é SIG (id=9001)
**Evidência:** `.env:DEV_EMPRESA_ID=1` e `jwt-auth.guard.ts:56` — em dev, `idEmpresa` é forçado para `1` (GERENTE), ignorando o JWT.
**Detalhe:** Em desenvolvimento, todas as queries usam `WHERE id_empresa = 1` e buscam dados do schema GERENTE. A empresa real do usuário está no schema SIG com `id_empresa = 9001`. Portanto, os testes locais estão apontando para dados diferentes dos de produção.
**Fix imediato:** Alterar `.env`: `DEV_EMPRESA_ID=9001`.
---
### Finding 2: `vw_pedidos_erp` não existe em nenhum arquivo do repositório
**Evidência:**
- `orders.service.ts:77``FROM vw_pedidos_erp e`
- `sarweb_views.sql` — define `sarweb.vw_pedidos`, não `vw_pedidos_erp`
- `grep -rn "vw_pedidos_erp"` → apenas `orders.service.ts` e `dashboard.service.ts`; zero arquivos SQL
**Detalhe:** A view existe somente no banco (criada manualmente). Seu comportamento exato — especialmente se normaliza `situa` do ERP para valores SAR — é desconhecido sem inspecionar o banco.
---
### Finding 3: Mismatch de códigos `situa` entre ERP e SAR
**Evidência:** `sarweb_views.sql:367-378` (GERENTE) vs `sarweb_views.sql:411-418` (SIG) vs `order.contract.ts:13-18` (SAR).
| Situa | GERENTE ERP | SIG ERP | SAR (app) |
|-------|-------------|-------------|-----------------|
| 1 | Pendente | Pendente | Ag. Aprovação |
| 2 | Liberado | Liberado | Aprovado |
| 3 | **Faturado**| Liberado | **Cancelado** |
| 4 | **Cancelado**| **Faturado**| **Faturado** |
| 5 | — | **Cancelado**| — |
**Efeito confirmado (GERENTE):** Um pedido FATURADO no ERP (`situa=3`) exibe cor **vermelha** (Cancelado) no SAR — porque `OrderStatusBadge` usa o número cru para definir `tagColor`. O texto pode estar correto (via `statusDescr`) mas a cor é errada.
**Efeito no filtro de status:** `situaFilter = situa != null ? 'AND e.situa = ${situa}' : ''` — se o usuário filtra `situa=3` (SAR=Cancelado), recebe pedidos FATURADOS do GERENTE.
---
### Finding 4: `dt_ultima_compra` ignorando histórico ERP — `activityStatus` sempre errado
**Evidência:** `clients.service.ts:100`
```sql
LEFT JOIN pedidos p ON p.id_cliente = c.id_cliente
AND p.id_empresa = c.id_empresa
AND p.situa != 3
```
`pedidos` aqui é a tabela SAR (`sar.pedidos`, Prisma), não os pedidos históricos do ERP.
**Detalhe:** Clientes que têm anos de histórico no ERP mas nunca fizeram pedido pelo SAR ficam com `dt_ultima_compra = NULL``activityStatus = 'inactive'`. Portanto, a carteira inteira aparece como **"Inativo"** na tela de Clientes.
**Fix necessário:** O JOIN deve usar `vw_pedidos_erp` (ou equivalente) em vez da tabela SAR.
---
### Finding 5: Filtro por `status` de clientes aplicado DEPOIS da paginação SQL
**Evidência:** `clients.service.ts:138`
```typescript
if (status) mapped = mapped.filter((c) => c.activityStatus === status);
```
O `total` vem de `SELECT COUNT(*)` sem o filtro de status (`clients.service.ts:114-122`).
**Efeito:** Ao filtrar por `status=active`, a API retorna menos itens que `limit` (ex.: 8 de 50), mas `total` ainda diz 2606. A paginação fica quebrada e a portfólio card mostra totais incorretos quando usa `useClientList({ limit:1, status:X })`.
---
## Hypothesized Paths
### Hypothesis 1: `vw_pedidos_erp` causa duplicação via UNION sem filtro por empresa
**Status:** Open
**Teoria:** `vw_pedidos_erp` faz UNION ALL de GERENTE + SIG sem filtrar por `id_empresa`, e para uma empresa que existe nos dois schemas (id=1 e id=9001), as mesmas ordens aparecem duas vezes.
**Confirmaria:** `\d+ sarweb.vw_pedidos_erp` mostrando UNION ALL sem cláusula WHERE por empresa, + query retornando duplicatas com `id_empresa` distintos.
**Refutaria:** View com UNION ALL onde cada SELECT tem `AND id_empresa = X` fixo.
---
### Hypothesis 2: SAR-created orders nunca aparecem na listagem
**Status:** Open
**Teoria:** `orders.service.ts list()` só consulta `vw_pedidos_erp` (ERP), nunca `sar.pedidos` (Prisma). Pedidos criados pelo SAR somem da lista após criação.
**Confirmaria:** Criar pedido via SAR → abrir `/pedidos` → pedido não aparece na lista.
**Refutaria:** `vw_pedidos_erp` inclui `sar.pedidos` via UNION ALL.
---
## Missing Evidence
| Gap | Impacto | Como obter |
| -------------------------------- | -------------------------------------------- | --------------------------------------------- |
| DDL de `vw_pedidos_erp` | Confirma/refuta H1, H2 e Bug #3 (situa) | `\d+ sarweb.vw_pedidos_erp` no psql |
| Query real com id_empresa=9001 | Confirma duplicação e valores | Rodar GET /orders com JWT prod ou DEV_ID=9001 |
| Confirmar se `sar.pedidos` aparece na lista | Confirma H2 | Criar pedido SAR → verificar lista /orders |
## Source Code Trace
| Elemento | Detalhe |
| -------------- | ------------------------------------------------------------- |
| Bug #1 origem | `jwt-auth.guard.ts:56``DEV_EMPRESA_ID` sobrescreve JWT |
| Bug #2 origem | Banco de dados (DDL não versionada) |
| Bug #3 origem | `orders.service.ts:43,45,98-99``situa` cru do ERP |
| Bug #4 origem | `clients.service.ts:100` — JOIN com `sar.pedidos` (não ERP) |
| Bug #5 origem | `clients.service.ts:138` — filter JS pós-paginação SQL |
## Conclusion
**Confidence:** Medium (root causes identificadas; DDL de `vw_pedidos_erp` é peça faltante)
Cinco bugs confirmados ou fortemente deduzidos explicam os sintomas:
1. **DEV aponta para empresa errada** (`DEV_EMPRESA_ID=1` vs real=9001) — dados diferentes entre dev e prod.
2. **`situa` ERP ≠ SAR** — cores/filtros de status errados para pedidos históricos.
3. **`dt_ultima_compra` ignora ERP** — carteira toda aparece inativa.
4. **`status` filter pós-paginação** — totais e paginação quebrados.
5. **`vw_pedidos_erp` não versionada** — comportamento opaco, possível fonte de duplicação.
## Recommended Next Steps
### Fix imediato (sem risco)
Alterar `.env`: `DEV_EMPRESA_ID=9001` para que dev espelhe produção.
### Antes de qualquer outro fix: verificar `vw_pedidos_erp` no banco
```sql
-- Rodar diretamente no psql:
\d+ sarweb.vw_pedidos_erp
-- ou
SELECT pg_get_viewdef('sarweb.vw_pedidos_erp', true);
```
O resultado define o caminho dos próximos fixes.
### Fix #3 — `situa` — normalizar na `vw_pedidos_erp` (ou no service)
Adicionar CASE na view (ou no service) mapeando ERP situa → SAR situa:
```sql
-- GERENTE: 2→2, 3→4(Faturado), 4→3(Cancelado)
-- SIG: 2→2, 4→4(Faturado), 5→3(Cancelado)
```
### Fix #4 — `dt_ultima_compra` — usar ERP orders
Em `clients.service.ts:100`, substituir `pedidos` por `vw_pedidos_erp` (ou a view equivalente):
```sql
LEFT JOIN vw_pedidos_erp p
ON p.id_cliente = c.id_cliente
AND p.id_empresa = c.id_empresa
AND p.situa NOT IN (3, 4, 5) -- situa=cancelado nos dois sistemas
```
### Fix #5 — `status` filter — mover para SQL
Em `clients.service.ts`, incluir o filtro de `activityStatus` na query SQL via subquery ou CTE com `dt_ultima_compra`, eliminando o filter JS pós-paginação.
## Reproduction Plan
1. Alterar `DEV_EMPRESA_ID=9001` no `.env`
2. Reiniciar a API
3. Abrir `/clientes` → verificar se clientes aparecem e se `activityStatus` faz sentido
4. Abrir `/pedidos` → verificar se pedidos aparecem e se status está correto
5. Rodar `\d+ sarweb.vw_pedidos_erp` no banco e trazer o resultado para continuar a investigação
## Side Findings
- `ALERT_DAYS=30` e `INACTIVE_DAYS=60` estão hardcoded em `clients.service.ts:11-12`. O comentário diz "Configuráveis por empresa futuramente" — tarefa pendente.
- `orders.service.ts:46` usa interpolação de string direta para `numPedSar` (ILIKE). O `escSql()` de `clients.service.ts:24` não é reutilizado aqui — potencial SQL injection menor para campo de busca.
- `vw_pedidos` em `sarweb_views.sql` não é usada em lugar nenhum do código fonte — `vw_pedidos_erp` é usada em seu lugar. Possível que `vw_pedidos_erp` seja um rename ou extensão da `vw_pedidos`.

View File

@@ -0,0 +1,348 @@
---
stepsCompleted: [1, 2, 3, 4, 5]
inputDocuments:
- design-artifacts/A-Product-Brief/01-product-brief.md
- _bmad-output/planning-artifacts/prds/prd-sar-2026-05-27/prd.md
- design-artifacts/C-UX-Scenarios/00-ux-scenarios.md
- design-artifacts/_progress/wds-project-outline.yaml
workflowType: 'architecture'
project_name: 'SAR — Força de Vendas'
user_name: 'Julian'
date: '2026-05-30'
---
# Documento de Decisões de Arquitetura
# SAR — Força de Vendas
> Referência canônica para agentes de IA e devs implementando histórias. Cada decisão aqui é lei — não reabrir sem RFC.
---
## 1. Visão geral do sistema
SAR é um SaaS B2B de força de vendas multi-tenant. Arquitetura web-first com PWA para o cockpit Rep (mobile), desktop para Supervisor/Dono. MVP entrega cockpits Rafael (Rep) e Sandra (Supervisor).
```
┌─────────────────────────────────────────────────────────┐
│ Browser (PWA/Desktop) │
│ React 19.2 + TanStack Router/Query + Zustand + AntD │
└──────────────────────┬──────────────────────────────────┘
│ REST /api/v1 (+WebSocket/SSE futuro)
┌──────────────────────▼──────────────────────────────────┐
│ NestJS 11 API (apps/api) │
│ JwtAuthGuard → WorkspaceCLS → Module handlers │
└──────────────────────┬──────────────────────────────────┘
│ Prisma 7 (pool por empresa)
┌──────────────────────▼──────────────────────────────────┐
│ PostgreSQL 18 (schema "sar" + views ERP) │
│ Um schema por empresa (idEmpresa) │
└─────────────────────────────────────────────────────────┘
```
---
## 2. Estrutura do monorepo
```
sar/
├── apps/
│ ├── api/ ← NestJS (backend)
│ └── web/ ← React Vite (frontend)
├── libs/
│ └── shared/
│ └── api-interface/ ← contratos Zod compartilhados
├── STACK.md ← fonte da verdade técnica
└── CODING-RULES.md ← invariantes e pegadinhas
```
**Regra:** Qualquer tipo que cruza a fronteira API↔Web mora em `libs/shared/api-interface`. Nunca duplicar tipos.
---
## 3. Multi-tenancy
**Modelo:** `idEmpresa: number` (inteiro do ERP) identifica o tenant. Não existe `workspaceId: string` — o ADR 0006 original foi revogado.
**Mecanismo no backend:**
- `WorkspaceModule` registra CLS global (nestjs-cls)
- CLS middleware injeta `requestId` e `idEmpresa = 0` (fallback para rotas públicas)
- `JwtAuthGuard` após validar o JWT sobrescreve `idEmpresa`, `userId`, `role` e injeta o `PrismaClient` certo no CLS
- Todo handler acessa `cls.get('prisma')` — nunca um Prisma singleton (CODING-RULES PGD-DB-009)
**No banco:**
- Schema `sar` contém todas as tabelas SAR
- Views `vw_*` expõem dados do ERP legado (leitura apenas)
- Isolation por `id_empresa` nas queries — nenhuma query atravessa empresas
---
## 4. Módulos da API (NestJS)
| Módulo | Responsabilidade |
|--------|-----------------|
| `auth` | `/auth/me` — perfil do usuário autenticado; `/dev-auth` — login dev (sem master-login) |
| `workspace` | CLS global, pool de Prisma por empresa |
| `catalog` | Catálogo de produtos, empresa (pauta/preço) |
| `clients` | Lista e ficha de clientes |
| `orders` | CRUD pedidos, fluxo aprovação, histórico |
| `dashboard` | KPIs do Rep e Supervisor |
| `notifications` | Web Push (futuro SSE/Socket.IO) |
| `health` | `/health` — liveness/readiness |
| `ping` | `/ping` — sanity check |
| `logger` | Pino configurado com redact de PII |
**Padrão de módulo:**
```
módulo/
├── *.module.ts
├── *.controller.ts ← valida entrada com contrato Zod
├── *.service.ts ← lógica de domínio
└── *.types.ts ← tipos internos (não exportados para o shared)
```
---
## 5. Contratos Zod (API Interface)
Localização: `libs/shared/api-interface/src/lib/*.contract.ts`
Contratos existentes: `auth`, `client`, `company`, `dashboard`, `notifications`, `order`, `ping`, `product`
**Padrão de contrato:**
```typescript
// Schema de entrada (mutation)
export const CreateXxxSchema = z.object({ ... });
export type CreateXxx = z.infer<typeof CreateXxxSchema>;
// Schema de saída (query)
export const XxxResponseSchema = z.object({ ... });
export type XxxResponse = z.infer<typeof XxxResponseSchema>;
// Schema de lista (com paginação)
export const XxxListQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
export const XxxListResponseSchema = z.object({
data: z.array(XxxResponseSchema),
total: z.number().int().nonneg(),
page: z.number().int().positive(),
limit: z.number().int().positive(),
});
```
**Regra:** Backend valida entrada com o Schema (422 se inválido — RFC 9457). Frontend parseia resposta com o Schema (`Schema.parse(data)`).
---
## 6. Autenticação e autorização
**Fluxo MVP (dev):**
- Token JWT em `localStorage` via `authStore` (transitório — dev only)
- `POST /api/v1/auth/dev-login` retorna JWT assinado
- Bearer token no header de toda requisição protegida
**Fluxo produção (planejado):**
- master-login IdP OAuth2/OIDC (JCS próprio)
- Access token em memória; refresh token em cookie `httpOnly; Secure; SameSite=Lax`
- `JwtAuthGuard` valida e injeta contexto no CLS
**Papéis (roles):** `rep` · `supervisor` · `dono` · `admin`
**Frontend — role routing:**
- Raiz `/` → lê role do JWT payload, redireciona para `<RepPainel>` ou `<SupervisorPainel>`
- Cada cockpit renderiza apenas suas rotas; sem RBAC granular no MVP
---
## 7. Frontend — estrutura
```
apps/web/src/
├── cockpits/
│ ├── rep/ ← páginas do cockpit Rafael
│ │ ├── RepPainel.tsx
│ │ ├── ClientsPage.tsx
│ │ ├── ClientDetailPage.tsx
│ │ ├── OrdersPage.tsx
│ │ ├── OrderDetailPage.tsx
│ │ ├── OrderPrintPage.tsx
│ │ ├── NewOrderPage.tsx
│ │ └── CatalogPage.tsx
│ └── supervisor/ ← páginas do cockpit Sandra
│ ├── SupervisorPainel.tsx
│ └── ApprovalQueuePage.tsx
├── components/
│ └── layout/ ← AppShell, Sidebar, Topbar
├── lib/
│ ├── router.tsx ← TanStack Router (flat routes)
│ ├── auth-store.ts ← Zustand-lite para token
│ ├── api-client.ts ← apiFetch + ApiError (RFC 9457)
│ ├── query-client.ts← TanStack Query config
│ ├── queries/ ← hooks de query por domínio
│ ├── hooks/ ← hooks utilitários
│ └── theme.ts ← AntD theme config
```
**Rotas atuais:**
| Path | Componente | Cockpit |
|------|-----------|---------|
| `/` | HomeRoute (role redirect) | — |
| `/rep` | RepPainel | Rep |
| `/clientes` | ClientsPage | Rep |
| `/clientes/$id` | ClientDetailPage | Rep |
| `/pedidos` | OrdersPage | Rep |
| `/pedidos/novo` | NewOrderPage | Rep |
| `/pedidos/$id` | OrderDetailPage | Rep |
| `/pedidos/$id/imprimir` | OrderPrintPage | Rep |
| `/catalogo` | CatalogPage | Rep |
| `/aprovacoes` | ApprovalQueuePage | Supervisor |
---
## 8. Gerenciamento de estado
| Tipo de estado | Solução |
|----------------|---------|
| Server state (queries, mutations) | TanStack Query v5 |
| Auth (token, user profile) | Zustand (`authStore`) |
| UI state local (modais, forms) | `useState` / `useReducer` em componente |
| Offline queue (futuro) | IndexedDB + Service Worker |
**Regra:** Não duplicar server state em Zustand. TanStack Query é a fonte da verdade para dados do servidor.
---
## 9. API Client (frontend)
`lib/api-client.ts``apiFetch(path, options)`:
- Base URL: `/api/v1` (proxy Vite em dev → `:3000`; Nginx em prod → mesmo origin)
- Injeta `Authorization: Bearer <token>` automaticamente
- Parseia `application/problem+json``ApiError` com `status` + `problem`
- Não faz parse Zod — caller é responsável
**Tratamento de erro:**
- `422` = validação Zod (erros detalhados em `problem.errors`)
- `4xx` outros = erro de domínio
- `5xx` = retry automático pelo QueryClient (máx 2x)
---
## 10. Erros e RFC 9457
Todo erro da API retorna `application/problem+json`:
```json
{
"type": "https://sar.jcsinformatica.com.br/errors/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "Campo obrigatório ausente",
"requestId": "uuid",
"errors": [{ "path": "idCliente", "message": "Required", "code": "invalid_type" }]
}
```
**Regra (CODING-RULES):** Nunca retornar `403` para recurso inexistente — usar `404` para não vazar existência.
---
## 11. Ciclo de vida do Pedido
Estados controlados pelo SAR:
```
0 (Orçamento) ──→ 1 (Ag. Aprovação) ──→ 2 (Transmitido)
└──→ 0 (recusado → volta Orçamento)
```
Estados espelhados do ERP (pós-integração): `3=Cancelado`, `4=Faturado`
**Alçada:** se `descontoPerc > alçadaRep` → situa vai para `1`. Supervisor aprova/recusa/ajusta.
**Idempotency-Key:** gerado no frontend antes do envio; garante que retentativas de sync não duplicam pedidos.
---
## 12. Impressão de pedido
Rota `/pedidos/$id/imprimir``OrderPrintPage.tsx` — renderiza HTML otimizado para `window.print()` (browser → PDF). Sem geração server-side de PDF no MVP.
---
## 13. Offline (Rafael) — planejado
**Mecanismo:** IndexedDB queue + Service Worker
- Catálogo e clientes cacheados com TTL 4h
- Pedidos criados offline enfileirados com `Idempotency-Key` local
- Sync automático ao retorno de sinal
- Dados financeiros sensíveis (limite de crédito numérico, inadimplência) só online
**Status atual:** não implementado. Será uma epic dedicada.
---
## 14. Real-time (Sandra) — planejado
**Mecanismo:** Socket.IO 4 / SSE via `notifications` module
- Novos pedidos aparecem em < 3s no painel Sandra
- Push notifications Web Push (PWA) para aprovações
**Status atual:** `notifications.module.ts` existe, endpoints WIP.
---
## 15. Banco de dados
- PostgreSQL 18, schema `sar`
- Prisma 7 como ORM/query builder
- Pool de clientes Prisma gerenciado pelo `WorkspacePrismaPool` (CLS)
- Views `vw_clientes`, `vw_representantes`, `vw_pedidos_erp` expõem dados legados
- Isolation por `id_empresa` em todas as queries
**Regra:** Raw SQL via `prisma.$queryRaw` apenas quando Prisma ORM não atende (ex: JOINs com views legadas).
---
## 16. Infraestrutura
| Componente | Solução |
|-----------|---------|
| Hosting | Proxmox on-prem Brasil |
| Orquestração | Docker Compose |
| Deploy | Ansible |
| CDN / proxy | Cloudflare + Nginx |
| Object storage | MinIO |
| Secrets | Vault |
| Cache / filas | Valkey (Redis-compatible) + BullMQ 5.77 |
| Observabilidade | OpenTelemetry + Pino |
| E-mail | Resend |
---
## 17. Invariantes de implementação (CODING-RULES)
- `PrismaClient` sempre via `cls.get('prisma')` — nunca singleton injetado diretamente
- `idEmpresa` real vem do JWT (guard) — nunca de `.env` ou parâmetro de rota
- PII (CPF/CNPJ, telefone, e-mail) redactada em todos os logs via Pino `redact`
- Tokens JWT: acesso em memória, refresh em cookie `httpOnly; Secure; SameSite=Lax`
- Rate limit em auth: 5 tentativas/min/IP
- Nomes visíveis em toda UI — nunca exibir só código (cliente, rep, produto)
- Empresa matriz `9001` normalizada → `1` em consultas de catálogo
---
## 18. Decisões em aberto (não resolver sem RFC)
| # | Questão |
|---|---------|
| OQ-1 | Formato e frequência de importação do catálogo/clientes do ERP legado |
| OQ-2 | Alçada de desconto: fixa por rep ou por linha de produto? |
| OQ-3 | Fórmula de cálculo de comissão FLEX documentada? |
| OQ-5 | Múltiplos supervisores: distribuição da fila de Aprovações |
| OQ-6 | TTL de 4h para catálogo offline é aceitável para o 1º cliente? |
---
*Gerado em 2026-05-30. Baseado no código existente + PRD 2026-05-27 + Product Brief 2026-05-26.*

View File

@@ -0,0 +1,74 @@
# Decision Log — SAR PRD MVP
**Workspace:** `_bmad-output/planning-artifacts/prds/prd-sar-2026-05-27/`
**Iniciado:** 2026-05-27
**Facilitador:** bmad-prd (Create · Fast Path)
---
## Decisões
### D-001 — Escopo MVP: Rafael + Sandra apenas
**Data:** 2026-05-27
**Decisão:** MVP cobre cockpits Rafael (Representante) e Sandra (Supervisora). Daniel e Alice têm telas placeholder.
**Justificativa:** Julian definiu explicitamente: consulta de clientes, pedidos históricos, lançamento de pedido novo. Sandra foi incluída porque aprovação de desconto é inseparável do fluxo de pedido do Rafael.
**Impacto:** Cockpits Daniel e Alice ficam fora do escopo funcional.
### D-002 — Aprovação de desconto inclusa no MVP
**Data:** 2026-05-27
**Decisão:** Fluxo completo de aprovação (Rafael solicita → Sandra aprova/recusa) está no MVP (C5).
**Justificativa:** Julian confirmou explicitamente ao ser perguntado.
**Impacto:** Sandra precisa de cockpit funcional (painel + fila de aprovações + push).
### D-003 — WhatsApp via Share API nativa no MVP
**Data:** 2026-05-27
**Decisão:** Sem Meta Cloud API no MVP. Compartilhamento via Web Share API (abre WhatsApp nativo do device).
**Justificativa:** Reduz complexidade e custo de integração para o MVP. Meta Cloud API entra na próxima iteração.
**Impacto:** Nenhuma mensagem programática para clientes finais no MVP.
### D-004 — ERP: importação manual no MVP
**Data:** 2026-05-27
**Decisão:** Catálogo, pautas e clientes importados via arquivo (CSV/JSON) ou endpoint simples. Sem integração automática bidirecional no MVP.
**Justificativa:** Reduz escopo e depende do ERP específico do primeiro cliente (a definir).
**Impacto:** OQ-1 precisa ser resolvido com o primeiro cliente antes de C4.
### D-005 — Working mode: Fast Path
**Data:** 2026-05-27
**Decisão:** Julian optou por Fast Path (terse brief, referência a documentos existentes como fonte de verdade).
**Justificativa:** Documentos do Phase 1+2 (Brief + Trigger Map) estavam completos e detalhados. Extração via subagentes foi suficiente para draft completo.
---
### D-006 — Fixes do reviewer gate aplicados
**Data:** 2026-05-27
**Decisão:** 3 achados críticos do reviewer incorporados no PRD antes do `status: final`:
1. FR-2.4/FR-2.5 + NFR-3.3 — contradição sobre offline resolvida: situação resumida cacheada; limite numérico e inadimplência requerem conexão com disclaimer.
2. FR-4.11 (renumerado para FR-4.12) — adicionado FR para falha de sync: Pedido retorna ao Rep com status `falha de sync` + motivo legível, nunca descartado silenciosamente.
3. FR-4.7 — adicionada nota [OQ-2] para alçada por linha de produto.
### D-007 — OQs triadas: non-blockers para PRD final
**Data:** 2026-05-27
**Decisão:** PRD finalizado com todas as 6 OQs abertas. Classificação:
- **Phase-blockers para epics específicos** (não para o PRD): OQ-1 (C4), OQ-2 (C4/C5), OQ-4 (C2/C4), OQ-5 (C5)
- **Non-blockers**: OQ-3 (C7 — comissão FLEX pode ser placeholder), OQ-6 (TTL configurável)
- OQ-1 e OQ-4 dependem do primeiro cliente — devem ser resolvidas antes do início do design de C2/C4.
### D-008 — PRD finalizado
**Data:** 2026-05-27
**Status:** `final`
**Artefatos:** `prd.md` + `.decision-log.md` + `addendum.md` (não gerado — sem overflow de conteúdo)
---
## Assumptions abertas (a confirmar com Julian)
| ID | Assumption | Seção |
|----|-----------|-------|
| A-001 | Criação de usuários feita por admin JCS (sem self-service) | FR-1.5 |
| A-002 | Thresholds de inatividade (30/60d) configuráveis por workspace | FR-2.3 |
| A-003 | Catálogo de clientes sincronizado do ERP; Rafael não cria/edita | FR-2.6 |
| A-004 | Sync de catálogo com TTL de 4h | FR-4.4 |
| A-005 | Alçada de desconto default: 5% | FR-4.11 |
| A-006 | Qualquer supervisor do workspace pode aprovar qualquer pedido da equipe | FR-5.6 |
| A-007 | Sandra não acessa fichas de clientes individuais no MVP | FR-8.3 |
| A-008 | Sem suporte a browsers legados (IE etc.) | NFR-5.3 |

View File

@@ -0,0 +1,416 @@
---
title: "SAR — Força de Vendas: PRD MVP"
status: final
created: 2026-05-27
updated: 2026-05-27
project: sar
version: "0.1"
---
# SAR — Força de Vendas: PRD MVP
## 1. Visão Geral
**SAR** é uma plataforma SaaS web de força de vendas para PMEs brasileiras com 550 Representantes externos. Substitui o app Android/Desktop legado da JCS e resolve a dor central: **donos e supervisores decidem no escuro** enquanto 510% da Carteira esfria silenciosamente na rua.
O produto entrega **quatro cockpits especializados** (Representante · Supervisor · Dono · Admin) compartilhando um único dado em tempo real. O MVP valida os dois cockpits de maior impacto operacional imediato: **Rafael** (Rep em campo) e **Sandra** (supervisora que aprova e monitora).
**Por que agora:** janela de mercado de 23 anos antes de concorrentes se modernizarem. O primeiro cliente pagante em 34 meses valida o modelo e financia as próximas iterações.
---
## 2. Objetivos e Métricas de Sucesso
### North Star
> Primeiro cliente paga e **renova** nos primeiros 3 meses após go-live.
### Métricas de comportamento (MVP)
| Métrica | Alvo MVP | Sinal de problema |
|---------|----------|-------------------|
| Pedidos lançados pelo Rep no SAR vs. total | ≥ 90% | < 70% = adoção baixa |
| Tempo médio de lançamento de Pedido | < 2 min | > 5 min = UX ruim |
| Taxa de sincronização offline bem-sucedida | ≥ 99,5% | < 99% = risco de perda |
| Aprovações respondidas em < 30 min | ≥ 80% | < 60% = gargalo |
| Clientes Inativos >60 d visualizados/semana | ≥ 30% da lista | < 10% = feature ignorada |
### Métricas de negócio JCS (Y1)
- 1020 clientes pagantes até o mês 12
- ARR R$ 200k600k
- NPS donos > 50
### Counter-métricas
- Pedidos duplicados por falha de sync → alvo: 0
- Reclamações de dado incorreto no histórico → alvo: < 2/mês/cliente
---
## 3. Personas MVP
### Rafael — Representante *(cockpit primário)*
- **Perfil:** 3050 anos, vendedor B2B externo, comissionado, atende 50200 Clientes ativos
- **Device:** Mobile-first · PWA iOS (Android legado continua em paralelo)
- **Contexto de uso:** No carro, no posto, no fundo da loja. Conexão instável 3G/4G. Raramente na mesa.
- **Goals prioritários:**
- Bater meta mensal sem depender do escritório para informações
- Ter clareza absoluta da Carteira — saber sem pensar quem comprou, quando e quanto
- Lançar Pedido em menos de 60 segundos, mesmo sem sinal
- **Frustrações que o produto resolve:**
- Perde Pedido por bug ou ausência de sinal no app legado
- Comissão é mistério até o fechamento do mês
- Não sabe quais Clientes estão esfriando antes que seja tarde demais
- Precisa ligar para o escritório para consultar histórico de Cliente
### Sandra — Supervisora *(cockpit secundário)*
- **Perfil:** 3555 anos, gerente comercial, coordena 530 Representantes, desktop o dia todo
- **Device:** Desktop-first · PWA mobile-light (Aprovações em reuniões/almoço)
- **Contexto de uso:** Abre o SAR às 8h30. Checagem diária, Aprovações durante reuniões, fechamento de semana.
- **Goals prioritários:**
- Saber o que está acontecendo na rua sem precisar ligar para os Reps
- Aprovar descontos com contexto real (histórico, margem, alçada) em segundos
- Identificar problemas no time antes que virem perda de Cliente
- **Frustrações que o produto resolve:**
- Aprova desconto no WhatsApp sem nenhum contexto
- Descobre tarde demais que um Cliente-chave parou de comprar
- Não tem visão consolidada dos Pedidos do dia em tempo real
---
## 4. Escopo do MVP
### Dentro do escopo
| # | Capacidade | Cockpit |
|---|-----------|---------|
| C1 | Autenticação e acesso por papel | Todos |
| C2 | Consulta de Clientes (lista + ficha) | Rafael |
| C3 | Consulta de Pedidos históricos | Rafael |
| C4 | Lançamento de Pedido novo (online + offline) | Rafael |
| C5 | Fluxo de Aprovação de desconto | Rafael → Sandra |
| C6 | Push notification de Aprovação | Sandra → Rafael |
| C7 | Painel Rafael (meta, KPIs, alertas de Inativo) | Rafael |
| C8 | Painel Sandra (Pedidos do dia, fila de Aprovações) | Sandra |
| C9 | Onboarding de workspace (admin interno JCS) | — |
### Fora do escopo (MVP)
- Cockpit Daniel (BI + IA estratégica) → placeholder de tela apenas
- Cockpit Alice (campanhas, ICMS-ST, pautas) → placeholder de tela apenas
- Editor visual de campanhas no-code
- Assistente IA por NCM/UF para ICMS-ST
- WhatsApp conversacional bidirecional (apenas Share API nativo)
- App nativo iOS/Android
- Integração automática com ERPs externos
- Benchmark cross-tenant
- Agenda + check-in GPS *(desejável; entra se não comprometer prazo)*
---
## 5. Jornadas de Usuário
### UJ-1 — Rafael lança Pedido em campo (fluxo principal)
> Rafael está na frente do comprador na distribuidora. Abre o SAR no celular (4G fraco).
1. Rafael abre a ficha do Cliente pelo nome — vê histórico e limite de crédito disponível
2. Toca em "Novo Pedido" — catálogo carrega do cache offline
3. Adiciona produtos por busca ou lista de favoritos
4. O sistema sugere o desconto máximo dentro da alçada de Rafael
5. Rafael aplica desconto de 8% (dentro da alçada de 10%) — Pedido vai direto para confirmação
6. Rafael confirma — Pedido entra na fila offline com Idempotency-Key
7. Ao recuperar sinal, o Pedido sincroniza automaticamente — Rafael recebe confirmação silenciosa
8. Cliente recebe resumo via Share API (WhatsApp nativo do celular de Rafael)
*Variação offline:* os passos 6 e 7 ocorrem com delay. O Pedido aparece como "pendente sincronização" na lista.
### UJ-2 — Rafael solicita Aprovação de desconto acima da alçada
> Rafael quer dar 15% de desconto; sua alçada é 10%.
1. Rafael digita 15% — o sistema avisa "acima da sua alçada (10%). Enviar para Aprovação?"
2. Rafael confirma — Pedido vai para Sandra com status `aprovação pendente`
3. Sandra recebe push notification com contexto: Cliente, valor, desconto solicitado, histórico
4. Sandra abre a notificação no celular (ou no Painel desktop) — vê histórico do Cliente
5. Sandra aprova ou recusa com um toque, podendo ajustar o percentual
6. Rafael recebe push notification: "Pedido #1234 aprovado — 13%" e o Pedido entra em sincronização
### UJ-3 — Sandra monitora o dia (fluxo diário)
> Sandra chega às 8h30 e abre o SAR no notebook.
1. Painel mostra: Pedidos do dia, fila de Aprovações pendentes, alertas de Inativos por Rep
2. Sandra vê que o Rep Marcos tem 3 Clientes Inativos há mais de 60 dias — acessa a lista
3. Abre a fila de Aprovações: 2 Pedidos aguardando. Resolve os dois com contexto visível
4. À tarde: recebe push no celular durante o almoço — Aprovação urgente. Resolve em segundos
---
## 6. Requisitos Funcionais
### C1 — Autenticação e Acesso
**FR-1.1** O sistema autentica usuários via master-login (IdP OAuth2/OIDC próprio da JCS).
**FR-1.2** Cada usuário tem exatamente um papel no workspace: `representante` · `supervisor` · `dono` · `admin`. O papel define qual cockpit é exibido e quais operações são permitidas.
**FR-1.3** O acesso a workspaces é isolado fisicamente por banco de dados (BD-por-workspace, ADR 0006). Nenhum usuário acessa dados de outro workspace, nem mesmo administradores da JCS.
**FR-1.4** O sistema bloqueia acesso e retorna HTTP 404 (não 403) quando o usuário autenticado não tem permissão sobre um recurso — para não vazar a existência do recurso.
**FR-1.5** [ASSUMPTION] Criação de usuários e workspaces é feita por administrador interno da JCS (não self-service no MVP). Onboarding assistido.
---
### C2 — Consulta de Clientes
**FR-2.1** O Rep lista todos os Clientes da sua Carteira com busca por nome, razão social e CNPJ/CPF.
**FR-2.2** A lista exibe, para cada Cliente: nome, última compra (data + valor), status de atividade (`ativo` · `em alerta` · `inativo`) e se há Pedidos em aberto.
**FR-2.3** O status de atividade é calculado automaticamente: `inativo` = sem Pedido Faturado há mais de 60 dias; `em alerta` = 3060 dias; `ativo` = menos de 30 dias. [ASSUMPTION] Thresholds configuráveis por workspace pelo admin.
**FR-2.4** Ao abrir a ficha do Cliente, o Rep vê:
- Dados cadastrais: nome, CNPJ/CPF, endereço de entrega, telefone, e-mail
- Situação financeira resumida (`regular` · `atenção` · `bloqueado`) — disponível offline (cacheada)
- Limite de crédito disponível (valor numérico) — **requer conexão**; exibido com disclaimer "dados de até [hora da última sync]" quando offline
- Histórico de inadimplência — **requer conexão**; não cacheado offline
- Últimos 10 Pedidos com status e valor
- Comissão gerada pelo Cliente no mês atual e no mês anterior
**FR-2.5** A lista de Clientes, dados cadastrais, situação financeira resumida e os últimos 10 Pedidos estão disponíveis offline após a última sincronização. Dados financeiros sensíveis (limite de crédito numérico, inadimplência) requerem conexão.
**FR-2.6** [ASSUMPTION] O cadastro de Clientes é sincronizado do ERP legado da empresa. O Rep não cria nem edita Clientes no MVP.
---
### C3 — Consulta de Pedidos Históricos
**FR-3.1** O Rep visualiza todos os Pedidos da sua Carteira com filtros por: Cliente, status, período (padrão: últimos 90 dias) e número do Pedido.
**FR-3.2** Cada Pedido exibe: número, Cliente, data de emissão, status (`orçamento` · `aprovação pendente` · `aprovado` · `faturado` · `cancelado`), valor total e desconto aplicado.
**FR-3.3** Ao abrir um Pedido, o Rep vê: itens (produto, quantidade, preço unitário, desconto, subtotal), status de Aprovação (com quem está e desde quando, se pendente) e histórico de alterações de status.
**FR-3.4** Pedidos com status `aprovação pendente` têm indicador visual destacado na lista.
**FR-3.5** O histórico dos últimos 90 dias está disponível offline após sincronização. Pedidos mais antigos requerem conexão.
---
### C4 — Lançamento de Pedido Novo
**FR-4.1** O Rep inicia um novo Pedido a partir da ficha do Cliente ou da tela inicial.
**FR-4.2** O fluxo de lançamento funciona completamente offline. Pedidos criados sem conexão são enfileirados localmente (IndexedDB) e sincronizados automaticamente quando o sinal é restaurado.
**FR-4.3** Cada Pedido é identificado por um `Idempotency-Key` gerado localmente antes do envio, garantindo que retentativas de sync não criem Pedidos duplicados.
**FR-4.4** O catálogo de produtos (código, descrição, preço de tabela, estoque disponível, foto) fica cacheado localmente para uso offline. [ASSUMPTION] A sincronização do catálogo ocorre ao abrir o app com conexão ativa, com TTL de 4h.
**FR-4.5** O Rep adiciona produtos ao Pedido por: busca por nome/código, lista de favoritos pessoais ou lista dos produtos mais comprados pelo Cliente.
**FR-4.6** Para cada item, o Rep define quantidade e percentual de desconto. O sistema exibe o preço resultante e o subtotal em tempo real.
**FR-4.7** O sistema valida o desconto contra a alçada do Rep **antes** da submissão:
- Dentro da alçada: Pedido segue direto para confirmação (FR-4.8)
- Acima da alçada: Pedido entra no fluxo de Aprovação (C5)
- [OQ-2] Se a alçada variar por linha de produto, a validação ocorre item a item; o Pedido só vai para Aprovação se ao menos um item exceder a alçada da sua linha.
**FR-4.8** Na tela de confirmação, o Rep vê: resumo dos itens, valor total, desconto médio, status do limite de crédito do Cliente e botão "Confirmar Pedido".
**FR-4.9** Após confirmação, o sistema:
- Registra o Pedido com status `orçamento` (dentro da alçada) ou `aprovação pendente` (acima da alçada)
- Exibe confirmação imediata ao Rep, mesmo que a sincronização ainda não tenha ocorrido
- Disponibiliza opção de compartilhar resumo do Pedido via Share API (WhatsApp nativo)
**FR-4.10** O Rep visualiza Pedidos pendentes de sync em uma lista local, com indicação clara de "aguardando conexão".
**FR-4.11** Se o servidor rejeitar um Pedido da fila offline (produto inativo, Cliente bloqueado, pauta vencida), o Pedido retorna ao Rep com status `falha de sync` e motivo legível em linguagem humana. O Pedido nunca é descartado silenciosamente — fica visível na fila com opção de editar e reenviar ou cancelar.
**FR-4.12** [ASSUMPTION] A alçada de desconto é configurada por Rep pelo admin do workspace. Default: 5%.
---
### C5 — Fluxo de Aprovação de Desconto
**FR-5.1** Quando um Pedido exige Aprovação, o supervisor responsável recebe push notification e o Pedido é incluído na fila de Aprovações.
**FR-5.2** A fila de Aprovações exibe, para cada Pedido pendente: Rep, Cliente, valor total, desconto solicitado vs. alçada, tempo aguardando e indicador de urgência (> 2h sem resposta).
**FR-5.3** Ao abrir um Pedido para aprovar, o supervisor vê:
- Resumo do Pedido (itens, valores, desconto)
- Histórico do Cliente: últimas compras, inadimplência, volume no período
- Alçada do Rep e justificativa do desconto (campo livre, opcional)
**FR-5.4** O supervisor pode: **aprovar** (com o desconto solicitado), **aprovar com ajuste** (definir percentual diferente) ou **recusar** (com motivo obrigatório).
**FR-5.5** Após a decisão:
- O Rep recebe push notification com o resultado
- O status do Pedido é atualizado em tempo real no Painel de ambos
- Se aprovado, o Pedido avança para status `aprovado`; se recusado, retorna ao Rep com o motivo
**FR-5.6** [ASSUMPTION] No MVP, qualquer supervisor do workspace pode aprovar qualquer Pedido da sua equipe. Hierarquia de Aprovação com múltiplos níveis é pós-MVP.
---
### C6 — Notificações e Push
**FR-6.1** O sistema envia Web Push Notification para:
- Sandra: novo Pedido aguardando Aprovação (com preview: Rep, Cliente, valor)
- Rafael: Aprovação concedida ou recusada (com resultado e eventual ajuste)
**FR-6.2** Notificações push funcionam com o navegador em background (PWA).
**FR-6.3** O sistema exibe um badge de contagem no ícone de notificações na Topbar com o total de itens pendentes de ação.
**FR-6.4** O Rep compartilha o resumo de um Pedido confirmado via Share API do browser (abre o WhatsApp nativo ou qualquer app de mensagens do dispositivo). [ASSUMPTION] O conteúdo compartilhado é texto formatado com os itens e o valor total; link para visualização futura é pós-MVP.
---
### C7 — Painel Rafael
**FR-7.1** O Painel do Rep exibe, ao abrir o app:
- Saudação com nome e data atual
- Meta do mês (valor atingido / valor total, percentual e progresso visual)
- Valor faltante para atingir a meta
- Comissão acumulada no mês (valor fixo + FLEX, quando aplicável)
**FR-7.2** O Painel lista os Clientes Inativos da Carteira do Rep, ordenados por dias sem compra (decrescente). Clientes com mais de 60 dias têm destaque visual.
**FR-7.3** O Painel exibe os Pedidos recentes (últimos 7 dias) com status e indicação de pendentes de sync.
**FR-7.4** Todos os dados do Painel são acessíveis offline após a última sincronização. A data e hora da última sync são visíveis.
---
### C8 — Painel Sandra
**FR-8.1** O Painel da supervisora exibe, ao abrir o app:
- Fila de Aprovações pendentes (ordenada por tempo aguardando)
- Resumo dos Pedidos do dia da equipe: total de Pedidos, valor consolidado e comparativo com a mesma semana do mês anterior
- Alertas de Clientes Inativos por Rep (top 3 Reps com maior número de Inativos)
**FR-8.2** O Painel atualiza em tempo real via Socket.IO/SSE: novos Pedidos, mudanças de status e novas Aprovações pendentes.
**FR-8.3** [ASSUMPTION] No MVP, Sandra não acessa fichas de Clientes individuais nem histórico de Pedidos por Rep diretamente — apenas o que aparece no Painel e na fila de Aprovações. Drill-down por Rep é próxima iteração.
---
## 7. Requisitos Não-Funcionais
### Performance
**NFR-1.1** Operações CRUD (consulta de Cliente, histórico de Pedidos, confirmação de Pedido): p99 < 800 ms.
**NFR-1.2** Carregamento inicial do Painel (dados do dia): p99 < 2 s com conexão 4G.
**NFR-1.3** Sincronização de Pedido offline pendente após retorno de conexão: início do envio em menos de 5 s.
**NFR-1.4** Atualização em tempo real no Painel da Sandra (novo Pedido aparece): menos de 3 s após o evento.
### Offline (Rafael)
**NFR-2.1** Rafael consulta Clientes, visualiza histórico e lança Pedidos completos sem nenhuma conexão de rede, usando os dados da última sincronização.
**NFR-2.2** Pedidos criados offline são persistidos no IndexedDB com `Idempotency-Key` gerado localmente, garantindo envio único quando a conexão é restaurada.
**NFR-2.3** O app detecta automaticamente a perda e o retorno de conexão e sincroniza a fila sem ação do usuário.
**NFR-2.4** Em nenhuma circunstância um Pedido pode ser perdido silenciosamente. Falhas de sync devem ser exibidas visivelmente para o Rep.
### Segurança e LGPD
**NFR-3.1** PII de Clientes (CPF/CNPJ, telefone, e-mail) é redactada nos logs de aplicação.
**NFR-3.2** Dados de Clientes e Pedidos são fisicamente isolados por workspace (BD-por-workspace, ADR 0006). Nenhuma query atravessa workspaces.
**NFR-3.3** O armazenamento offline (IndexedDB) contém apenas os dados mínimos necessários para o fluxo de lançamento de Pedido. Dados financeiros sensíveis (limite de crédito completo, histórico de inadimplência) requerem conexão.
**NFR-3.4** Tokens JWT são armazenados em memória (nunca em localStorage). Refresh token em cookie `httpOnly; Secure; SameSite=Lax`.
**NFR-3.5** Rate limit em endpoints de autenticação: 5 tentativas/min/IP.
**NFR-3.6** Todo opt-in para Web Push é explícito, com possibilidade de revogar a qualquer momento.
### Acessibilidade
**NFR-4.1** Interfaces seguem WCAG AA para contraste, tamanho de alvo touch (mínimo 44 px) e suporte a leitor de tela.
**NFR-4.2** Score Lighthouse ≥ 90 em Performance, Acessibilidade e Best Practices — gate obrigatório de CI.
### Compatibilidade
**NFR-5.1** Rafael: Safari iOS 17+ e Chrome Android 120+ (PWA com offline, Web Push, Share API, Geolocation).
**NFR-5.2** Sandra: Chrome 120+ e Safari 17+ em desktop. Layout responsivo até 1280 px.
**NFR-5.3** [ASSUMPTION] Sem suporte a IE ou browsers legados.
### Disponibilidade
**NFR-6.1** SLA de disponibilidade: 99,5% em horário comercial (7h21h BRT, segsáb).
**NFR-6.2** Janela de manutenção: domingos das 2h6h BRT, comunicada com 48h de antecedência.
---
## 8. Integrações
### 8.1 Master-login (IdP JCS) — *Obrigatório*
Autenticação e gestão de usuários/workspaces via OAuth2/OIDC. Usuários e papéis são gerenciados pelo IdP; o SAR atua como resource server.
### 8.2 WhatsApp — *Share API nativa (MVP)*
O compartilhamento de Pedidos usa a Web Share API nativa do device (abre WhatsApp ou qualquer app de mensagens). Nenhuma integração com Meta Cloud API no MVP. [ASSUMPTION] WhatsApp Business API (mensagens programáticas) entra na próxima iteração.
### 8.3 ERP Legado — *Sync via importação*
[ASSUMPTION] Catálogo de produtos, pautas de preço e cadastro de Clientes são importados do ERP legado via arquivo (CSV/JSON) ou endpoint configurável pelo admin. O SAR é o sistema de registro dos Pedidos novos; o ERP continua sendo o sistema de faturamento. Integração bidirecional automática é pós-MVP.
### 8.4 Web Push — *Nativo*
Via Push API do browser + Service Worker. Sem provedor SaaS externo de push no MVP.
---
## 9. Restrições Técnicas e de Design
- **Stack:** STACK.md v2.2 é canônica e imutável sem RFC. Node 24 · Nest 11 · Prisma 7 · React 19.2 · AntD 6.4 · PostgreSQL 18 · multi-tenancy BD-por-workspace.
- **Brand:** `brand.md` é canônico. Paleta JCS Blue `#004a99`, Plus Jakarta Sans, Topbar 80 px + Sidebar 260 px, radius 12/20 px.
- **Vocabulário do produto:** Cliente · Representante/Rep · Orçamento · Pedido · Faturado · Visita · Carteira · Inativo · Painel · Aprovação. Nunca "Lead", "Prospect" ou "Ticket".
- **Infraestrutura:** Proxmox on-prem Brasil. Sem AWS/GCP/Azure. Cloudflare como CDN/proxy.
- **LGPD by design:** datacenter BR, PII criptografada, redact em logs, Art. 18 implementado.
- **Ambiente de desenvolvimento:** Docker Compose dev com Postgres 18, Valkey 8, MinIO e Mailpit — todos com healthcheck validado.
---
## 10. Questões Abertas
| # | Questão | Impacto | Responsável | Prazo |
|---|---------|---------|-------------|-------|
| OQ-1 | Catálogo de produtos e pautas de preço: formato e frequência de importação do ERP legado? | Alto — bloqueia FR-4.4 | Julian + primeiro cliente | Antes do design de C4 |
| OQ-2 | Alçada de desconto: é fixa por Rep ou pode variar por linha de produto? | Médio — impacta FR-4.7 e FR-4.11 | Julian | Antes de C4/C5 |
| OQ-3 | Comissão FLEX: a fórmula de cálculo está documentada? | Médio — impacta FR-7.1 | Julian | Antes de C7 |
| ~~OQ-4~~ | ~~Limite de crédito: é calculado no SAR ou importado do ERP?~~ | ✅ **RESOLVIDO 2026-05-27** — gerenciado no SAR (admin/supervisor define; SAR é fonte da verdade) | Julian | — |
| OQ-5 | Múltiplos supervisores: se houver mais de um no workspace, como se distribui a fila de Aprovações? | Médio — impacta FR-5.1 | Julian | Antes de C5 |
| OQ-6 | Catálogo offline: TTL de 4h é aceitável para o primeiro cliente? Há risco de Rep vender produto fora de pauta? | Médio — impacta FR-4.4 | Julian + primeiro cliente | Antes de C4 |
---
## 11. Fora do Escopo (explícito)
- Cockpit Daniel (dashboard executivo + IA estratégica) — tela placeholder apenas
- Cockpit Alice (campanhas, ICMS-ST, pautas, cadastros) — tela placeholder apenas
- Editor visual de campanhas no-code
- Assistente IA por NCM/UF
- WhatsApp conversacional bidirecional (Meta Cloud API)
- Agenda e roteamento de visitas com GPS
- App nativo iOS ou Android
- Integração automática bidirecional com ERP
- Multi-empresa por workspace (uma empresa por workspace no MVP)
- Benchmark cross-tenant anonimizado
- Dark mode *(desejável, não bloqueante)*
- Relatórios exportáveis (PDF/Excel)
---
*Documento gerado em 2026-05-27 via bmad-prd (Fast Path). Assumptions marcados — revisar com Julian antes do início de C4 e C5.*

View File

@@ -2,6 +2,7 @@
"name": "api-e2e", "name": "api-e2e",
"$schema": "../../node_modules/nx/schemas/project-schema.json", "$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application", "projectType": "application",
"tags": ["scope:api", "type:e2e", "domain:shared"],
"implicitDependencies": ["api"], "implicitDependencies": ["api"],
"targets": { "targets": {
"e2e": { "e2e": {

View File

@@ -2,5 +2,14 @@
"name": "@sar/api", "name": "@sar/api",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"description": "SAR · API (NestJS 11 — CommonJS conforme CODING-RULES.md PGD-DB-004)" "description": "SAR · API (NestJS 11 — CommonJS conforme CODING-RULES.md PGD-DB-004)",
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"web-push": "^3.6.7"
},
"devDependencies": {
"@types/web-push": "^3.6.4"
}
} }

15
apps/api/prisma.config.ts Normal file
View File

@@ -0,0 +1,15 @@
// Prisma 7 config — usado pelo CLI (migrate, generate, studio).
// Conexão de runtime fica no WorkspacePrismaPool (adapter por workspace).
// CODING-RULES PGD-DB-001: DATABASE_URL aponta direto ao PG na porta 5432 (sem PgBouncer).
import path from 'node:path';
import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: path.join(import.meta.dirname, 'prisma/schema.prisma'),
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'],
},
});

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

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -0,0 +1,287 @@
// SAR — Schema no banco ERP da JCS (schema `sar` dentro do PostgreSQL do SIG/gestao)
// ADR 0006 revogado: banco separado por workspace → schema `sar` no ERP JCS.
// O isolamento multi-tenant é por `id_empresa` em todas as tabelas.
//
// CODING-RULES PGD-DB-004: moduleFormat = "cjs" (NestJS é CJS)
// CODING-RULES PGD-DB-001: MIGRATION_DATABASE_URL aponta direto ao PG (sem PgBouncer)
// A URL de runtime deve incluir ?schema=sar (injetado pelo JwtAuthGuard via WorkspacePrismaPool)
generator client {
provider = "prisma-client-js"
output = "../../../node_modules/.prisma/client"
moduleFormat = "cjs"
}
// Prisma 7: url removida do schema — conexão em prisma.config.ts (migrate)
// e no WorkspacePrismaPool via PrismaPg adapter (runtime).
// A URL de runtime inclui ?schema=sar para rotear ao schema correto.
datasource db {
provider = "postgresql"
}
// ─── Pedido (C3) ─────────────────────────────────────────────────────────────
//
// Pedido emitido pelo Rep. Situa: 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado.
// idEmpresa: tenant (empresa no ERP). codVendedor: gestao.vendedor.codigo.
// idCliente: sig.corrent.id_corrent. numPedSar: sequencial SAR (SAR-NNNNN).
model Pedido {
id String @id @default(uuid()) @db.Uuid
idEmpresa Int @map("id_empresa")
numPedSar String @unique @map("num_ped_sar")
idCliente Int @map("id_cliente")
codVendedor Int @map("cod_vendedor")
situa Int @default(1)
dtPedido DateTime @default(now()) @db.Date @map("dt_pedido")
idPauta Int? @map("id_pauta")
codFormapag Int? @map("cod_formapag")
totalProdutos Decimal @default(0) @db.Decimal(15, 2) @map("total_produtos")
totalIpi Decimal @default(0) @db.Decimal(15, 2) @map("total_ipi")
totalIcmsst Decimal @default(0) @db.Decimal(15, 2) @map("total_icmsst")
total Decimal @default(0) @db.Decimal(15, 2)
descontoPerc Decimal @default(0) @db.Decimal(5, 2) @map("desconto_perc")
descontoValor Decimal @default(0) @db.Decimal(15, 2) @map("desconto_valor")
acrescimo Decimal @default(0) @db.Decimal(15, 2)
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")
idempotencyKey String? @unique @map("idempotency_key")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
itens PedidoItem[]
historico HistoricoPedido[]
@@index([idEmpresa])
@@index([codVendedor])
@@index([idCliente])
@@index([situa])
@@index([dtPedido])
@@map("pedidos")
}
// ─── PedidoItem (C3/C4) ──────────────────────────────────────────────────────
//
// Item do pedido. Produto desnormalizado via idProduto (vw_produtos).
model PedidoItem {
id String @id @default(uuid()) @db.Uuid
idPedido String @db.Uuid @map("id_pedido")
ordem Int
idProduto Int @map("id_produto")
codProduto String? @map("cod_produto")
descProduto String? @map("desc_produto")
qtd Decimal @db.Decimal(10, 3)
precoUnitario Decimal @db.Decimal(15, 2) @map("preco_unitario")
descontoPerc Decimal @default(0) @db.Decimal(5, 2) @map("desconto_perc")
descontoValor Decimal @default(0) @db.Decimal(15, 2) @map("desconto_valor")
precoPauta Decimal @default(0) @db.Decimal(15, 2) @map("preco_pauta")
comissao Decimal @default(0) @db.Decimal(15, 2)
vlFlex Decimal @default(0) @db.Decimal(15, 2) @map("vl_flex")
precoComIpi Decimal @default(0) @db.Decimal(15, 2) @map("preco_com_ipi")
vlIpi Decimal @default(0) @db.Decimal(15, 2) @map("vl_ipi")
vlIcmsst Decimal @default(0) @db.Decimal(15, 2) @map("vl_icmsst")
total Decimal @db.Decimal(15, 2)
pedido Pedido @relation(fields: [idPedido], references: [id], onDelete: Cascade)
@@index([idPedido])
@@map("pedido_itens")
}
// ─── HistoricoPedido (C3) ────────────────────────────────────────────────────
//
// Registro imutável de cada transição de situa. changedBy = cod_vendedor do ator.
model HistoricoPedido {
id String @id @default(uuid()) @db.Uuid
idPedido String @db.Uuid @map("id_pedido")
situaAnterior Int? @map("situa_anterior")
situaNova Int @map("situa_nova")
changedBy Int @map("changed_by")
nota String?
changedAt DateTime @default(now()) @map("changed_at")
pedido Pedido @relation(fields: [idPedido], references: [id], onDelete: Cascade)
@@index([idPedido])
@@map("historico_pedido")
}
// ─── AlcadaDesconto (C4) ─────────────────────────────────────────────────────
//
// Alçada de desconto por vendedor, empresa e grupo de produto.
// codGrupo = 0 → limite global/default do rep.
model AlcadaDesconto {
codVendedor Int @map("cod_vendedor")
idEmpresa Int @map("id_empresa")
codGrupo Int @default(0) @map("cod_grupo")
limitePerc Decimal @default(5) @db.Decimal(5, 2) @map("limite_perc")
updatedAt DateTime @updatedAt @map("updated_at")
@@id([codVendedor, idEmpresa, codGrupo])
@@index([codVendedor, idEmpresa])
@@map("alcada_desconto")
}
// ─── MetaRepresentante (C7) ──────────────────────────────────────────────────
//
// Meta mensal e taxas de comissão por rep. Uma linha por rep/empresa/mês.
model MetaRepresentante {
codVendedor Int @map("cod_vendedor")
idEmpresa Int @map("id_empresa")
ano Int
mes Int
metaValor Decimal @db.Decimal(15, 2) @map("meta_valor")
taxaComissao Decimal @default(3) @db.Decimal(5, 2) @map("taxa_comissao")
taxaFlex Decimal @default(1) @db.Decimal(5, 2) @map("taxa_flex")
updatedAt DateTime @updatedAt @map("updated_at")
@@id([codVendedor, idEmpresa, ano, mes])
@@index([codVendedor, idEmpresa])
@@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.
// codVendedor desnormalizado do JWT para filtrar destinatários.
model PushSubscription {
id String @id @default(uuid()) @db.Uuid
codVendedor Int? @map("cod_vendedor")
idEmpresa Int @map("id_empresa")
role String
endpoint String @unique
p256dh String
auth String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([idEmpresa])
@@index([codVendedor])
@@map("push_subscription")
}

View File

@@ -1,28 +1,50 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { APP_FILTER, APP_PIPE } from '@nestjs/core'; import { APP_FILTER, APP_GUARD, APP_PIPE } from '@nestjs/core';
import { ZodValidationPipe } from 'nestjs-zod'; import { ZodValidationPipe } from 'nestjs-zod';
import { EnvModule } from './config/env.module'; import { EnvModule } from './config/env.module';
import { LoggerModule } from './logger/logger.module'; import { LoggerModule } from './logger/logger.module';
import { WorkspaceModule } from './workspace/workspace.module'; import { WorkspaceModule } from './workspace/workspace.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { PingModule } from './ping/ping.module'; import { PingModule } from './ping/ping.module';
import { AuthModule } from './auth/auth.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { ClientsModule } from './clients/clients.module';
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'; import { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({ @Module({
imports: [ imports: [
// Ordem importa: Env primeiro (fail-fast), depois Logger, CLS, módulos de domínio. // Ordem: Env primeiro (fail-fast), depois Logger, CLS, Auth, módulos de domínio.
EnvModule, EnvModule,
LoggerModule, LoggerModule,
WorkspaceModule, WorkspaceModule,
AuthModule,
HealthModule, HealthModule,
PingModule, PingModule,
ClientsModule,
OrdersModule,
CatalogModule,
DashboardModule,
NotificationsModule,
ReportsModule,
PoliticasModule,
EquipeModule,
FunilModule,
SacModule,
], ],
providers: [ providers: [
// Pipe global: nestjs-zod converte ZodSchema (via createZodDto) em validação automática.
// CODING-RULES §06: schema é o contrato; DTO é a classe que o expõe.
{ provide: APP_PIPE, useClass: ZodValidationPipe }, { provide: APP_PIPE, useClass: ZodValidationPipe },
// Filter global: RFC 9457. Zod → 422.
{ provide: APP_FILTER, useClass: ProblemDetailsFilter }, { provide: APP_FILTER, useClass: ProblemDetailsFilter },
// Guard global — exige JWT em todas as rotas exceto as com @Public().
{ provide: APP_GUARD, useClass: JwtAuthGuard },
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -0,0 +1,34 @@
import { Controller, Get } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { UserProfile } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import type { PrismaClient } from '@prisma/client';
@Controller({ path: 'auth' })
export class AuthController {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
@Get('me')
async me(): Promise<UserProfile> {
const prisma = this.cls.get('prisma') as PrismaClient;
const userId = this.cls.get('userId') ?? '';
const role = this.cls.get('role') ?? 'rep';
const idEmpresa = this.cls.get('idEmpresa');
// Representante é cadastro global (sem id_empresa).
const rows = await prisma.$queryRaw<{ codigo: number; nome: string }[]>`
SELECT codigo, nome
FROM sar.vw_representantes
WHERE codigo = ${parseInt(userId, 10)}
LIMIT 1
`;
const row = rows[0];
return {
codVendedor: row?.codigo ?? parseInt(userId, 10),
nome: row?.nome ?? userId,
role,
idEmpresa,
};
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { WorkspaceModule } from '../workspace/workspace.module';
import { JwtAuthGuard } from './jwt-auth.guard';
import { DevAuthController } from './dev-auth.controller';
import { AuthController } from './auth.controller';
@Module({
imports: [WorkspaceModule],
controllers: [DevAuthController, AuthController],
providers: [JwtAuthGuard],
exports: [JwtAuthGuard],
})
export class AuthModule {}

View File

@@ -0,0 +1,49 @@
import { Body, Controller, HttpCode, HttpStatus, NotFoundException, Post } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SignJWT } from 'jose';
import { createZodDto } from 'nestjs-zod';
import { DevTokenRequestSchema, type AuthTokenResponse } from '@sar/api-interface';
import type { Env } from '../config/env.schema';
import { Public } from './public.decorator';
class DevTokenRequestDto extends createZodDto(DevTokenRequestSchema) {}
// Dev-only stub — emite JWT HS256 para smoke tests locais.
// CODING-RULES PGD-SEC-002: retorna 404 em produção.
// CODING-RULES PGD-AUTHZ-002: id_empresa vem do body aqui APENAS porque
// este endpoint É o gerador do token — nenhum outro handler pode fazer isso.
// ADR 0006 revogado: workspaceId → idEmpresa (Int da empresa no ERP)
@Public()
@Controller({ path: 'auth/dev' })
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')
@HttpCode(HttpStatus.OK)
async token(@Body() dto: DevTokenRequestDto): Promise<AuthTokenResponse> {
if (this.isProd) throw new NotFoundException();
const accessToken = await new SignJWT({
id_empresa: dto.idEmpresa ?? this.devEmpresaId,
role: dto.role,
})
.setProtectedHeader({ alg: 'HS256' })
.setSubject(dto.userId)
.setIssuedAt()
.setExpirationTime(`${this.expiresIn}s`)
.sign(this.secret);
return { accessToken, tokenType: 'Bearer', expiresIn: this.expiresIn };
}
}

View File

@@ -0,0 +1,93 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { ClsService } from 'nestjs-cls';
import { jwtVerify } from 'jose';
import type { Request } from 'express';
import type { Env } from '../config/env.schema';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { WorkspacePrismaPool } from '../workspace/workspace-prisma-pool.service';
import type { JwtPayload } from './jwt.types';
import { IS_PUBLIC_KEY } from './public.decorator';
// Guard global (APP_GUARD). Valida Bearer HS256 e atualiza CLS com idEmpresa real.
// CODING-RULES PGD-AUTHZ-002: idEmpresa sempre do JWT, nunca de body/param.
// Ordem NestJS: middleware CLS (idEmpresa default) → este guard (idEmpresa real).
// ADR 0006 revogado: workspace_id → id_empresa; URL inclui ?schema=sar
@Injectable()
export class JwtAuthGuard implements CanActivate {
private readonly secret: Uint8Array;
private readonly isProd: boolean;
private readonly devRepCode: string;
private readonly devEmpresaId: number;
constructor(
private readonly reflector: Reflector,
private readonly cls: ClsService<WorkspaceClsStore>,
private readonly pool: WorkspacePrismaPool,
config: ConfigService<Env, true>,
) {
this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true }));
this.isProd = config.get('NODE_ENV', { infer: true }) === 'production';
this.devRepCode = String(config.get('DEV_REP_CODE', { infer: true }));
this.devEmpresaId = config.get('DEV_EMPRESA_ID', { infer: true });
}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (this.isPublic(context)) return true;
const req = context.switchToHttp().getRequest<Request>();
const token = this.extractBearer(req);
if (!token) {
throw new UnauthorizedException('token ausente');
}
try {
const { payload } = await jwtVerify<JwtPayload>(token, this.secret, {
algorithms: ['HS256'],
});
(req as Request & { user: JwtPayload }).user = payload as JwtPayload;
// 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'];
if (!baseUrl) throw new Error('DATABASE_URL não configurada');
this.cls.set('prisma', this.pool.getOrCreate(idEmpresa, baseUrl));
return true;
} catch {
throw new UnauthorizedException('token inválido ou expirado');
}
}
private isPublic(ctx: ExecutionContext): boolean {
return (
this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
ctx.getHandler(),
ctx.getClass(),
]) === true
);
}
private extractBearer(req: Request): string | undefined {
const auth = req.headers['authorization'];
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
return auth.slice(7);
}
return undefined;
}
}

View File

@@ -0,0 +1,18 @@
// Claims do JWT emitido pelo master-login. Fonte da verdade para req.user.
// CODING-RULES PGD-AUTHZ-002: id_empresa vem sempre do token, nunca do body.
// ADR 0006 revogado: workspace_id → id_empresa (Int, empresa no ERP).
export type JwtRole = 'rep' | 'supervisor' | 'manager' | 'admin';
export interface JwtPayload {
sub: string; // userId / cod_vendedor como string
id_empresa: number; // empresa no ERP (era workspace_id)
role: JwtRole;
iat?: number;
exp?: number;
}
// Tipo auxiliar para requests autenticados — evita global namespace augmentation.
export interface AuthenticatedRequest {
user: JwtPayload;
}

View File

@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
/** Marca um controller ou handler como público — JwtAuthGuard não exige token. */
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,55 @@
import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import {
ProdutoListQuerySchema,
type EmpresaInfo,
type FormaPagamento,
type Municipio,
type Pauta,
type ProdutoDetail,
type ProdutoListQuery,
type ProdutoListResponse,
} from '@sar/api-interface';
import { CatalogService } from './catalog.service';
class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {}
// ADR 0006 revogado: UUID → Int para ID de produto. Sync removido (ERP direto via view).
@Controller({ path: 'catalog' })
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();
}
@Get('payment-methods')
formasPagamento(): Promise<FormaPagamento[]> {
return this.catalog.formasPagamento();
}
@Get('company')
company(): Promise<EmpresaInfo> {
return this.catalog.company();
}
@Get()
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery;
return this.catalog.list(parsed);
}
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number): Promise<ProdutoDetail> {
const product = await this.catalog.findOne(id);
if (!product) throw new NotFoundException(`Produto ${id} não encontrado`);
return product;
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CatalogController } from './catalog.controller';
import { CatalogService } from './catalog.service';
@Module({
controllers: [CatalogController],
providers: [CatalogService],
exports: [CatalogService],
})
export class CatalogModule {}

View File

@@ -0,0 +1,403 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
EmpresaInfo,
FormaPagamento,
Municipio,
Pauta,
ProdutoDetail,
ProdutoListQuery,
ProdutoListResponse,
ProdutoSummary,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
// ADR 0006 revogado: produtos lidos diretamente de vw_produtos (ERP) + vw_estoque.
// Sem sync — dados sempre frescos da view do ERP.
function escSql(s: string): string {
return s.replace(/'/g, "''");
}
// Produtos, pautas e estoque são por-empresa e vivem na MATRIZ. O ERP usa códigos
// de empresa > 9000 para origem de pedido (ex.: 9001), mas o cadastro fica na
// matriz correspondente (9001 → 1), espelhando o CASE de vw_pedidos_erp.
// Pedidos continuam usando idEmpresa cru; só o catálogo normaliza.
function matrizEmpresa(idEmpresa: number): number {
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
}
interface ProdutoRow {
id_erp: number;
codigo: string;
descricao: string;
unidade: string | null;
vl_preco1: string;
cod_grupo: number | null;
grupo: string | null;
cod_subgrupo: number | null;
subgrupo: string | null;
marca: string | null;
ativo: number;
qtd_estoque: string | null;
lista_pauta: number | null;
referencia: string | null;
descr_det: string | null;
vl_preco2: string | null;
vl_preco3: string | null;
aliq_ipi: string | null;
peso_liquido: string | null;
qtd_volume: string | null;
lote_mul_venda: number | null;
preco_promocional: string | null;
}
@Injectable()
export class CatalogService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
// Dados legais da empresa matriz que fatura o pedido (cabeçalho do PDF).
async company(): Promise<EmpresaInfo> {
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'));
interface Row {
id_empresa: number;
razao_social: string | null;
nome_fantasia: string | null;
cnpj: string | null;
inscr_estadual: string | null;
endereco: string | null;
numero: string | null;
complemento: string | null;
bairro: string | null;
cidade: string | null;
uf: string | null;
cep: string | null;
telefone: string | null;
email: string | null;
}
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT e.id_empresa,
TRIM(e.razao_social) AS razao_social,
TRIM(e.nome) AS nome_fantasia,
TRIM(e.cnpj) AS cnpj,
TRIM(e.inscr_estadual) AS inscr_estadual,
TRIM(e.endereco) AS endereco,
e.num_endereco::text AS numero,
e.complemento,
TRIM(e.bairro) AS bairro,
TRIM(m.nome) AS cidade,
TRIM(e.uf) AS uf,
e.cep,
e.telefone,
TRIM(e.email) AS email
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
`);
const r = rows[0];
if (!r) throw new Error(`Empresa matriz ${idEmpresa} não encontrada`);
const clean = (v: string | null) => {
const t = (v ?? '').trim();
return t === '' ? null : t;
};
return {
idEmpresa: Number(r.id_empresa),
razaoSocial: clean(r.razao_social) ?? clean(r.nome_fantasia) ?? `Empresa ${r.id_empresa}`,
nomeFantasia: clean(r.nome_fantasia),
cnpj: clean(r.cnpj),
inscricaoEstadual: clean(r.inscr_estadual),
endereco: clean(r.endereco),
numero: clean(r.numero),
complemento: clean(r.complemento),
bairro: clean(r.bairro),
cidade: clean(r.cidade),
uf: clean(r.uf),
cep: clean(r.cep),
telefone: clean(r.telefone),
email: clean(r.email),
};
}
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');
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
interface PautaRow {
id_pauta: number;
codigo: number;
descricao: string;
}
const rows = await prisma.$queryRawUnsafe<PautaRow[]>(`
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao
FROM vw_pautas pa
JOIN vw_representantes r ON pa.codigo IN (
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
)
WHERE pa.id_empresa = ${idEmpresa}
AND pa.ativo = 1
AND r.codigo = ${codVendedor}
ORDER BY pa.codigo
`);
return rows.map((r) => ({
idPauta: Number(r.id_pauta),
codigo: Number(r.codigo),
descricao: r.descricao,
}));
}
async list(query: ProdutoListQuery): Promise<ProdutoListResponse> {
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 { q, codGrupo, idPauta, page, limit } = query;
const offset = (page - 1) * limit;
const grupoFilter = codGrupo != null ? `AND p.cod_grupo = ${codGrupo}` : '';
const searchFilter = q
? `AND (p.descricao ILIKE '%${escSql(q)}%' OR p.codigo ILIKE '%${escSql(q)}%')`
: '';
// Com pauta: usa preço específico da pauta. Sem pauta: filtra vl_preco1 > 0.
if (idPauta != null) {
interface PautaItemRow extends ProdutoRow {
preco_pauta: string;
}
const [rows, countRows] = await Promise.all([
prisma.$queryRawUnsafe<PautaItemRow[]>(`
SELECT
p.id_erp, p.codigo, p.descricao, p.unidade,
pp.preco1::text AS preco_pauta,
p.cod_grupo, p.grupo, p.cod_subgrupo, p.subgrupo, p.marca,
p.ativo, e.qtd_estoque::text, p.lista_pauta,
p.referencia, p.descr_det, p.vl_preco2::text, p.vl_preco3::text,
p.aliq_ipi::text, p.peso_liquido::text, p.qtd_volume::text,
p.lote_mul_venda, p.preco_promocional::text
FROM vw_pauta_produtos pp
JOIN vw_produtos p ON p.id_erp = pp.id_produto AND p.id_empresa = ${idEmpresa}
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
WHERE pp.id_pauta = ${idPauta}
AND p.ativo = 1
AND pp.preco1 > 0
${grupoFilter}
${searchFilter}
ORDER BY p.descricao
LIMIT ${limit} OFFSET ${offset}
`),
prisma.$queryRawUnsafe<[{ count: string }]>(`
SELECT COUNT(*)::text AS count
FROM vw_pauta_produtos pp
JOIN vw_produtos p ON p.id_erp = pp.id_produto AND p.id_empresa = ${idEmpresa}
WHERE pp.id_pauta = ${idPauta} AND p.ativo = 1 AND pp.preco1 > 0
${grupoFilter} ${searchFilter}
`),
]);
const total = parseInt(countRows[0]?.count ?? '0', 10);
return {
data: rows.map((p) => this.mapRow(p, p.preco_pauta)),
total,
page,
limit,
};
}
// Sem pauta: produtos com preço base preenchido
const [rows, countRows] = await Promise.all([
prisma.$queryRawUnsafe<ProdutoRow[]>(`
SELECT
p.id_erp, p.codigo, p.descricao, p.unidade,
p.vl_preco1::text,
p.cod_grupo, p.grupo, p.cod_subgrupo, p.subgrupo, p.marca,
p.ativo, e.qtd_estoque::text, p.lista_pauta,
p.referencia, p.descr_det, p.vl_preco2::text, p.vl_preco3::text,
p.aliq_ipi::text, p.peso_liquido::text, p.qtd_volume::text,
p.lote_mul_venda, p.preco_promocional::text
FROM vw_produtos p
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
WHERE p.id_empresa = ${idEmpresa} AND p.ativo = 1 AND p.vl_preco1 > 0
${grupoFilter} ${searchFilter}
ORDER BY p.descricao
LIMIT ${limit} OFFSET ${offset}
`),
prisma.$queryRawUnsafe<[{ count: string }]>(`
SELECT COUNT(*)::text AS count FROM vw_produtos p
WHERE p.id_empresa = ${idEmpresa} AND p.ativo = 1 AND p.vl_preco1 > 0
${grupoFilter} ${searchFilter}
`),
]);
const total = parseInt(countRows[0]?.count ?? '0', 10);
return { data: rows.map((p) => this.mapRow(p, p.vl_preco1)), total, page, limit };
}
private mapRow(p: ProdutoRow, preco: string): ProdutoSummary {
return {
idErp: Number(p.id_erp),
codigo: (p.codigo ?? '').trim(),
descricao: (p.descricao ?? '').trim(),
unidade: p.unidade,
vlPreco1: preco ?? '0',
codGrupo: p.cod_grupo !== null ? Number(p.cod_grupo) : null,
grupo: p.grupo ? p.grupo.trim() : null,
codSubgrupo: p.cod_subgrupo !== null ? Number(p.cod_subgrupo) : null,
subgrupo: p.subgrupo ? p.subgrupo.trim() : null,
marca: p.marca ? p.marca.trim() : null,
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,
};
}
async findOne(idErp: number): Promise<ProdutoDetail | null> {
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;
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,
p.descricao,
p.unidade,
p.vl_preco1::text,
p.cod_grupo,
p.grupo,
p.cod_subgrupo,
p.subgrupo,
p.marca,
p.ativo,
e.qtd_estoque::text,
p.lista_pauta,
p.referencia,
p.descr_det,
p.vl_preco2::text,
p.vl_preco3::text,
p.aliq_ipi::text,
p.peso_liquido::text,
p.qtd_volume::text,
p.lote_mul_venda,
p.preco_promocional::text
FROM vw_produtos p
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;
return {
...this.mapRow(p, p.vl_preco1),
referencia: p.referencia,
descricaoDetalhada: p.descr_det,
vlPreco2: p.vl_preco2,
vlPreco3: p.vl_preco3,
aliqIpi: p.aliq_ipi,
pesoLiquido: p.peso_liquido,
qtdVolume: p.qtd_volume,
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,
})),
};
}
async formasPagamento(): Promise<FormaPagamento[]> {
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'));
interface Row {
codigo: number;
descricao: string;
num_parcelas: number | null;
tx_acrescimo: string;
}
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT codigo, TRIM(descricao) AS descricao, num_parcelas, tx_acrescimo::text
FROM sar.vw_formas_pagamento
WHERE id_empresa = ${idEmpresa}
AND ativa = 1
AND integrar_sar = 1
ORDER BY codigo
`);
return rows.map((r) => ({
codigo: Number(r.codigo),
descricao: r.descricao,
numParcelas: r.num_parcelas !== null ? Number(r.num_parcelas) : null,
txAcrescimo: r.tx_acrescimo ?? '0',
}));
}
}

View File

@@ -0,0 +1,102 @@
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 {
constructor(private readonly clients: ClientsService) {}
@Get()
list(@Query() query: ClientListQueryDto): Promise<ClientListResponse> {
const parsed = ClientListQuerySchema.parse(query) as ClientListQuery;
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

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ClientsController } from './clients.controller';
import { ClientsService } from './clients.service';
@Module({
controllers: [ClientsController],
providers: [ClientsService],
})
export class ClientsModule {}

View File

@@ -0,0 +1,734 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
ActivityStatus,
ClientDetail,
ClientListQuery,
ClientListResponse,
ClientNovoResult,
ClientSummary,
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;
const INACTIVE_DAYS = 60;
// Usado apenas por findOne (já tem dt_ultima_compra calculado pelo SQL)
function activityStatus(dtUltimaCompra: Date | null): ActivityStatus {
if (!dtUltimaCompra) return 'inactive';
const days = Math.floor((Date.now() - dtUltimaCompra.getTime()) / 86_400_000);
if (days >= INACTIVE_DAYS) return 'inactive';
if (days >= ALERT_DAYS) return 'alert';
return 'active';
}
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;
id_empresa: number;
nome: string;
razao: string | null;
cgcpf: string | null;
email: string | null;
telefone: string | null;
cod_vendedor: number;
nome_vendedor: string | null;
limite_credito: string | null;
dt_ultima_compra: Date | null;
ativo: number;
pessoa: number | null;
inscricao_estadual: string | null;
endereco: string | null;
num_endereco: string | null;
bairro: string | null;
cep: string | null;
ddd: string | null;
obs: string | null;
cod_pauta: number | null;
dt_cadastro: string | null;
dt_atual: string | null;
}
// SQL compartilhado: dois subqueries que calculam a data do último pedido
// considerando TANTO pedidos ERP (vw_pedidos_erp) QUANTO pedidos SAR (tabela pedidos).
// vw_pedidos_erp: situa SIG 5=Cancelado (excluir); pedidos SAR: situa 3=Cancelado (excluir).
// Clientes são cadastro GLOBAL (sem vínculo de id_empresa). A "última compra",
// porém, é escopada à empresa atual: filtramos os pedidos por idEmpresa e juntamos
// apenas por id_cliente.
function pedidosJoins(idEmpresa: number): string {
return `
LEFT JOIN (
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
) erp_ped ON erp_ped.id_cliente = c.id_cliente
LEFT JOIN (
SELECT id_cliente, MAX(dt_pedido) AS dt_max
FROM pedidos
WHERE situa != 3 AND id_empresa = ${idEmpresa}
GROUP BY id_cliente
) sar_ped ON sar_ped.id_cliente = c.id_cliente
`;
}
// Subquery escalar para o nome do representante (cadastro global, sem id_empresa).
// NÃO usar JOIN: vw_representantes tem códigos duplicados, o que multiplicaria as
// linhas de cliente e quebraria contagem/paginação. LIMIT 1 garante 1 nome.
const NOME_VENDEDOR_SUBQ = `
(SELECT r.nome FROM vw_representantes r
WHERE r.codigo = c.cod_vendedor
LIMIT 1) AS nome_vendedor`;
// Expressão SQL que calcula o activity_status a partir das datas dos dois joins.
const ACTIVITY_CASE = (alias_erp = 'erp_ped', alias_sar = 'sar_ped') => `
CASE
WHEN GREATEST(${alias_erp}.dt_max, ${alias_sar}.dt_max) IS NULL THEN 'inactive'
WHEN (CURRENT_DATE - GREATEST(${alias_erp}.dt_max, ${alias_sar}.dt_max)::date) >= ${INACTIVE_DAYS} THEN 'inactive'
WHEN (CURRENT_DATE - GREATEST(${alias_erp}.dt_max, ${alias_sar}.dt_max)::date) >= ${ALERT_DAYS} THEN 'alert'
ELSE 'active'
END
`;
@Injectable()
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');
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 { q, status, page, limit } = query;
const offset = (page - 1) * limit;
// 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)}%')`
: '';
// Filtro de status calculado em SQL — evita paginação quebrada do filtro pós-SQL
const statusFilter = status ? `AND ${ACTIVITY_CASE()} = '${status}'` : '';
// Clientes globais: sem filtro de id_empresa. Rep continua escopado por cod_vendedor.
const baseWhere = `
WHERE c.ativo = 1
${vendedorFilter}
${searchFilter}
${statusFilter}
`;
const joins = pedidosJoins(idEmpresa);
const [rows, totalRows] = await Promise.all([
prisma.$queryRawUnsafe<ClientRow[]>(`
SELECT
c.id_cliente,
c.id_empresa,
c.nome,
c.razao,
c.cgcpf,
c.email,
c.telefone,
c.cod_vendedor,
${NOME_VENDEDOR_SUBQ},
c.limite_credito::text,
c.ativo,
c.pessoa,
c.inscricao_estadual,
c.endereco,
c.num_endereco,
c.bairro,
c.cep,
c.ddd,
c.obs,
c.cod_pauta,
c.dt_cadastro::text,
c.dt_atual::text,
GREATEST(erp_ped.dt_max, sar_ped.dt_max) AS dt_ultima_compra
FROM vw_clientes c
${joins}
${baseWhere}
ORDER BY c.nome
LIMIT ${limit} OFFSET ${offset}
`),
prisma.$queryRawUnsafe<[{ count: string }]>(`
SELECT COUNT(*)::text AS count
FROM vw_clientes c
${joins}
${baseWhere}
`),
]);
const total = parseInt(totalRows[0]?.count ?? '0', 10);
const mapped: ClientSummary[] = rows.map((r) => ({
idCliente: Number(r.id_cliente),
idEmpresa: Number(r.id_empresa),
nome: r.nome,
razao: r.razao,
cgcpf: r.cgcpf,
email: r.email,
telefone: r.telefone,
codVendedor: Number(r.cod_vendedor),
nomeVendedor: r.nome_vendedor ?? null,
limiteCreditoStr: r.limite_credito,
activityStatus: activityStatus(r.dt_ultima_compra),
dtUltimaCompra: r.dt_ultima_compra?.toISOString() ?? null,
}));
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');
const rows = await prisma.$queryRawUnsafe<ClientRow[]>(`
SELECT
c.id_cliente, c.id_empresa, c.nome, c.razao, c.cgcpf, c.email,
c.telefone, c.cod_vendedor, ${NOME_VENDEDOR_SUBQ}, c.limite_credito::text,
c.ativo, c.pessoa, c.inscricao_estadual, c.endereco, c.num_endereco,
c.bairro, c.cep, c.ddd, c.obs, c.cod_pauta,
c.dt_cadastro::text, c.dt_atual::text,
GREATEST(erp_ped.dt_max, sar_ped.dt_max) AS dt_ultima_compra
FROM vw_clientes c
${pedidosJoins(idEmpresa)}
WHERE c.id_cliente = ${idCliente}
LIMIT 1
`);
const r = rows[0];
if (!r) throw new NotFoundException(`Cliente ${idCliente} não encontrado`);
return {
idCliente: Number(r.id_cliente),
idEmpresa: Number(r.id_empresa),
nome: r.nome,
razao: r.razao,
cgcpf: r.cgcpf,
email: r.email,
telefone: r.telefone,
codVendedor: Number(r.cod_vendedor),
nomeVendedor: r.nome_vendedor ?? null,
limiteCreditoStr: r.limite_credito,
activityStatus: activityStatus(r.dt_ultima_compra),
dtUltimaCompra: r.dt_ultima_compra?.toISOString() ?? null,
ativo: Number(r.ativo),
pessoa: r.pessoa !== null ? Number(r.pessoa) : null,
inscricaoEstadual: r.inscricao_estadual,
endereco: r.endereco,
numEndereco: r.num_endereco,
bairro: r.bairro,
cep: r.cep,
ddd: r.ddd,
obs: r.obs,
codPauta: r.cod_pauta !== null ? Number(r.cod_pauta) : null,
dtCadastro: r.dt_cadastro,
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

@@ -13,7 +13,10 @@ export const EnvSchema = z
API_PORT: z.coerce.number().int().positive().default(3000), API_PORT: z.coerce.number().int().positive().default(3000),
API_HOST: z.string().min(1).default('0.0.0.0'), API_HOST: z.string().min(1).default('0.0.0.0'),
API_GLOBAL_PREFIX: z.string().min(1).default('api'), API_GLOBAL_PREFIX: z.string().min(1).default('api'),
API_VERSION: z.string().regex(/^v\d+$/).default('v1'), API_VERSION: z
.string()
.regex(/^v\d+$/)
.default('v1'),
// CORS — origens permitidas (Web em dev: http://localhost:4200) // CORS — origens permitidas (Web em dev: http://localhost:4200)
CORS_ORIGINS: z CORS_ORIGINS: z
@@ -28,13 +31,20 @@ export const EnvSchema = z
// Master-login (DEV stub — IdP real virá na próxima sessão) // Master-login (DEV stub — IdP real virá na próxima sessão)
MASTER_LOGIN_URL: z.url().default('http://localhost:3000/auth/dev'), MASTER_LOGIN_URL: z.url().default('http://localhost:3000/auth/dev'),
MASTER_LOGIN_JWT_SECRET: z.string().min(32).default('dev_jwt_secret_change_in_prod_use_vault_xxxxx'), MASTER_LOGIN_JWT_SECRET: z
.string()
.min(32)
.default('dev_jwt_secret_change_in_prod_use_vault_xxxxx'),
JWT_ACCESS_EXPIRATION: z.coerce.number().int().positive().default(900), JWT_ACCESS_EXPIRATION: z.coerce.number().int().positive().default(900),
JWT_REFRESH_EXPIRATION: z.coerce.number().int().positive().default(2_592_000), JWT_REFRESH_EXPIRATION: z.coerce.number().int().positive().default(2_592_000),
// Multi-tenancy — workspace de dev (até master-login real entrar) // Multi-tenancy — workspace de dev (até master-login real entrar)
DEFAULT_WORKSPACE_ID: z.string().min(1).default('dev-workspace'), DEFAULT_WORKSPACE_ID: z.string().min(1).default('dev-workspace'),
// Representante fixo de dev — forçado no guard enquanto não há login real
DEV_REP_CODE: z.coerce.number().int().positive().default(29),
DEV_EMPRESA_ID: z.coerce.number().int().positive().default(1),
// Postgres (Prisma virá depois) // Postgres (Prisma virá depois)
DATABASE_URL: z.string().optional(), DATABASE_URL: z.string().optional(),
MIGRATION_DATABASE_URL: z.string().optional(), MIGRATION_DATABASE_URL: z.string().optional(),
@@ -61,6 +71,11 @@ export const EnvSchema = z
OTEL_TRACES_SAMPLER_ARG: z.coerce.number().min(0).max(1).default(1.0), OTEL_TRACES_SAMPLER_ARG: z.coerce.number().min(0).max(1).default(1.0),
SENTRY_DSN: z.string().optional(), SENTRY_DSN: z.string().optional(),
// Web Push VAPID (C6) — gerado via web-push generateVAPIDKeys()
VAPID_PUBLIC_KEY: z.string().optional(),
VAPID_PRIVATE_KEY: z.string().optional(),
VAPID_CONTACT: z.string().default('mailto:noreply@sar.dev'),
// Feature flags // Feature flags
GROWTHBOOK_API_HOST: z.string().optional(), GROWTHBOOK_API_HOST: z.string().optional(),
GROWTHBOOK_CLIENT_KEY: z.string().optional(), GROWTHBOOK_CLIENT_KEY: z.string().optional(),
@@ -74,11 +89,16 @@ export const EnvSchema = z
ctx.addIssue({ ctx.addIssue({
code: 'custom', code: 'custom',
path: ['MASTER_LOGIN_JWT_SECRET'], path: ['MASTER_LOGIN_JWT_SECRET'],
message: 'JWT secret de DEV não pode ser usada em produção (CODING-RULES §08, PGD-SEC-002).', message:
'JWT secret de DEV não pode ser usada em produção (CODING-RULES §08, PGD-SEC-002).',
}); });
} }
if (!env.DATABASE_URL) { if (!env.DATABASE_URL) {
ctx.addIssue({ code: 'custom', path: ['DATABASE_URL'], message: 'obrigatório em produção' }); ctx.addIssue({
code: 'custom',
path: ['DATABASE_URL'],
message: 'obrigatório em produção',
});
} }
} }
}); });

View File

@@ -0,0 +1,45 @@
import { Controller, ForbiddenException, Get, Query } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { DashboardService } from './dashboard.service';
@Controller({ path: 'dashboard' })
export class DashboardController {
constructor(
private readonly dashboard: DashboardService,
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') ?? '');
}
@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

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}

View File

@@ -0,0 +1,754 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
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
const SITUA_PENDENTE = 1;
// vw_metas.tipo (gestao.metavenda): GL = meta global, GR = meta por grupo.
const TIPO_META_GLOBAL = 'GL';
const TIPO_META_GRUPO = 'GR';
// Metas/produtos vivem na MATRIZ; pedidos usam idEmpresa cru (ex.: 9001 → matriz 1).
function matrizEmpresa(idEmpresa: number): number {
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
}
interface MetaErpRow {
tipo: string;
cod_grupo: number | null;
desc_grupo: string | null;
valor: string;
qtdade: string;
peso: string;
vl_fator: string;
}
interface RealizadoGrupoRow {
cod_grupo: number | null;
grupo: string | null;
pedidos: string;
valor: string;
qtd: string;
peso: string;
}
interface NaoPositivadoRow {
id_cliente: number;
nome: string;
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 {
cod_vendedor: number;
nome_vendedor: string | null;
inativos_count: string;
}
@Injectable()
export class DashboardService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
async repDashboard(userId: string): Promise<RepDashboard> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const codVendedor = parseInt(userId, 10);
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 0, 23, 59, 59, 999);
// 1. Metas do mês — vw_metas vive na matriz (normaliza 9001→1).
// GL = meta global; GR = meta por grupo. A dimensão segue o que o ERP tiver.
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
const metaErpRows = await prisma.$queryRawUnsafe<MetaErpRow[]>(`
SELECT TRIM(tipo) AS tipo, cod_grupo, TRIM(desc_grupo) AS desc_grupo,
valor::text AS valor, qtdade::text AS qtdade,
peso::text AS peso, vl_fator::text AS vl_fator
FROM vw_metas
WHERE id_empresa = ${idEmpresaMatriz}
AND cod_vendedor = ${codVendedor}
AND ano = ${year}
AND mes = ${month}
AND TRIM(tipo) IN ('${TIPO_META_GLOBAL}', '${TIPO_META_GRUPO}')
`);
const glRows = metaErpRows.filter((m) => m.tipo === TIPO_META_GLOBAL);
const grRows = metaErpRows.filter((m) => m.tipo === TIPO_META_GRUPO);
// Total global: usa GL se houver; senão soma as metas por grupo (GR).
const targetAmount = glRows.length
? glRows.reduce((a, m) => a + Number(m.valor), 0)
: grRows.reduce((a, m) => a + Number(m.valor), 0);
const metaDimensao = grRows.length > 0 ? ('grupo' as const) : ('global' as const);
// 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);
interface TotalRow {
total: string;
}
interface CountRow {
count: string;
}
interface RecentRow {
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;
}
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
WHERE id_empresa = ${idEmpresa}
AND cod_vendedor = ${codVendedor}
AND situa NOT IN (1, 5)
AND dt_pedido >= '${monthStartStr}'
AND dt_pedido <= '${monthEndStr}'
`),
prisma.$queryRawUnsafe<CountRow[]>(`
SELECT COUNT(*)::text AS count
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND cod_vendedor = ${codVendedor}
AND situa != 5
AND dt_pedido >= '${monthStartStr}'
AND dt_pedido <= '${monthEndStr}'
`),
prisma.$queryRawUnsafe<RecentRow[]>(`
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
WHERE e.id_empresa = ${idEmpresa}
AND e.cod_vendedor = ${codVendedor}
AND e.situa != 5
AND e.dt_pedido >= '${new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)}'
ORDER BY e.dt_pedido DESC
LIMIT 10
`),
// Realizado por grupo — itens faturados (situa 2/4) → produto (matriz) → cod_grupo.
// peso = qtd × peso_líquido do produto; pedidos = nº de pedidos distintos.
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.cod_vendedor = ${codVendedor}
AND e.situa NOT IN (1, 5)
AND e.dt_pedido >= '${monthStartStr}'
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);
const pedidosMes = Number(pedidosMesRows[0]?.count ?? 0);
const pct = targetAmount > 0 ? Math.round((atingido / targetAmount) * 100) : 0;
const falta = Math.max(0, targetAmount - atingido);
// 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,
TRIM(c.nome) AS nome,
TRIM(c.razao) AS razao,
TO_CHAR(up.dt_max, 'YYYY-MM-DD') AS dt_ultimo_pedido,
(CURRENT_DATE - up.dt_max::date) AS dias_sem_pedido,
(up.dt_max IS NOT NULL) AS comprou_antes,
ct.whatsapp
FROM vw_clientes c
LEFT JOIN ultimo_pedido up ON up.id_cliente = c.id_cliente
LEFT JOIN 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
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.
const realPorGrupo = new Map<number, RealizadoGrupoRow>();
for (const r of realizadoGrupoRows) {
if (r.cod_grupo != null) realPorGrupo.set(Number(r.cod_grupo), r);
}
const metasPorGrupo = grRows
.map((m) => {
const cod = m.cod_grupo != null ? Number(m.cod_grupo) : null;
const real = cod != null ? realPorGrupo.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),
};
})
.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,
pedidosMes,
historicoMensal,
pedidosRecentes: recentRows.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,
situa: 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,
})),
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(),
};
}
async supervisorDashboard(): Promise<SupervisorDashboard> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const now = new Date();
// Supervisor vê só a própria equipe (cod_supervisor em vw_representantes);
// gerente/admin acessando este painel veem a empresa toda.
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,
...(team ? { codVendedor: { in: team } } : {}),
},
orderBy: { dtPedido: 'asc' },
take: 50,
});
// Pedidos do dia — lê do ERP (situa != 5=Cancelado)
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayStr = todayStart.toISOString().slice(0, 10);
const lastWeekStr = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
const lastWeekEndStr = new Date(todayStart.getTime() - 6 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
interface DayRow {
count: string;
total: string;
}
const [todayRows, lastWeekRows] = await Promise.all([
prisma.$queryRawUnsafe<DayRow[]>(`
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa} AND situa != 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')}
`),
]);
// Top 3 reps com mais clientes inativos (>30 dias sem compra no ERP)
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(`
SELECT inativos.cod_vendedor,
(SELECT r.nome FROM vw_representantes r
WHERE r.codigo = inativos.cod_vendedor
LIMIT 1) AS nome_vendedor,
COUNT(*)::text AS inativos_count
FROM (
SELECT c.id_cliente, c.cod_vendedor
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
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)}'
) inativos
GROUP BY inativos.cod_vendedor
ORDER BY COUNT(*) DESC
LIMIT 3
`);
// Resolve nomes de cliente e representante da fila (pedidos SAR só têm os códigos)
const repCods = [...new Set(approvalQueue.map((p) => p.codVendedor))];
const cliIds = [...new Set(approvalQueue.map((p) => p.idCliente))];
const [repNameRows, cliNameRows] = await Promise.all([
repCods.length
? prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${repCods.join(',')})`,
)
: Promise.resolve([]),
cliIds.length
? prisma.$queryRawUnsafe<
{ id_cliente: number; nome: string | null; razao: string | null }[]
>(
`SELECT id_cliente, nome, razao FROM vw_clientes WHERE id_cliente IN (${cliIds.join(',')})`,
)
: Promise.resolve([]),
]);
const repNameMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
const cliNameMap = new Map(
cliNameRows.map((c) => [Number(c.id_cliente), { nome: c.nome, razao: c.razao }]),
);
const mapPedido = (o: (typeof approvalQueue)[number]) => ({
id: o.id,
numPedSar: o.numPedSar,
idCliente: o.idCliente,
nomeCliente: cliNameMap.get(o.idCliente)?.nome ?? null,
razaoCliente: cliNameMap.get(o.idCliente)?.razao ?? null,
codVendedor: o.codVendedor,
nomeVendedor: repNameMap.get(o.codVendedor) ?? null,
situa: o.situa,
dtPedido: o.dtPedido.toISOString(),
total: String(o.total),
descontoPerc: String(o.descontoPerc),
obs: o.obs,
createdAt: o.createdAt.toISOString(),
fonte: 'sar' as const,
});
return {
approvalQueue: approvalQueue.map(mapPedido),
pedidosDia: {
count: Number(todayRows[0]?.count ?? 0),
total: Number(todayRows[0]?.total ?? 0),
countSemanaAnterior: Number(lastWeekRows[0]?.count ?? 0),
totalSemanaAnterior: Number(lastWeekRows[0]?.total ?? 0),
},
inativosPorRep: inativosPorRep.map((r) => ({
codVendedor: Number(r.cod_vendedor),
nomeVendedor: r.nome_vendedor ?? null,
inativosCount: parseInt(r.inativos_count, 10),
})),
syncedAt: now.toISOString(),
};
}
}

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

@@ -5,22 +5,22 @@ import {
HealthCheckService, HealthCheckService,
MemoryHealthIndicator, MemoryHealthIndicator,
} from '@nestjs/terminus'; } from '@nestjs/terminus';
import { Public } from '../auth/public.decorator';
import { WorkspacePoolHealthIndicator } from './workspace-pool.health-indicator';
// CODING-RULES §20 (PGD-OBS-003): // CODING-RULES §20 (PGD-OBS-003):
// /health/live → liveness só com memory.checkHeap(350MB). // /health/live → liveness: memory.checkHeap(350MB).
// /health/ready → readiness pinga master-login + amostra LRU (K=3) dos pools // /health/ready → readiness: heap + amostra LRU (K=3) do WorkspacePrismaPool.
// quentes do WorkspacePrismaPool + Valkey + BullMQ. // Próximos: MasterLoginHealthIndicator, ValkeyHealthIndicator, BullMQHealthIndicator.
// NUNCA percorrer todos os workspaces (O(N) → false negative). // NUNCA percorrer todos os workspaces (O(N)).
//
// Hoje o "ready" só checa heap, idêntico ao live. Quando master-login,
// WorkspacePrismaPool, Valkey e BullMQ entrarem, cada um adiciona seu indicator
// aqui — sem nunca virar O(N) sobre workspaces.
@Public()
@Controller({ path: 'health' }) @Controller({ path: 'health' })
export class HealthController { export class HealthController {
constructor( constructor(
private readonly health: HealthCheckService, private readonly health: HealthCheckService,
private readonly memory: MemoryHealthIndicator, private readonly memory: MemoryHealthIndicator,
private readonly workspacePool: WorkspacePoolHealthIndicator,
) {} ) {}
@Get('live') @Get('live')
@@ -32,11 +32,9 @@ export class HealthController {
@Get('ready') @Get('ready')
@HealthCheck() @HealthCheck()
ready(): Promise<HealthCheckResult> { ready(): Promise<HealthCheckResult> {
// Skeleton: por enquanto idêntico ao live. Próximas frentes: return this.health.check([
// - MasterLoginHealthIndicator (obrigatório) () => this.memory.checkHeap('heap', 350 * 1024 * 1024),
// - WorkspacePoolLruHealthIndicator (K=3 amostra) () => this.workspacePool.check('workspace_pool', 3),
// - ValkeyHealthIndicator ]);
// - BullMQHealthIndicator
return this.health.check([() => this.memory.checkHeap('heap', 350 * 1024 * 1024)]);
} }
} }

View File

@@ -1,9 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus'; import { TerminusModule } from '@nestjs/terminus';
import { WorkspaceModule } from '../workspace/workspace.module';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
import { WorkspacePoolHealthIndicator } from './workspace-pool.health-indicator';
@Module({ @Module({
imports: [TerminusModule], imports: [TerminusModule, WorkspaceModule],
controllers: [HealthController], controllers: [HealthController],
providers: [WorkspacePoolHealthIndicator],
}) })
export class HealthModule {} export class HealthModule {}

View File

@@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import { HealthIndicatorResult, HealthIndicator, HealthCheckError } from '@nestjs/terminus';
import { WorkspacePrismaPool } from '../workspace/workspace-prisma-pool.service';
// Amostra os K workspaces mais quentes do LRU — nunca O(N) sobre todos os workspaces.
// CODING-RULES §20 (PGD-OBS-003): readiness não percorre workspaces individualmente.
@Injectable()
export class WorkspacePoolHealthIndicator extends HealthIndicator {
constructor(private readonly pool: WorkspacePrismaPool) {
super();
}
async check(key = 'workspace_pool', k = 3): Promise<HealthIndicatorResult> {
const results = await this.pool.health(k);
if (results.length === 0) {
return this.getStatus(key, true, { active: 0 });
}
const failed = results.filter((r) => !r.ok);
const isHealthy = failed.length === 0;
const detail = {
active: results.length,
healthy: results.length - failed.length,
...(failed.length > 0 && { failed: failed.map((r) => r.workspaceId) }),
};
if (!isHealthy) {
throw new HealthCheckError(`${key} degradado`, this.getStatus(key, false, detail));
}
return this.getStatus(key, true, detail);
}
}

View File

@@ -0,0 +1,34 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Req } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
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 {
constructor(private readonly svc: NotificationsService) {}
@Post('subscribe')
@HttpCode(HttpStatus.NO_CONTENT)
async subscribe(@Req() req: AuthenticatedRequest, @Body() body: SubscribeDto): Promise<void> {
await this.svc.subscribe(req.user.sub, req.user.role, body as unknown as SubscribePayload);
}
@Delete('unsubscribe')
@HttpCode(HttpStatus.NO_CONTENT)
async unsubscribe(@Req() req: AuthenticatedRequest, @Body() body: UnsubscribeDto): Promise<void> {
await this.svc.unsubscribe(req.user.sub, body.endpoint);
}
@Get('pending-count')
async pendingCount(@Req() req: AuthenticatedRequest) {
return this.svc.pendingCount(req.user.sub, req.user.role);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { PushService } from './push.service';
@Module({
controllers: [NotificationsController],
providers: [NotificationsService, PushService],
exports: [NotificationsService],
})
export class NotificationsModule {}

View File

@@ -0,0 +1,97 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { SubscribePayload, PendingCountResponse } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { PushService, type PushPayload } from './push.service';
// Situa: 1=Pendente Aprovação
const SITUA_PENDENTE = 1;
@Injectable()
export class NotificationsService {
constructor(
private readonly cls: ClsService<WorkspaceClsStore>,
private readonly push: PushService,
) {}
async subscribe(userId: string, role: string, dto: SubscribePayload): 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.upsert({
where: { endpoint: dto.endpoint },
update: {
codVendedor,
idEmpresa,
role,
p256dh: dto.keys.p256dh,
auth: dto.keys.auth,
},
create: {
codVendedor,
idEmpresa,
role,
endpoint: dto.endpoint,
p256dh: dto.keys.p256dh,
auth: dto.keys.auth,
},
});
}
// 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, idEmpresa, codVendedor } });
}
async pendingCount(userId: string, role: string): Promise<PendingCountResponse> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
if (role === 'supervisor' || role === 'manager' || role === 'admin') {
const count = await prisma.pedido.count({
where: { situa: SITUA_PENDENTE, idEmpresa },
});
return { count };
}
return { count: 0 };
}
// Envia push para todos os supervisores/managers/admin da empresa.
async notifySupervisors(payload: PushPayload): Promise<void> {
const prisma = this.cls.get('prisma');
if (!prisma) return;
const idEmpresa = this.cls.get('idEmpresa');
const subs = await prisma.pushSubscription.findMany({
where: {
idEmpresa,
role: { in: ['supervisor', 'manager', 'admin'] },
},
});
await Promise.allSettled(subs.map((s) => this.push.send(s, payload)));
}
// Envia push para um codVendedor específico (todos os dispositivos registrados).
async notifyUser(userId: string, payload: PushPayload): Promise<void> {
const prisma = this.cls.get('prisma');
if (!prisma) return;
const idEmpresa = this.cls.get('idEmpresa');
const codVendedor = parseInt(userId, 10);
const subs = await prisma.pushSubscription.findMany({
where: { idEmpresa, codVendedor },
});
await Promise.allSettled(subs.map((s) => this.push.send(s, payload)));
}
}

View File

@@ -0,0 +1,51 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as webpush from 'web-push';
import type { Env } from '../config/env.schema';
export interface PushPayload {
title: string;
body: string;
url?: string;
}
interface PushTarget {
endpoint: string;
p256dh: string;
auth: string;
}
@Injectable()
export class PushService {
private readonly logger = new Logger(PushService.name);
private readonly enabled: boolean;
constructor(config: ConfigService<Env, true>) {
const publicKey = config.get('VAPID_PUBLIC_KEY', { infer: true });
const privateKey = config.get('VAPID_PRIVATE_KEY', { infer: true });
const contact = config.get('VAPID_CONTACT', { infer: true });
if (publicKey && privateKey) {
webpush.setVapidDetails(contact, publicKey, privateKey);
this.enabled = true;
} else {
this.enabled = false;
this.logger.warn(
'VAPID não configurado — push desativado (defina VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY)',
);
}
}
async send(target: PushTarget, payload: PushPayload): Promise<void> {
if (!this.enabled) return;
try {
await webpush.sendNotification(
{ endpoint: target.endpoint, keys: { p256dh: target.p256dh, auth: target.auth } },
JSON.stringify(payload),
);
} catch (err: unknown) {
// 410 Gone = subscription expirada; logar sem throw para não quebrar o fluxo principal
this.logger.warn({ err }, `Push falhou para ${target.endpoint.slice(0, 60)}`);
}
}
}

View File

@@ -0,0 +1,107 @@
import {
Body,
Controller,
ForbiddenException,
Get,
HttpCode,
Param,
ParseIntPipe,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
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,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { OrdersService } from './orders.service';
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
@Controller({ path: 'orders' })
export class OrdersController {
constructor(
private readonly orders: OrdersService,
private readonly cls: ClsService<WorkspaceClsStore>,
) {}
@Get()
list(@Query() query: PedidoListQueryDto): Promise<PedidoListResponse> {
const parsed = PedidoListQuerySchema.parse(query) as PedidoListQuery;
return this.orders.list(parsed);
}
@Post()
@HttpCode(201)
create(@Body() body: CreatePedidoDto): Promise<PedidoDetail> {
const parsed = CreatePedidoSchema.parse(body) as CreatePedido;
return this.orders.create(parsed);
}
@Patch(':id/transmit')
transmit(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
return this.orders.transmit(id);
}
@Patch(':id/approve')
approve(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: AprovarPedidoDto,
): Promise<PedidoDetail> {
const role = this.cls.get('role') ?? 'rep';
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem aprovar pedidos');
const parsed = AprovarPedidoSchema.parse(body) as AprovarPedido;
return this.orders.approve(id, parsed);
}
@Patch(':id/reject')
reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: RecusarPedidoDto,
): Promise<PedidoDetail> {
const role = this.cls.get('role') ?? 'rep';
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem recusar pedidos');
const parsed = RecusarPedidoSchema.parse(body) as RecusarPedido;
return this.orders.reject(id, parsed);
}
@Patch(':id/cancel')
cancel(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
return this.orders.cancel(id);
}
@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

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import { Controller, Get } from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import type { PingResponse } from '@sar/api-interface'; import type { PingResponse } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { Public } from '../auth/public.decorator';
// Endpoint de verificação de fundação: // Endpoint de verificação de fundação:
// - confirma que CLS está populando workspaceId + requestId; // - confirma que CLS está populando workspaceId + requestId;
@@ -9,6 +10,7 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types';
// - usado pela Web (Frente B) para validar conectividade real. // - usado pela Web (Frente B) para validar conectividade real.
// Contrato: @sar/api-interface · PingResponseSchema (zod). // Contrato: @sar/api-interface · PingResponseSchema (zod).
@Public()
@Controller({ path: 'ping' }) @Controller({ path: 'ping' })
export class PingController { export class PingController {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {} constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
@@ -19,7 +21,7 @@ export class PingController {
status: 'ok', status: 'ok',
service: 'sar-api', service: 'sar-api',
version: process.env['npm_package_version'] ?? '0.1.0', version: process.env['npm_package_version'] ?? '0.1.0',
workspaceId: this.cls.get('workspaceId'), idEmpresa: this.cls.get('idEmpresa'),
requestId: this.cls.get('requestId'), requestId: this.cls.get('requestId'),
uptimeSeconds: Math.round(process.uptime()), uptimeSeconds: Math.round(process.uptime()),
now: new Date().toISOString(), now: new Date().toISOString(),

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

@@ -0,0 +1,86 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import pg from 'pg';
// ADR 0006 revogado: BD-por-workspace → schema `sar` no ERP.
// Pool keyed por idEmpresa (number). URL deve incluir ?schema=sar.
// CODING-RULES PGD-DB-009: callers obtêm o client via CLS, não injetando este serviço.
const MAX_ENTRIES = 10; // LRU cap; ajustável via env na próxima iteração
const PG_POOL_SIZE = 5;
const noop = (): void => undefined;
interface PoolEntry {
client: PrismaClient;
pgPool: pg.Pool;
}
@Injectable()
export class WorkspacePrismaPool implements OnModuleDestroy {
private readonly logger = new Logger(WorkspacePrismaPool.name);
// Map preserves insertion order → LRU: primeiro = mais antigo, último = mais recente
private readonly cache = new Map<string, PoolEntry>();
getOrCreate(idEmpresa: number, dbUrl: string): PrismaClient {
const key = String(idEmpresa);
const hit = this.cache.get(key);
if (hit) {
// Move para o fim (LRU refresh)
this.cache.delete(key);
this.cache.set(key, hit);
return hit.client;
}
if (this.cache.size >= MAX_ENTRIES) {
this.evictOldest();
}
const pgPool = new pg.Pool({
connectionString: dbUrl,
max: PG_POOL_SIZE,
options: '-c search_path=sar',
});
const adapter = new PrismaPg(pgPool, { schema: 'sar' });
const client = new PrismaClient({ adapter });
this.cache.set(key, { client, pgPool });
this.logger.log(`pool criado: idEmpresa=${idEmpresa} total=${this.cache.size}`);
return client;
}
async health(k = 3): Promise<{ workspaceId: string; ok: boolean; latencyMs?: number }[]> {
// Verifica os k empresas mais recentes
const entries = [...this.cache.entries()].slice(-k);
return Promise.all(
entries.map(async ([workspaceId, { pgPool }]) => {
const start = Date.now();
try {
const conn = await pgPool.connect();
conn.release();
return { workspaceId, ok: true, latencyMs: Date.now() - start };
} catch {
return { workspaceId, ok: false };
}
}),
);
}
async onModuleDestroy(): Promise<void> {
await Promise.allSettled(
[...this.cache.values()].map(({ client, pgPool }) =>
client.$disconnect().finally(() => pgPool.end()),
),
);
this.cache.clear();
this.logger.log('pool destruído — todos os clientes desconectados');
}
private evictOldest(): void {
const [oldestId, oldest] = this.cache.entries().next().value as [string, PoolEntry];
void oldest.client.$disconnect().catch(noop);
void oldest.pgPool.end().catch(noop);
this.cache.delete(oldestId);
this.logger.log(`evicted LRU idEmpresa=${oldestId}`);
}
}

View File

@@ -1,48 +1,48 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ClsModule } from 'nestjs-cls'; import { ClsModule } from 'nestjs-cls';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import type { WorkspaceClsStore } from './workspace.types'; import type { WorkspaceClsStore } from './workspace.types';
import type { Env } from '../config/env.schema'; import { WorkspacePrismaPool } from './workspace-prisma-pool.service';
// CLS popula contexto por request. Hoje: requestId + DEFAULT_WORKSPACE_ID do env. // CLS middleware roda ANTES dos guards (ordem NestJS).
// Amanhã: workspaceId vem do JWT (PGD-AUTHZ-002); `prisma` é resolvido pelo // Aqui: apenas requestId + idEmpresa default (0 = não autenticado).
// WorkspacePrismaPool e injetado via cls.set('prisma', ...) aqui mesmo. // JwtAuthGuard atualiza idEmpresa, userId e prisma após validar o token.
// CODING-RULES PGD-DB-009: prisma via cls.get('prisma'), nunca singleton.
// CODING-RULES PGD-AUTHZ-002: idEmpresa real vem do JWT (guard), não do env.
// ADR 0006 revogado: workspaceId: string → idEmpresa: number
@Module({ @Module({
imports: [ imports: [
ClsModule.forRootAsync({ ClsModule.forRootAsync({
global: true, global: true,
imports: [ConfigModule], useFactory: () => ({
inject: [ConfigService],
useFactory: (config: ConfigService<Env, true>) => ({
middleware: { middleware: {
mount: true, mount: true,
generateId: true, generateId: true,
idGenerator: (req: Request) => { idGenerator: (req: Request) => {
// Prioridade: req.id (pino-http já gerou/leu header) > header bruto > novo UUID.
const fromPino = (req as Request & { id?: unknown }).id; const fromPino = (req as Request & { id?: unknown }).id;
if (typeof fromPino === 'string' && fromPino.length > 0) return fromPino; if (typeof fromPino === 'string' && fromPino.length > 0) return fromPino;
const headerVal = req.headers['x-request-id']; const headerVal = req.headers['x-request-id'];
return typeof headerVal === 'string' && headerVal.length > 0 return typeof headerVal === 'string' && headerVal.length > 0 ? headerVal : randomUUID();
? headerVal
: randomUUID();
}, },
setup: (cls, req: Request, res: Response) => { setup: (cls, req: Request, res: Response) => {
const store = cls as unknown as { const store = cls as unknown as {
set: <K extends keyof WorkspaceClsStore>(key: K, value: WorkspaceClsStore[K]) => void; set: <K extends keyof WorkspaceClsStore>(key: K, value: WorkspaceClsStore[K]) => void;
getId: () => string; getId: () => string;
}; };
const requestId = store.getId(); const requestId = store.getId();
res.setHeader('x-request-id', requestId); res.setHeader('x-request-id', requestId);
store.set('requestId', requestId); store.set('requestId', requestId);
store.set('workspaceId', config.get('DEFAULT_WORKSPACE_ID', { infer: true })); // Fallback para rotas públicas (ping, health). Guard sobrescreve em rotas protegidas.
store.set('idEmpresa', 0);
}, },
}, },
}), }),
}), }),
], ],
exports: [ClsModule], providers: [WorkspacePrismaPool],
exports: [ClsModule, WorkspacePrismaPool],
}) })
export class WorkspaceModule {} export class WorkspaceModule {}

View File

@@ -1,13 +1,16 @@
import type { ClsStore } from 'nestjs-cls'; import type { ClsStore } from 'nestjs-cls';
import type { PrismaClient } from '@prisma/client';
import type { JwtRole } from '../auth/jwt.types';
// Forma do CLS store por request — fonte da verdade para qualquer caller // Forma do CLS store por request — fonte da verdade para qualquer caller.
// que faça `cls.get(...)`. Quando o PrismaClient por workspace entrar // CODING-RULES PGD-DB-009: nunca importe PrismaClient diretamente; use cls.get('prisma').
// (ADR 0006), `prisma` virará obrigatório aqui — por hora segue opcional. // CODING-RULES PGD-AUTHZ-002: idEmpresa vem sempre do JWT, nunca de body/param/query.
// ADR 0006 revogado: workspaceId: string → idEmpresa: number
export interface WorkspaceClsStore extends ClsStore { export interface WorkspaceClsStore extends ClsStore {
requestId: string; requestId: string;
workspaceId: string; idEmpresa: number; // era workspaceId: string — agora Int da empresa no ERP
// userId virá quando master-login estiver plugado. userId?: string; // cod_vendedor como string; preenchido pelo JwtAuthGuard
userId?: string; role?: JwtRole; // preenchido pelo JwtAuthGuard após validar o token
// prisma: PrismaClient — adicionar quando WorkspacePrismaPool entrar. prisma?: PrismaClient; // preenchido pelo JwtAuthGuard via WorkspacePrismaPool
} }

View File

@@ -0,0 +1 @@
{"fileNames":[],"fileInfos":[],"root":[],"options":{"allowSyntheticDefaultImports":true,"composite":false,"declaration":true,"declarationMap":true,"emitDecoratorMetadata":true,"esModuleInterop":true,"experimentalDecorators":true,"module":199,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noUncheckedIndexedAccess":true,"skipLibCheck":true,"sourceMap":true,"strict":true,"target":10,"useDefineForClassFields":false,"verbatimModuleSyntax":false},"version":"5.9.3"}

View File

@@ -3,7 +3,7 @@
"$schema": "../../node_modules/nx/schemas/project-schema.json", "$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application", "projectType": "application",
"sourceRoot": "apps/web-e2e/src", "sourceRoot": "apps/web-e2e/src",
"tags": [], "tags": ["scope:web", "type:e2e", "domain:shared"],
"implicitDependencies": ["web"], "implicitDependencies": ["web"],
"// targets": "to see all targets run: nx show project web-e2e --web", "// targets": "to see all targets run: nx show project web-e2e --web",
"targets": {} "targets": {}

123
apps/web/public/sw.js Normal file
View File

@@ -0,0 +1,123 @@
// Service Worker SAR
// C4/NFR-2: cache de API para uso offline (network-first, fallback to cache)
// C6: Web Push
// App shell: stale-while-revalidate para assets estáticos
const API_CACHE = 'sar-api-v2';
const SHELL_CACHE = 'sar-shell-v2';
// Paths de API que valem ser cacheados para offline
// Auth e mutations (POST/PATCH) nunca são interceptados
const CACHEABLE_API = [
'/api/v1/clients',
'/api/v1/catalog',
'/api/v1/orders',
'/api/v1/dashboard',
'/api/v1/auth/me',
];
// ── Fetch ──────────────────────────────────────────────────────────────────────
globalThis.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.pathname.startsWith('/api/v1/')) {
const cacheable = CACHEABLE_API.some((p) => url.pathname.startsWith(p));
if (cacheable) {
event.respondWith(networkFirst(request, API_CACHE));
}
return;
}
if (request.mode === 'navigate') {
// App shell HTML — network first, cache fallback
event.respondWith(shellNavigate(request));
return;
}
// Assets estáticos (JS/CSS/fontes/imagens) — stale-while-revalidate
if (/\.(js|css|woff2?|png|svg|ico)$/.test(url.pathname)) {
event.respondWith(staleWhileRevalidate(request, SHELL_CACHE));
}
});
// Network-first: tenta rede, cai no cache se offline
async function networkFirst(request, cacheName) {
const cache = await caches.open(cacheName);
try {
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await cache.match(request);
if (cached) return cached;
return offlineResponse();
}
}
// Navigate: network first; fallback para a raiz cacheada
async function shellNavigate(request) {
const cache = await caches.open(SHELL_CACHE);
try {
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
// Sempre armazena a raiz como fallback universal
cache.put(new Request('/'), response.clone());
}
return response;
} catch {
const cached = (await cache.match(request)) ?? (await cache.match('/'));
if (cached) return cached;
return offlineResponse();
}
}
// Stale-while-revalidate: responde do cache, atualiza em background
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const networkFetch = fetch(request).then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
});
return cached ?? networkFetch;
}
function offlineResponse() {
return new Response(
JSON.stringify({
type: 'sar:offline',
title: 'Sem conexão',
status: 503,
}),
{ status: 503, headers: { 'Content-Type': 'application/json' } },
);
}
// ── Push (C6) ─────────────────────────────────────────────────────────────────
globalThis.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
globalThis.registration.showNotification(data.title ?? 'SAR', {
body: data.body ?? '',
icon: '/sar-icon.png',
badge: '/sar-icon.png',
data: data.url ? { url: data.url } : undefined,
}),
);
});
globalThis.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url;
if (url) {
event.waitUntil(clients.openWindow(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

@@ -1,204 +0,0 @@
import { Card, Col, Flex, Progress, Row, Space, Tag, Typography } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faArrowTrendUp,
faClipboardCheck,
faCircleExclamation,
faRoute,
faMessage,
} from '@fortawesome/free-solid-svg-icons';
const { Title, Text } = Typography;
/**
* Painel do Rafael (Representante) — PRIMARY persona.
* MOCK data — substituir por TanStack Query quando API estiver pronta.
* Tom canônico: Direto · Confiante · Específico (vocabulário: meta, carteira, inativo, pedido).
*/
export function RafaelPainel() {
// Mock — em produção vem de TanStack Query
const metaMes = { atingido: 47600, total: 60000 };
const metaPct = Math.round((metaMes.atingido / metaMes.total) * 100);
const falta = metaMes.total - metaMes.atingido;
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
{/* Saudação canon (tom: Direto, Específico) */}
<Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}>
Bom dia, Rafael
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
27 de maio · 4 visitas na agenda · 2 propostas pra avançar
</Text>
</Flex>
{/* Linha 1 — Meta + KPIs rápidos */}
<Row gutter={[24, 24]}>
<Col xs={24} md={12}>
<Card style={{ height: '100%' }}>
<Flex vertical gap={16}>
<Flex justify="space-between" align="flex-start">
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
META DE MAIO
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
R$ {metaMes.atingido.toLocaleString('pt-BR')}
</Title>
<Text type="secondary">
de R${' '}
<span className="tabular-nums">
{metaMes.total.toLocaleString('pt-BR')}
</span>
</Text>
</Space>
<Tag color={metaPct >= 80 ? 'success' : 'processing'}>
{metaPct}% atingido
</Tag>
</Flex>
<Progress
percent={metaPct}
showInfo={false}
strokeColor="var(--jcs-blue)"
trailColor="var(--jcs-blue-light)"
/>
<Text style={{ fontSize: 'var(--text-md)' }}>
Faltam{' '}
<strong className="tabular-nums">
R$ {falta.toLocaleString('pt-BR')}
</strong>{' '}
pra fechar maio.
</Text>
</Flex>
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Space direction="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
PEDIDOS NO MÊS
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
28
</Title>
<Text type="success" style={{ fontSize: 'var(--text-sm)' }}>
<FontAwesomeIcon icon={faArrowTrendUp} /> +18% vs abril
</Text>
</Space>
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Space direction="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
COMISSÃO ACUMULADA
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
R$ 2.540
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
FLEX: R$ 380
</Text>
</Space>
</Card>
</Col>
</Row>
{/* Linha 2 — Alertas + Próxima visita */}
<Row gutter={[24, 24]}>
<Col xs={24} lg={12}>
<Card
title={
<Space>
<FontAwesomeIcon
icon={faCircleExclamation}
style={{ color: 'var(--orange)' }}
/>
Clientes esfriando
</Space>
}
extra={<Text type="secondary">3 hoje</Text>}
>
<Flex vertical gap={12}>
<ClienteInativoItem nome="OPENFRIOS" dias={47} ultimaCompra="R$ 3.200" />
<ClienteInativoItem nome="DISTRIBUIDORA NORTE" dias={62} ultimaCompra="R$ 1.880" />
<ClienteInativoItem nome="MERCADO SÃO PAULO" dias={71} ultimaCompra="R$ 980" />
</Flex>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title={
<Space>
<FontAwesomeIcon icon={faRoute} style={{ color: 'var(--jcs-blue)' }} />
Próxima visita
</Space>
}
>
<Flex vertical gap={12}>
<Space direction="vertical" size={4}>
<Title level={4} style={{ margin: 0, color: 'var(--jcs-blue)' }}>
OPENFRIOS
</Title>
<Text type="secondary">
Rua das Indústrias, 1.245 · São Paulo, SP · 14:30
</Text>
</Space>
<Flex gap={12} wrap="wrap">
<Tag icon={<FontAwesomeIcon icon={faClipboardCheck} />} color="processing">
3 pedidos em andamento
</Tag>
<Tag icon={<FontAwesomeIcon icon={faMessage} />} color="success">
WhatsApp atualizado
</Tag>
</Flex>
</Flex>
</Card>
</Col>
</Row>
{/* Footer informativo (sem ruído — tom Apple clean) */}
<Flex justify="center" style={{ paddingTop: 16 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
SAR · Força de Vendas · Powered by JCS Sistemas
</Text>
</Flex>
</Flex>
);
}
function ClienteInativoItem({
nome,
dias,
ultimaCompra,
}: {
nome: string;
dias: number;
ultimaCompra: string;
}) {
return (
<Flex
justify="space-between"
align="center"
style={{
padding: 'var(--space-sm) var(--space-md)',
borderRadius: 12,
background: 'var(--bg-surface-alt)',
}}
>
<Space direction="vertical" size={0}>
<Text strong>{nome}</Text>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Última compra: <span className="tabular-nums">{ultimaCompra}</span>
</Text>
</Space>
<Tag color="warning" className="tabular-nums">
{dias} dias
</Tag>
</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

@@ -0,0 +1,192 @@
import { useState } from 'react';
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 { useBreakpoint } = Grid;
const { Title, Text } = Typography;
const { Search } = Input;
function fmtPrice(v: string | null | undefined): string {
const n = Number(v ?? 0);
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
}
function buildColumns(
onDetail: (id: number) => void,
condicoesDe: (p: ProdutoSummary) => CondicaoComercial[],
): TableColumnsType<ProdutoSummary> {
return [
{
title: 'Código',
dataIndex: 'codigo',
width: 110,
render: (v: string) => <span style={{ fontVariantNumeric: 'tabular-nums' }}>{v.trim()}</span>,
},
{
title: 'Descrição',
dataIndex: 'descricao',
render: (v: string, row: ProdutoSummary) => (
<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>
),
},
{
title: 'Und',
dataIndex: 'unidade',
width: 60,
align: 'center',
render: (v: string | null) => v ?? '—',
},
{
title: 'Marca',
dataIndex: 'marca',
width: 130,
render: (v: string | null) => (v ? <Tag>{v.trim()}</Tag> : null),
},
{
title: 'Preço',
dataIndex: 'vlPreco1',
width: 120,
align: 'right',
render: (v: string) => (
<span style={{ fontWeight: 600, color: Number(v) > 0 ? '#389e0d' : '#999' }}>
{fmtPrice(v)}
</span>
),
},
{
title: 'Estoque',
dataIndex: 'qtdEstoque',
width: 90,
align: 'right',
render: (v: string | null) => {
if (v == null) return '—';
const n = Number(v);
return (
<span style={{ color: n > 0 ? 'inherit' : '#f5222d' }}>{n.toLocaleString('pt-BR')}</span>
);
},
},
{
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={{ 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>
{/* ── Filtros ──────────────────────────────────────────────────── */}
<Space style={{ marginBottom: 16, width: '100%' }} wrap>
<Search
placeholder="Buscar por código ou descrição..."
allowClear
style={{ width: isMobile ? '100%' : 300 }}
onSearch={(v) => {
setQ(v);
setPage(1);
}}
onChange={(e) => {
if (!e.target.value) {
setQ('');
setPage(1);
}
}}
/>
<Select
placeholder="Selecionar pauta de preços"
allowClear
loading={pautasLoading}
style={{ width: isMobile ? '100%' : 340 }}
onChange={(v) => {
setIdPauta(v as number | undefined);
setPage(1);
}}
options={pautas?.map((p) => ({
value: p.idPauta,
label: `${p.codigo}${p.descricao}`,
}))}
/>
</Space>
<Table<ProdutoSummary>
rowKey="idErp"
columns={columns}
dataSource={data?.data ?? []}
loading={isLoading}
size="small"
pagination={{
current: page,
pageSize: limit,
total: data?.total ?? 0,
showSizeChanger: false,
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

@@ -0,0 +1,587 @@
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 { CtrTitulo, NotaFiscal, PedidoSummary, TopProduto } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import {
useClientDetail,
useClientCtr,
useClientCtrList,
useClientNotasFiscais,
useClientOrdersHistory,
useClientTopProdutos,
} from '../../lib/queries/clients';
import { ClientContacts } from '../../components/contacts/ClientContacts';
const { useBreakpoint } = Grid;
const { Title, Text } = Typography;
const ACTIVITY_COLOR: Record<string, string> = {
active: 'success',
alert: 'warning',
inactive: 'default',
};
const ACTIVITY_LABEL: Record<string, string> = {
active: 'Ativo',
alert: 'Alerta',
inactive: 'Inativo',
};
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º Pedido',
dataIndex: 'numPedSar',
render: (num: string, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
{
title: 'Status',
dataIndex: 'situa',
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' as const,
render: (v: string) =>
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Data',
dataIndex: 'dtPedido',
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: 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" 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, maxWidth: '100%', overflowX: 'hidden' }}>
<Space align="center" style={{ marginBottom: 16 }} wrap>
<Link to="/clientes"> Clientes</Link>
<Title level={3} style={{ margin: 0 }}>
{client.razao ?? client.nome}
</Title>
<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 } })}
>
Novo Pedido
</Button>
</Space>
<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>
<Descriptions.Item label="Telefone">
{client.ddd ? `(${client.ddd}) ` : ''}
{client.telefone ?? '—'}
</Descriptions.Item>
{client.endereco && (
<Descriptions.Item label="Endereço" span={isMobile ? 1 : 2}>
{client.endereco}
{client.numEndereco ? `, ${client.numEndereco}` : ''}
{client.bairro ? `${client.bairro}` : ''}
{client.cep ? ` — CEP ${client.cep}` : ''}
</Descriptions.Item>
)}
<Descriptions.Item label="Limite de Crédito">
{client.limiteCreditoStr
? Number(client.limiteCreditoStr).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})
: '—'}
</Descriptions.Item>
<Descriptions.Item label="Última Compra">
{client.dtUltimaCompra
? new Date(client.dtUltimaCompra).toLocaleDateString('pt-BR')
: '—'}
</Descriptions.Item>
</Descriptions>
{/* ── 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(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>
);
}

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,540 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Alert,
Badge,
Button,
Descriptions,
Divider,
Form,
InputNumber,
Modal,
Space,
Spin,
Table,
Tag,
Timeline,
Typography,
Input,
message,
} from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faShareNodes } from '@fortawesome/free-solid-svg-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';
import { SITUA_LABEL } from '@sar/api-interface';
import { useOrderDetail } from '../../lib/queries/orders';
import { useClientOrders } from '../../lib/queries/orders';
import { apiFetch } from '../../lib/api-client';
import { authStore } from '../../lib/auth-store';
const { Title, Text } = Typography;
const { TextArea } = Input;
// ─── Helpers ──────────────────────────────────────────────────────────────────
const SITUA_COLOR: Record<number, string> = {
0: 'default',
1: 'warning',
2: 'processing',
3: 'error',
4: 'success',
};
function fmt(v: string | number): string {
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function buildShareText(order: {
numPedSar: string;
idCliente: number;
total: string;
itens: Array<{ descProduto: string | null; qtd: string; precoUnitario: string }>;
}): string {
const lines = [
`*Pedido ${order.numPedSar} — Cliente ${order.idCliente}*`,
'',
...order.itens.map(
(it) =>
`${it.descProduto ?? '?'} × ${Number(it.qtd).toLocaleString('pt-BR')}${fmt(it.precoUnitario)} un.`,
),
'',
`*Total: ${fmt(order.total)}*`,
];
return lines.join('\n');
}
function getRoleFromToken(): 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';
}
}
// ─── Subcomponents ────────────────────────────────────────────────────────────
const itemColumns: TableColumnsType<PedidoItem> = [
{ title: 'Código', dataIndex: 'codProduto', width: 100 },
{ title: 'Produto', dataIndex: 'descProduto', ellipsis: true },
{ title: 'Qtd', dataIndex: 'qtd', width: 90, align: 'right' },
{
title: 'Preço Unit.',
dataIndex: 'precoUnitario',
width: 120,
align: 'right',
render: (v: string) => fmt(v),
},
{
title: 'Desc %',
dataIndex: 'descontoPerc',
width: 80,
align: 'right',
render: (v: string) => `${v}%`,
},
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right',
render: (v: string) => fmt(v),
},
];
function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
return (
<Timeline
items={history.map((h) => ({
color:
SITUA_COLOR[h.situaNova] === 'success'
? 'green'
: SITUA_COLOR[h.situaNova] === 'warning'
? 'orange'
: SITUA_COLOR[h.situaNova] === 'error'
? 'red'
: 'blue',
content: (
<div>
<Text strong>{SITUA_LABEL[h.situaNova] ?? String(h.situaNova)}</Text>
{h.situaAnterior != null && (
<Text type="secondary">
{' '}
(de {SITUA_LABEL[h.situaAnterior] ?? String(h.situaAnterior)})
</Text>
)}
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(h.changedAt).toLocaleString('pt-BR')} cod. {h.changedBy}
</Text>
{h.nota && (
<div style={{ marginTop: 4 }}>
<Text italic>"{h.nota}"</Text>
</div>
)}
</div>
),
}))}
/>
);
}
// ─── Approve Modal ────────────────────────────────────────────────────────────
function ApproveModal({
open,
originalDiscount,
onConfirm,
onCancel,
loading,
}: {
open: boolean;
originalDiscount: string;
onConfirm: (descontoPerc?: number, nota?: string) => void;
onCancel: () => void;
loading: boolean;
}) {
const [disc, setDisc] = useState<number | null>(null);
const [nota, setNota] = useState('');
return (
<Modal
title="Aprovar Pedido"
open={open}
onOk={() => onConfirm(disc ?? undefined, nota || undefined)}
onCancel={onCancel}
okText="Confirmar Aprovação"
cancelText="Voltar"
confirmLoading={loading}
>
<Form layout="vertical">
<Form.Item
label={`Desconto global (original: ${originalDiscount}%)`}
help="Deixe em branco para manter o desconto solicitado."
>
<InputNumber
min={0}
max={100}
step={0.5}
placeholder={originalDiscount}
value={disc}
onChange={(v) => setDisc(v)}
addonAfter="%"
style={{ width: 160 }}
/>
</Form.Item>
<Form.Item label="Observação (opcional)">
<TextArea
rows={2}
value={nota}
onChange={(e) => setNota(e.target.value)}
maxLength={300}
/>
</Form.Item>
</Form>
</Modal>
);
}
// ─── Reject Modal ─────────────────────────────────────────────────────────────
function RejectModal({
open,
onConfirm,
onCancel,
loading,
}: {
open: boolean;
onConfirm: (motivo: string) => void;
onCancel: () => void;
loading: boolean;
}) {
const [motivo, setMotivo] = useState('');
return (
<Modal
title="Recusar Pedido"
open={open}
onOk={() => motivo.trim() && onConfirm(motivo.trim())}
onCancel={onCancel}
okText="Confirmar Recusa"
okButtonProps={{ danger: true, disabled: !motivo.trim() }}
cancelText="Voltar"
confirmLoading={loading}
>
<Form layout="vertical">
<Form.Item label="Motivo da recusa" required>
<TextArea
rows={3}
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
maxLength={500}
showCount
placeholder="Informe o motivo para o representante..."
/>
</Form.Item>
</Form>
</Modal>
);
}
// ─── OrderDetailPage ──────────────────────────────────────────────────────────
export function OrderDetailPage() {
const { id } = useParams({ from: '/pedidos/$id' });
const navigate = useNavigate();
const qc = useQueryClient();
const { data: order, isLoading, error } = useOrderDetail(id);
const { data: clientOrders } = useClientOrders(order?.idCliente);
const role = getRoleFromToken();
const isErp = order?.fonte === 'erp';
const canAct = !isErp && role !== 'rep' && order?.situa === 1;
const canTransmit = !isErp && role === 'rep' && order?.situa === 0;
const canShare =
role === 'rep' &&
(order?.situa === 2 || order?.situa === 4) &&
typeof navigator !== 'undefined' &&
!!navigator.share;
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [chamadoOpen, setChamadoOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const approveMutation = useMutation({
mutationFn: ({ descontoPerc, nota }: { descontoPerc?: number; nota?: string }) =>
apiFetch(`/orders/${id}/approve`, { method: 'PATCH', body: { descontoPerc, nota } }),
onSuccess: () => {
setApproveOpen(false);
void qc.invalidateQueries({ queryKey: ['orders', id] });
void qc.invalidateQueries({ queryKey: ['orders'] });
},
onError: (e: unknown) => {
setApproveOpen(false);
setActionError(e instanceof Error ? e.message : 'Erro ao aprovar');
},
});
const rejectMutation = useMutation({
mutationFn: (motivo: string) =>
apiFetch(`/orders/${id}/reject`, { method: 'PATCH', body: { motivo } }),
onSuccess: () => {
setRejectOpen(false);
void qc.invalidateQueries({ queryKey: ['orders', id] });
void qc.invalidateQueries({ queryKey: ['orders'] });
},
onError: (e: unknown) => {
setRejectOpen(false);
setActionError(e instanceof Error ? e.message : 'Erro ao recusar');
},
});
const transmitMutation = useMutation({
mutationFn: () => apiFetch(`/orders/${id}/transmit`, { method: 'PATCH' }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['orders', id] });
void qc.invalidateQueries({ queryKey: ['orders'] });
},
// Mensagem de bloqueio de alçada (desconto acima do máximo) vem aqui.
onError: (e: unknown) => setActionError(e instanceof Error ? e.message : 'Erro ao transmitir'),
});
if (isLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
if (error || !order)
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
const timeWaiting =
order.situa === 1
? Math.floor((Date.now() - new Date(order.createdAt).getTime()) / 3_600_000)
: null;
// Orçamento: tela mais larga para consulta/revisão com o cliente.
const isOrcamento = order.situa === 0;
return (
<div style={{ padding: 24, maxWidth: isOrcamento ? 1320 : 960, margin: '0 auto' }}>
<Space align="center" style={{ marginBottom: 16 }} wrap>
<Link to="/pedidos"> Pedidos</Link>
<Title level={3} style={{ margin: 0 }}>
{order.numPedSar}
</Title>
<Badge
status={
(SITUA_COLOR[order.situa] ?? 'default') as
| 'default'
| 'warning'
| 'processing'
| 'success'
| 'error'
}
text={
<Tag color={SITUA_COLOR[order.situa] ?? 'default'}>
{SITUA_LABEL[order.situa] ?? String(order.situa)}
</Tag>
}
/>
{timeWaiting !== null && timeWaiting > 2 && (
<Tag color="red">Urgente {timeWaiting}h aguardando</Tag>
)}
<Button
icon={<FilePdfOutlined />}
onClick={() => navigate({ to: '/pedidos/$id/imprimir', params: { id } })}
>
Gerar PDF
</Button>
{canTransmit && (
<Button
type="primary"
loading={transmitMutation.isPending}
onClick={() => {
setActionError(null);
transmitMutation.mutate();
}}
style={{ backgroundColor: '#389e0d', borderColor: '#389e0d' }}
>
Transmitir pedido
</Button>
)}
{canAct && (
<Space>
<Button type="primary" onClick={() => setApproveOpen(true)}>
Aprovar
</Button>
<Button danger onClick={() => setRejectOpen(true)}>
Recusar
</Button>
</Space>
)}
{canShare && (
<Button
icon={<FontAwesomeIcon icon={faShareNodes} />}
onClick={async () => {
try {
await navigator.share({ text: buildShareText(order) });
} catch {
void message.info('Compartilhamento cancelado');
}
}}
>
Compartilhar
</Button>
)}
{role === 'rep' && !isErp && (order.situa === 2 || order.situa === 4) && (
<Button icon={<CustomerServiceOutlined />} onClick={() => setChamadoOpen(true)}>
Abrir Chamado
</Button>
)}
</Space>
{actionError && (
<Alert
type="error"
message={actionError}
showIcon
closable
onClose={() => setActionError(null)}
style={{ marginBottom: 16 }}
/>
)}
<Descriptions
bordered
size={isOrcamento ? 'middle' : 'small'}
column={isOrcamento ? 3 : 2}
style={{ marginBottom: 24 }}
>
<Descriptions.Item label="Cliente">
<Link to="/clientes/$id" params={{ id: String(order.idCliente) }}>
{order.razaoCliente ?? order.nomeCliente ?? `Cód. ${order.idCliente}`}
</Link>
</Descriptions.Item>
<Descriptions.Item label="Representante">
{order.nomeVendedor ?? `Cód. ${order.codVendedor}`}
</Descriptions.Item>
<Descriptions.Item label="Data">
{new Date(order.dtPedido).toLocaleDateString('pt-BR')}
</Descriptions.Item>
{order.aprovadoEm && (
<Descriptions.Item label="Aprovado em">
{new Date(order.aprovadoEm).toLocaleString('pt-BR')} cód. {order.aprovadoPor}
</Descriptions.Item>
)}
{order.formaPagamento && (
<Descriptions.Item label="Cond. Pagamento" span={2}>
{order.formaPagamento}
</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" 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={isOrcamento ? 3 : 2}>
{order.obs}
</Descriptions.Item>
)}
{order.motivoRecusa && (
<Descriptions.Item label="Motivo Recusa" span={isOrcamento ? 3 : 2}>
<Text type="danger">{order.motivoRecusa}</Text>
</Descriptions.Item>
)}
</Descriptions>
<Divider titlePlacement="left">Itens ({order.itens.length})</Divider>
<Table<PedidoItem>
rowKey="id"
columns={itemColumns}
dataSource={order.itens}
pagination={false}
size={isOrcamento ? 'middle' : 'small'}
style={{ marginBottom: 24 }}
/>
{clientOrders && clientOrders.length > 0 && (
<>
<Divider titlePlacement="left">Outros Pedidos do Cliente</Divider>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={clientOrders.filter((o) => o.id !== id).slice(0, 5)}
columns={[
{
title: 'Nº',
dataIndex: 'numPedSar',
width: 110,
render: (n: string, r: { id: string }) => (
<Link to="/pedidos/$id" params={{ id: r.id }}>
{n}
</Link>
),
},
{
title: 'Status',
dataIndex: 'situa',
width: 130,
render: (s: number) => (
<Tag color={SITUA_COLOR[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>
),
},
{
title: 'Total',
dataIndex: 'total',
align: 'right' as const,
render: (v: string) => fmt(v),
},
{
title: 'Data',
dataIndex: 'dtPedido',
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
]}
style={{ marginBottom: 24 }}
/>
</>
)}
<Divider titlePlacement="left">Histórico do Pedido</Divider>
<HistoryTimeline history={order.historico} />
<ApproveModal
open={approveOpen}
originalDiscount={order.descontoPerc}
onConfirm={(descontoPerc, nota) => approveMutation.mutate({ descontoPerc, nota })}
onCancel={() => setApproveOpen(false)}
loading={approveMutation.isPending}
/>
<RejectModal
open={rejectOpen}
onConfirm={(motivo) => rejectMutation.mutate(motivo)}
onCancel={() => setRejectOpen(false)}
loading={rejectMutation.isPending}
/>
{order && (
<AbrirChamadoModal
open={chamadoOpen}
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

@@ -0,0 +1,469 @@
import { useEffect } from 'react';
import { Button, Spin, Alert } from 'antd';
import { PrinterOutlined, ArrowLeftOutlined } from '@ant-design/icons';
import { useParams, useNavigate } from '@tanstack/react-router';
import { SITUA_LABEL } from '@sar/api-interface';
import { useOrderDetail } from '../../lib/queries/orders';
import { useClientDetail } from '../../lib/queries/clients';
import { useCompany } from '../../lib/queries/company';
// ─── Paleta / tokens ────────────────────────────────────────────────────────
const BLUE = '#003B8E';
const INK = '#1F2937';
const MUTED = '#64748B';
const LINE = '#E5EAF0';
// ─── Helpers de formatação ──────────────────────────────────────────────────
function money(v: string | number | null | undefined): string {
const n = typeof v === 'string' ? parseFloat(v) : (v ?? 0);
return (isNaN(n as number) ? 0 : (n as number)).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
});
}
function qty(v: string | number): string {
return Number(v).toLocaleString('pt-BR', { maximumFractionDigits: 3 });
}
function dateBR(v: string | null | undefined): string {
return v ? new Date(v).toLocaleDateString('pt-BR') : '—';
}
function doc(raw: string | null | undefined): string {
if (!raw) return '—';
const d = raw.replace(/\D/g, '');
if (d.length === 14) return d.replace(/(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})/, '$1.$2.$3/$4-$5');
if (d.length === 11) return d.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4');
return raw;
}
function phone(raw: string | null | undefined, ddd?: string | null): string {
const d = `${ddd ?? ''}${raw ?? ''}`.replace(/\D/g, '');
if (d.length === 11) return d.replace(/(\d{2})(\d{5})(\d{4})/, '($1) $2-$3');
if (d.length === 10) return d.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2-$3');
return raw ?? '—';
}
function cep(raw: string | null | undefined): string {
const d = (raw ?? '').replace(/\D/g, '');
return d.length === 8 ? d.replace(/(\d{5})(\d{3})/, '$1-$2') : (raw ?? '');
}
// Campos char do ERP vêm com padding — limpa, devolve null se vazio.
function tx(s: string | null | undefined): string | null {
const t = (s ?? '').trim();
return t === '' ? null : t;
}
// ─── Blocos visuais ─────────────────────────────────────────────────────────
const label: React.CSSProperties = {
fontSize: 9,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: MUTED,
display: 'block',
marginBottom: 2,
};
export function OrderPrintPage() {
const { id } = useParams({ from: '/pedidos/$id/imprimir' });
const navigate = useNavigate();
const { data: order, isLoading, error } = useOrderDetail(id);
const { data: client } = useClientDetail(order?.idCliente);
const { data: empresa } = useCompany();
// Auto-abre o diálogo de impressão quando tudo carregou.
useEffect(() => {
if (order && empresa) {
const t = setTimeout(() => window.print(), 600);
return () => clearTimeout(t);
}
}, [order, empresa]);
if (isLoading) return <Spin style={{ display: 'block', marginTop: 80 }} />;
if (error || !order)
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
const enderecoCli = client
? [
tx(client.endereco),
tx(client.numEndereco),
tx(client.bairro),
client.cep ? `CEP ${cep(client.cep)}` : null,
]
.filter(Boolean)
.join(', ')
: null;
const cgDigits = (client?.cgcpf ?? '').replace(/\D/g, '').length;
const docLabel = cgDigits === 11 ? 'CPF' : 'CNPJ';
const enderecoEmp = empresa
? [empresa.endereco, empresa.numero, empresa.complemento, empresa.bairro]
.filter(Boolean)
.join(', ')
: null;
const cidadeEmp = empresa
? [empresa.cidade, empresa.uf].filter(Boolean).join(' - ') +
(empresa.cep ? ` · CEP ${cep(empresa.cep)}` : '')
: null;
const clienteNome =
tx(order.razaoCliente) ?? tx(order.nomeCliente) ?? `Cliente ${order.idCliente}`;
const temDesc = Number(order.descontoValor) > 0 || Number(order.descontoPerc) > 0;
return (
<div style={{ background: '#EEF2F7', minHeight: '100vh', padding: '24px 0 60px' }}>
{/* Barra de ações (não imprime) */}
<div
className="no-print"
style={{
maxWidth: 820,
margin: '0 auto 16px',
display: 'flex',
justifyContent: 'space-between',
padding: '0 8px',
}}
>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate({ to: '/pedidos/$id', params: { id } })}
>
Voltar
</Button>
<Button type="primary" icon={<PrinterOutlined />} onClick={() => window.print()}>
Imprimir / Salvar PDF
</Button>
</div>
{/* Documento A4 */}
<div
className="sar-print"
style={{
width: '100%',
maxWidth: 820,
margin: '0 auto',
background: '#fff',
boxShadow: '0 4px 24px rgba(0,0,0,0.10)',
borderRadius: 4,
overflow: 'hidden',
fontFamily: "'Plus Jakarta Sans Variable', system-ui, sans-serif",
color: INK,
}}
>
{/* ── Cabeçalho: empresa matriz que fatura ───────────────────────── */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
padding: '22px 28px',
borderTop: `5px solid ${BLUE}`,
background: 'linear-gradient(180deg,#F8FAFD 0%,#fff 100%)',
borderBottom: `1px solid ${LINE}`,
}}
>
<div style={{ maxWidth: 460 }}>
<div style={{ fontSize: 19, fontWeight: 800, color: BLUE, lineHeight: 1.1 }}>
{empresa?.nomeFantasia ?? empresa?.razaoSocial ?? '...'}
</div>
<div style={{ fontSize: 11, color: MUTED, marginTop: 2 }}>{empresa?.razaoSocial}</div>
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6, lineHeight: 1.5 }}>
{empresa?.cnpj && <>CNPJ {empresa.cnpj}</>}
{empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}</>}
{enderecoEmp && <div>{enderecoEmp}</div>}
{cidadeEmp && <div>{cidadeEmp}</div>}
{(empresa?.telefone || empresa?.email) && (
<div>
{empresa?.telefone && <>Tel {phone(empresa.telefone)}</>}
{empresa?.telefone && empresa?.email && <> · </>}
{empresa?.email}
</div>
)}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 10, color: MUTED, fontWeight: 700, letterSpacing: '0.1em' }}>
PEDIDO
</div>
<div style={{ fontSize: 22, fontWeight: 800, color: INK, lineHeight: 1.1 }}>
{order.numPedSar}
</div>
<div
style={{
display: 'inline-block',
marginTop: 6,
padding: '2px 10px',
borderRadius: 20,
background: `${BLUE}12`,
color: BLUE,
fontSize: 10.5,
fontWeight: 700,
}}
>
{SITUA_LABEL[order.situa] ?? String(order.situa)}
</div>
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6 }}>
Emissão: {dateBR(order.dtPedido)}
</div>
</div>
</div>
{/* ── Representante + Cliente ─────────────────────────────────────── */}
<div
style={{
display: 'flex',
borderBottom: `1px solid ${LINE}`,
background: '#FAFBFD',
}}
>
{/* 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: 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: 10, color: MUTED, lineHeight: 1.3 }}>{tx(client?.nome)}</div>
)}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '2px 18px',
marginTop: 4,
fontSize: 10,
color: MUTED,
}}
>
{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>
</div>
{/* ── Itens ───────────────────────────────────────────────────────── */}
<div style={{ padding: '0 28px 0' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
<thead>
<tr style={{ background: '#F4F7FB' }}>
{['Cód.', 'Produto', 'Qtd', 'Preço un.', 'Desc.', 'Total'].map((h, i) => (
<th
key={h}
style={{
textAlign: i >= 2 ? 'right' : 'left',
padding: '8px 8px',
color: MUTED,
fontWeight: 700,
fontSize: 9.5,
letterSpacing: '0.05em',
textTransform: 'uppercase',
borderBottom: `2px solid ${LINE}`,
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{order.itens.map((it, idx) => (
<tr key={it.id} style={{ background: idx % 2 ? '#FBFCFE' : '#fff' }}>
<td
style={{ padding: '7px 8px', color: MUTED, borderBottom: `1px solid ${LINE}` }}
>
{it.codProduto ?? '—'}
</td>
<td style={{ padding: '7px 8px', color: INK, borderBottom: `1px solid ${LINE}` }}>
{it.descProduto ?? '—'}
</td>
<td
style={{
padding: '7px 8px',
textAlign: 'right',
borderBottom: `1px solid ${LINE}`,
}}
>
{qty(it.qtd)}
</td>
<td
style={{
padding: '7px 8px',
textAlign: 'right',
borderBottom: `1px solid ${LINE}`,
}}
>
{money(it.precoUnitario)}
</td>
<td
style={{
padding: '7px 8px',
textAlign: 'right',
color: MUTED,
borderBottom: `1px solid ${LINE}`,
}}
>
{Number(it.descontoPerc) > 0 ? `${Number(it.descontoPerc)}%` : '—'}
</td>
<td
style={{
padding: '7px 8px',
textAlign: 'right',
fontWeight: 600,
color: INK,
borderBottom: `1px solid ${LINE}`,
}}
>
{money(it.total)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* ── Totais ──────────────────────────────────────────────────────── */}
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '14px 28px 4px' }}>
<div style={{ width: 300 }}>
<TotRow k="Total dos produtos" v={money(order.totalProdutos)} />
{Number(order.totalIpi) > 0 && <TotRow k="IPI" v={money(order.totalIpi)} />}
{Number(order.totalIcmsst) > 0 && <TotRow k="ICMS-ST" v={money(order.totalIcmsst)} />}
{temDesc && (
<TotRow
k={`Desconto${Number(order.descontoPerc) > 0 ? ` (${Number(order.descontoPerc)}%)` : ''}`}
v={`- ${money(order.descontoValor)}`}
/>
)}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 8,
padding: '10px 14px',
background: BLUE,
borderRadius: 6,
color: '#fff',
}}
>
<span style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.04em' }}>TOTAL</span>
<span style={{ fontSize: 17, fontWeight: 800 }}>{money(order.total)}</span>
</div>
</div>
</div>
{/* ── 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={{ 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',
padding: '12px 0 18px',
borderTop: `1px solid ${LINE}`,
display: 'flex',
justifyContent: 'space-between',
fontSize: 9.5,
color: '#94A3B8',
}}
>
<span>
Documento sem valor fiscal · Pedido de venda emitido pelo representante via SAR.
</span>
<span>SAR · Powered by JCS Sistemas</span>
</div>
</div>
{/* CSS de impressão: esconde tudo menos o documento */}
<style>{`
@media print {
@page { size: A4; margin: 10mm; }
body * { visibility: hidden !important; }
.sar-print, .sar-print * { visibility: visible !important; }
.sar-print { position: absolute; left: 0; top: 0; width: 100% !important;
max-width: none !important; box-shadow: none !important; border-radius: 0 !important; }
.no-print { display: none !important; }
}
`}</style>
</div>
);
}
function TotRow({ k, v }: { k: string; v: string }) {
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
padding: '4px 14px',
fontSize: 11.5,
color: MUTED,
}}
>
<span>{k}</span>
<span style={{ color: INK, fontWeight: 600 }}>{v}</span>
</div>
);
}

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

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,696 @@
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 { 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> = {
1: 'warning',
2: 'processing',
3: 'error',
4: 'success',
};
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function greeting(): string {
const h = new Date().getHours();
if (h < 12) return 'Bom dia';
if (h < 18) return 'Boa tarde';
return 'Boa noite';
}
function today(): string {
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 });
}
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,
money,
dec = 0,
}: {
real: number;
meta: number;
money?: boolean;
dec?: number;
}) {
const f = (v: number) => (money ? fmt(v) : num(v, dec));
const ok = meta > 0 && real >= meta;
return (
<Space orientation="vertical" size={0} style={{ lineHeight: 1.15 }}>
<Text strong className="tabular-nums" style={{ color: ok ? 'var(--green)' : undefined }}>
{f(real)}
</Text>
<Text type="secondary" className="tabular-nums" style={{ fontSize: 'var(--text-xs)' }}>
/ {f(meta)}
</Text>
</Space>
);
}
const metaColumns: TableColumnsType<MetaItem> = [
{
title: 'Grupo',
dataIndex: 'rotulo',
key: 'rotulo',
fixed: 'left',
width: 180,
render: (v: string) => <Text strong>{v}</Text>,
},
{
title: 'Pedidos',
dataIndex: 'pedidos',
key: 'pedidos',
align: 'right',
width: 80,
render: (v: number) => <span className="tabular-nums">{num(v)}</span>,
},
{
title: 'Qtde',
key: 'qtd',
align: 'right',
width: 110,
render: (_: unknown, r: MetaItem) => <MetaCell real={r.qtdReal} meta={r.qtdMeta} />,
},
{
title: 'Peso (kg)',
key: 'peso',
align: 'right',
width: 120,
render: (_: unknown, r: MetaItem) => <MetaCell real={r.pesoReal} meta={r.pesoMeta} />,
},
{
title: 'Valor',
key: 'valor',
align: 'right',
width: 160,
render: (_: unknown, r: MetaItem) => <MetaCell real={r.valorReal} meta={r.valorMeta} money />,
},
{
title: 'Fator (R$/kg)',
key: 'fator',
align: 'right',
width: 110,
render: (_: unknown, r: MetaItem) => <MetaCell real={r.fatorReal} meta={r.fatorMeta} dec={2} />,
},
{
title: '% da meta (valor)',
key: 'pct',
align: 'center',
width: 160,
render: (_: unknown, r: MetaItem) => (
<Progress
percent={Math.min(r.pct, 100)}
size="small"
format={() => `${r.pct}%`}
strokeColor={r.pct >= 100 ? 'var(--green)' : 'var(--jcs-blue)'}
/>
),
},
];
// ─── 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();
if (isLoading || !data) {
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
<Skeleton active paragraph={{ rows: 2 }} />
<Row gutter={[24, 24]}>
<Col xs={24} md={14}>
<Skeleton active />
</Col>
<Col xs={24} md={10}>
<Skeleton active />
</Col>
</Row>
</Flex>
);
}
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' }}>
{/* Saudação */}
<Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}>
{greeting()}, {user?.nome?.split(' ')[0] ?? '...'}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
{today()}
{totalNaoPositivados > 0 && (
<>
{' '}
·{' '}
<span style={{ color: 'var(--orange)' }}>
{totalNaoPositivados} clientes sem pedido no mês
</span>
</>
)}
</Text>
</Flex>
{/* Linha 1 — Meta + Pedidos do mês */}
<Row gutter={[24, 24]}>
<Col xs={24} md={14}>
<Card style={{ height: '100%' }}>
<Flex vertical gap={16}>
<Flex justify="space-between" align="flex-start">
<Space orientation="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
META DO MÊS
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(meta.atingido)}
</Title>
<Text type="secondary">
de <span className="tabular-nums">{fmt(meta.total)}</span>
</Text>
</Space>
<Tag
color={meta.pct >= 100 ? 'success' : meta.pct >= 75 ? 'processing' : 'default'}
>
{meta.pct}% atingido
</Tag>
</Flex>
<Progress
percent={Math.min(meta.pct, 100)}
showInfo={false}
strokeColor="var(--jcs-blue)"
railColor="var(--jcs-blue-light)"
/>
{meta.falta > 0 ? (
<Text style={{ fontSize: 'var(--text-md)' }}>
Faltam <strong className="tabular-nums">{fmt(meta.falta)}</strong> pra fechar o
mês.
</Text>
) : (
<Text style={{ fontSize: 'var(--text-md)', color: 'var(--green)' }}>
Meta batida! Comissão FLEX ativa.
</Text>
)}
</Flex>
</Card>
</Col>
<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
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{pedidosMes}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
<FontAwesomeIcon icon={faArrowTrendUp} /> mês corrente
</Text>
</Space>
</Card>
</Col>
</Row>
{/* Metas por Grupo */}
{metasPorGrupo.length > 0 && (
<Card
title={
<Space>
<FontAwesomeIcon icon={faBullseye} style={{ color: 'var(--jcs-blue)' }} />
Metas por Grupo
</Space>
}
extra={
<Tag color={meta.pct >= 100 ? 'success' : meta.pct >= 75 ? 'processing' : 'default'}>
{meta.pct}% no valor total
</Tag>
}
>
<Table<MetaItem>
rowKey={(r) => String(r.codigo)}
columns={metaColumns}
dataSource={metasPorGrupo}
size="small"
pagination={false}
scroll={{ x: 820 }}
summary={(rows) => {
const t = rows.reduce(
(a, r) => ({
pedidos: a.pedidos + r.pedidos,
qtdReal: a.qtdReal + r.qtdReal,
qtdMeta: a.qtdMeta + r.qtdMeta,
pesoReal: a.pesoReal + r.pesoReal,
pesoMeta: a.pesoMeta + r.pesoMeta,
valorReal: a.valorReal + r.valorReal,
valorMeta: a.valorMeta + r.valorMeta,
}),
{
pedidos: 0,
qtdReal: 0,
qtdMeta: 0,
pesoReal: 0,
pesoMeta: 0,
valorReal: 0,
valorMeta: 0,
},
);
const pctTotal = t.valorMeta > 0 ? Math.round((t.valorReal / t.valorMeta) * 100) : 0;
const fatorReal = t.pesoReal > 0 ? t.valorReal / t.pesoReal : 0;
const fatorMeta = t.pesoMeta > 0 ? t.valorMeta / t.pesoMeta : 0;
return (
<Table.Summary.Row style={{ background: 'var(--bg-surface-alt)' }}>
<Table.Summary.Cell index={0}>
<Text strong>Total</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<span className="tabular-nums">{num(t.pedidos)}</span>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} align="right">
<MetaCell real={t.qtdReal} meta={t.qtdMeta} />
</Table.Summary.Cell>
<Table.Summary.Cell index={3} align="right">
<MetaCell real={t.pesoReal} meta={t.pesoMeta} />
</Table.Summary.Cell>
<Table.Summary.Cell index={4} align="right">
<MetaCell real={t.valorReal} meta={t.valorMeta} money />
</Table.Summary.Cell>
<Table.Summary.Cell index={5} align="right">
<MetaCell real={fatorReal} meta={fatorMeta} dec={2} />
</Table.Summary.Cell>
<Table.Summary.Cell index={6} align="center">
<Text strong>{pctTotal}%</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
}}
/>
</Card>
)}
{/* 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={14}>
<NaoPositivados clientes={naoPositivados} total={totalNaoPositivados} />
</Col>
<Col xs={24} lg={10}>
<Card
title={
<Space>
<FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} />
Pedidos recentes
</Space>
}
extra={<Link to="/pedidos">Ver todos</Link>}
>
{pedidosRecentes.length === 0 ? (
<Text type="secondary">Nenhum pedido nos últimos 7 dias.</Text>
) : (
<Flex vertical gap={10}>
{pedidosRecentes.map((o: PedidoSummary) => (
<Flex key={o.id} justify="space-between" align="center">
<Space orientation="vertical" size={0}>
<Link to="/pedidos/$id" params={{ id: o.id }}>
<Text strong className="tabular-nums">
{o.numPedSar}
</Text>
</Link>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{o.razaoCliente ?? o.nomeCliente ?? `Cód. cliente ${o.idCliente}`}
</Text>
</Space>
<Flex gap={8} align="center">
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
{Number(o.total).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})}
</Text>
<Tag color={SITUA_COLOR[o.situa] ?? 'default'}>
{SITUA_LABEL[o.situa] ?? String(o.situa)}
</Tag>
</Flex>
</Flex>
))}
</Flex>
)}
</Card>
</Col>
</Row>
<Flex justify="space-between" style={{ paddingTop: 8 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
SAR · Força de Vendas · Powered by JCS Sistemas
</Text>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')}
</Text>
</Flex>
</Flex>
);
}

View File

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

View File

@@ -0,0 +1,341 @@
import { Badge, Card, Col, Flex, Row, Skeleton, Space, Table, Tag, Typography } from 'antd';
import type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCheckCircle,
faCircleExclamation,
faClipboardList,
} from '@fortawesome/free-solid-svg-icons';
import { Link } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
import { useSupervisorDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth';
const { Title, Text } = Typography;
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function hoursWaiting(createdAt: string): number {
return Math.floor((Date.now() - new Date(createdAt).getTime()) / 3_600_000);
}
function delta(current: number, previous: number): { label: string; positive: boolean } | null {
if (previous === 0) return null;
const pct = Math.round(((current - previous) / previous) * 100);
return { label: `${pct >= 0 ? '+' : ''}${pct}% vs semana passada`, positive: pct >= 0 };
}
function greeting(): string {
const h = new Date().getHours();
if (h < 12) return 'Bom dia';
if (h < 18) return 'Boa tarde';
return 'Boa noite';
}
function today(): string {
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
}
const queueColumns: TableColumnsType<PedidoSummary> = [
{
title: 'Pedido',
dataIndex: 'numPedSar',
width: 120,
render: (num: string, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
{
title: 'Representante',
key: 'rep',
width: 150,
render: (_: unknown, row: PedidoSummary) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Cliente',
key: 'cliente',
width: 180,
render: (_: unknown, row: PedidoSummary) =>
row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`,
},
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right',
render: (v: string) => fmt(Number(v)),
},
{
title: 'Aguardando',
dataIndex: 'createdAt',
width: 120,
render: (v: string) => {
const h = hoursWaiting(v);
return <Tag color={h > 2 ? 'red' : 'orange'}>{h}h</Tag>;
},
},
{
title: '',
width: 90,
render: (_: unknown, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
<Tag color="blue" style={{ cursor: 'pointer' }}>
Analisar
</Tag>
</Link>
),
},
];
export function SupervisorPainel() {
const { data, isLoading } = useSupervisorDashboard();
const { data: user } = useCurrentUser();
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].map((i) => (
<Col key={i} xs={24} md={8}>
<Skeleton active />
</Col>
))}
</Row>
</Flex>
);
}
const { approvalQueue, pedidosDia, inativosPorRep, syncedAt } = data;
const urgentCount = approvalQueue.filter((o) => hoursWaiting(o.createdAt) > 2).length;
const countDelta = delta(pedidosDia.count, pedidosDia.countSemanaAnterior);
const totalDelta = delta(pedidosDia.total, pedidosDia.totalSemanaAnterior);
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
{/* Saudação */}
<Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}>
{greeting()}, {user?.nome?.split(' ')[0] ?? '...'}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
{today()}
{urgentCount > 0 && (
<>
{' '}
·{' '}
<span style={{ color: '#cf1322' }}>
{urgentCount} aprovação{urgentCount > 1 ? 'ões' : ''} urgente
{urgentCount > 1 ? 's' : ''}
</span>
</>
)}
</Text>
</Flex>
{/* KPIs */}
<Row gutter={[24, 24]}>
<Col xs={24} md={8}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
APROVAÇÕES PENDENTES
</Text>
<Flex align="center" gap={8}>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{approvalQueue.length}
</Title>
{urgentCount > 0 && (
<Badge
count={urgentCount}
style={{ backgroundColor: '#cf1322' }}
title={`${urgentCount} urgente(s) — mais de 2h`}
/>
)}
</Flex>
<Link to="/aprovacoes">
<Text style={{ fontSize: 'var(--text-sm)', color: 'var(--jcs-blue)' }}>
Ver fila completa
</Text>
</Link>
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
PEDIDOS HOJE
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{pedidosDia.count}
</Title>
{countDelta && (
<Text
style={{
fontSize: 'var(--text-sm)',
color: countDelta.positive ? 'var(--green)' : '#cf1322',
}}
>
{countDelta.label}
</Text>
)}
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
VALOR HOJE
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(pedidosDia.total)}
</Title>
{totalDelta && (
<Text
style={{
fontSize: 'var(--text-sm)',
color: totalDelta.positive ? 'var(--green)' : '#cf1322',
}}
>
{totalDelta.label}
</Text>
)}
</Space>
</Card>
</Col>
</Row>
{/* Fila de aprovações + Inativos por rep */}
<Row gutter={[24, 24]}>
<Col xs={24} lg={16}>
<Card
title={
<Space>
<FontAwesomeIcon icon={faCheckCircle} style={{ color: 'var(--jcs-blue)' }} />
Fila de Aprovações
{approvalQueue.length > 0 && (
<Badge
count={approvalQueue.length}
style={{ backgroundColor: 'var(--jcs-blue)' }}
/>
)}
</Space>
}
extra={<Link to="/aprovacoes">Ver todas</Link>}
>
<Table<PedidoSummary>
rowKey="id"
columns={queueColumns}
dataSource={approvalQueue.slice(0, 8)}
pagination={false}
size="small"
rowClassName={(row) => (hoursWaiting(row.createdAt) > 2 ? 'row-urgent' : '')}
locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }}
/>
<style>{`.row-urgent td { background: #fff1f0 !important; }`}</style>
</Card>
</Col>
<Col xs={24} lg={8}>
<Card
title={
<Space>
<FontAwesomeIcon icon={faCircleExclamation} style={{ color: 'var(--orange)' }} />
Inativos por Rep
</Space>
}
extra={
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
sem compra +30 dias
</Text>
}
>
{inativosPorRep.length === 0 ? (
<Text type="secondary">Nenhum inativo no momento.</Text>
) : (
<Flex vertical gap={12}>
{inativosPorRep.map((r) => (
<Flex
key={r.codVendedor}
justify="space-between"
align="center"
style={{
padding: 'var(--space-sm) var(--space-md)',
borderRadius: 12,
background: 'var(--bg-surface-alt)',
}}
>
<Space orientation="vertical" size={0}>
<Text strong>{r.nomeVendedor ?? `Rep cód. ${r.codVendedor}`}</Text>
{r.nomeVendedor && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
cód. {r.codVendedor}
</Text>
)}
</Space>
<Tag
color={r.inativosCount >= 3 ? 'orange' : 'default'}
className="tabular-nums"
>
{r.inativosCount} cliente{r.inativosCount > 1 ? 's' : ''}
</Tag>
</Flex>
))}
</Flex>
)}
</Card>
<Card
style={{ marginTop: 24 }}
title={
<Space>
<FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} />
Pedidos de Hoje
</Space>
}
>
<Flex vertical gap={8}>
<Flex justify="space-between">
<Text type="secondary">Total de pedidos</Text>
<Text strong className="tabular-nums">
{pedidosDia.count}
</Text>
</Flex>
<Flex justify="space-between">
<Text type="secondary">Valor consolidado</Text>
<Text strong className="tabular-nums">
{fmt(pedidosDia.total)}
</Text>
</Flex>
{pedidosDia.countSemanaAnterior > 0 && (
<Flex justify="space-between">
<Text type="secondary">Semana passada</Text>
<Text type="secondary" className="tabular-nums">
{pedidosDia.countSemanaAnterior} · {fmt(pedidosDia.totalSemanaAnterior)}
</Text>
</Flex>
)}
</Flex>
</Card>
</Col>
</Row>
<Flex justify="space-between" style={{ paddingTop: 8 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
SAR · Força de Vendas · Powered by JCS Sistemas
</Text>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')} · atualiza a cada 30s
</Text>
</Flex>
</Flex>
);
}

View File

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

View File

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

@@ -0,0 +1,74 @@
// Componente de login dev — visível apenas quando NODE_ENV !== 'production' e sem token.
// Em produção o token vem do master-login real (fora do escopo do MVP).
import { useState } from 'react';
import { Alert, Button, Card, Divider, Flex, Space, Typography } from 'antd';
import { apiFetch } from '../../lib/api-client';
import { authStore } from '../../lib/auth-store';
import { AuthTokenResponseSchema } from '@sar/api-interface';
type DevUser = { key: string; userId: string; role: string; label: string };
// 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: '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 }) {
const [loading, setLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleLogin(user: DevUser) {
setLoading(user.key);
setError(null);
try {
const raw = await apiFetch('/auth/dev/token', {
method: 'POST',
body: { userId: user.userId, role: user.role },
});
const { accessToken } = AuthTokenResponseSchema.parse(raw);
authStore.set(accessToken);
onLogin();
} catch (e) {
setError(e instanceof Error ? e.message : 'Erro ao obter token');
} finally {
setLoading(null);
}
}
return (
<Flex justify="center" align="center" style={{ minHeight: '100vh' }}>
<Card style={{ width: 380 }}>
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
<Typography.Title level={3} style={{ margin: 0 }}>
SAR · Login Dev
</Typography.Title>
<Alert
type="warning"
title="Ambiente de desenvolvimento"
description="Este login automático não existe em produção."
showIcon
/>
{error && <Alert type="error" title={error} showIcon />}
<Divider style={{ margin: '4px 0' }}>Entrar como</Divider>
{DEV_USERS.map((u) => (
<Button
key={u.key}
block
type={u.role === 'rep' ? 'primary' : 'default'}
loading={loading === u.key}
onClick={() => void handleLogin(u)}
>
{u.label}
</Button>
))}
</Space>
</Card>
</Flex>
);
}

View File

@@ -1,37 +1,91 @@
import { useState, type ReactNode } from 'react'; import { useEffect, type ReactNode } from 'react';
import { Flex } from 'antd'; 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 { Topbar } from './Topbar';
import { Sidebar } from './Sidebar'; 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 { interface AppShellProps {
children: ReactNode; 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) { export function AppShell({ children }: AppShellProps) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars const navigate = useNavigate();
const [_sidebarOpen, setSidebarOpen] = useState(true); 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 ( return (
<Flex vertical style={{ minHeight: '100vh', background: 'var(--bg-body)' }}> <Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}>
<Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} /> {!isOnline && (
<Flex flex={1}> <Alert
<Sidebar /> type="warning"
icon={<WifiOutlined />}
showIcon
banner
message="Sem conexão — pedidos lançados ficam salvos e serão enviados ao reconectar"
style={{ padding: '6px 16px', fontSize: 13 }}
/>
)}
<Topbar />
<Flex flex={1} style={{ overflow: 'hidden' }}>
{isDesktop && <Sidebar />}
<main <main
style={{ style={{
flex: 1, flex: 1,
padding: 'var(--space-xl)', padding: isDesktop ? 'var(--space-xl)' : 'var(--space-md)',
overflow: 'auto', paddingBottom: isDesktop
maxWidth: '100%', ? 'var(--space-xl)'
: 'calc(var(--layout-bottom-nav-height) + var(--space-lg))',
overflowY: 'auto',
overflowX: 'hidden',
minWidth: 0,
}} }}
> >
{children} {children}
</main> </main>
</Flex> </Flex>
{!isDesktop && <BottomNav />}
{/* FAB — Novo Pedido: acima do bottom nav em mobile */}
<Tooltip title="Novo Pedido" placement="left">
<Button
type="primary"
shape="circle"
icon={<PlusOutlined />}
onClick={() => void navigate({ to: '/pedidos/novo' })}
style={{
position: 'fixed',
bottom: isDesktop ? 32 : 'calc(var(--layout-bottom-nav-height) + 16px)',
right: 20,
width: 52,
height: 52,
fontSize: 22,
backgroundColor: '#389e0d',
borderColor: '#389e0d',
boxShadow: '0 4px 16px rgba(56,158,13,0.45)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
</Tooltip>
</Flex> </Flex>
); );
} }

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

@@ -0,0 +1,118 @@
import { Badge, Tooltip, Typography } from 'antd';
import { ApiError } from '../../lib/api-client';
import { useApiPing } from '../../lib/queries/ping';
import { brandTokens } from '../../lib/theme';
const { Text } = Typography;
// Pill discreto de "fundação viva" — prova que API↔Web↔contrato Zod funcionam.
// Conscientemente mantido na Topbar enquanto o produto está em foundation;
// quando virar normal, vira indicador só em /health (Sandra/Daniel).
export function FoundationStatus() {
const { data, error, isPending, isFetching } = useApiPing();
if (isPending) {
return (
<Pill color={brandTokens.textMuted} label="API…" tooltip="Verificando conexão com a API" />
);
}
if (error) {
const detail =
error instanceof ApiError
? `${error.problem.title}${error.problem.detail ? `${error.problem.detail}` : ''}`
: error.message;
return (
<Pill
color={brandTokens.red}
label="API offline"
tooltip={
<TooltipLines
lines={[
['Erro', detail],
['Status', String((error as ApiError).status ?? '—')],
['Request', (error as ApiError).problem?.requestId ?? '—'],
]}
/>
}
/>
);
}
return (
<Pill
color={brandTokens.green}
label={`API v${data.version}`}
pulse={isFetching}
tooltip={
<TooltipLines
lines={[
['Service', data.service],
['Version', data.version],
['Empresa', String(data.idEmpresa)],
['Request', data.requestId.slice(0, 8) + '…'],
['Uptime', `${data.uptimeSeconds}s`],
]}
/>
}
/>
);
}
function Pill({
color,
label,
tooltip,
pulse,
}: {
color: string;
label: string;
tooltip: React.ReactNode;
pulse?: boolean;
}) {
return (
<Tooltip title={tooltip} placement="bottomRight">
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
height: 28,
padding: '0 12px',
borderRadius: 999,
background: 'var(--bg-surface-alt)',
border: '1px solid var(--border-subtle)',
fontSize: 'var(--text-xs)',
fontWeight: 'var(--font-weight-medium)',
color: 'var(--text-muted)',
cursor: 'default',
}}
aria-label={`Estado da API: ${label}`}
>
<Badge color={color} status={pulse ? 'processing' : undefined} />
<span className="tabular-nums">{label}</span>
</span>
</Tooltip>
);
}
function TooltipLines({ lines }: { lines: ReadonlyArray<readonly [string, string]> }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '2px 12px' }}>
{lines.map(([label, value]) => (
<FragmentRow key={label} label={label} value={value} />
))}
</div>
);
}
function FragmentRow({ label, value }: { label: string; value: string }) {
return (
<>
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 12 }}>{label}</Text>
<Text style={{ color: '#fff', fontSize: 12 }} className="tabular-nums">
{value}
</Text>
</>
);
}

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