Compare commits

...

36 Commits

Author SHA1 Message Date
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
111 changed files with 15198 additions and 366 deletions

View File

@@ -45,6 +45,14 @@ OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=1.0
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)
GROWTHBOOK_API_HOST=http://localhost:3100
GROWTHBOOK_CLIENT_KEY=

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ node_modules
dist
build
out
tmp
.nx
.next
.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[]=*eslint*
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=@prisma/client-runtime-utils
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",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"tags": ["scope:api", "type:e2e", "domain:shared"],
"implicitDependencies": ["api"],
"targets": {
"e2e": {

View File

@@ -2,5 +2,14 @@
"name": "@sar/api",
"version": "0.1.0",
"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"
}
}

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

@@ -0,0 +1,20 @@
// 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'] ??
'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev',
},
migrations: {
seed: 'tsx prisma/seed.ts',
},
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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,169 @@
// 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?
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")
}
// ─── 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")
}

1137
apps/api/prisma/seed.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,40 @@
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 { EnvModule } from './config/env.module';
import { LoggerModule } from './logger/logger.module';
import { WorkspaceModule } from './workspace/workspace.module';
import { HealthModule } from './health/health.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 { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({
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,
LoggerModule,
WorkspaceModule,
AuthModule,
HealthModule,
PingModule,
ClientsModule,
OrdersModule,
CatalogModule,
DashboardModule,
NotificationsModule,
],
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 },
// Filter global: RFC 9457. Zod → 422.
{ 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 {}

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,47 @@
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;
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';
}
@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,
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,89 @@
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: força representante fixo (DEV_REP_CODE / DEV_EMPRESA_ID) ignorando o JWT.
// Em prod: usa os valores reais do JWT.
const idEmpresa = this.isProd ? payload.id_empresa : this.devEmpresaId;
const userId = this.isProd ? payload.sub : this.devRepCode;
this.cls.set('idEmpresa', idEmpresa);
this.cls.set('userId', userId);
this.cls.set('role', payload.role);
const baseUrl =
process.env['DATABASE_URL'] ??
'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev';
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,49 @@
import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import {
ProdutoListQuerySchema,
type EmpresaInfo,
type FormaPagamento,
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('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,340 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
EmpresaInfo,
FormaPagamento,
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,
NULLIF(e.numero, 0)::text AS numero,
NULLIF(TRIM(e.complemento), '.') AS complemento,
TRIM(e.bairro) AS bairro,
TRIM(m.nome) AS cidade,
TRIM(e.estado::text) AS uf,
TRIM(e.cep::text) AS cep,
TRIM(e.telefone::text) AS telefone,
TRIM(e.email) AS email
FROM gestao.empresa 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 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,
};
}
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 rows = await 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
`);
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,
};
}
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,27 @@
import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import {
ClientListQuerySchema,
type ClientDetail,
type ClientListQuery,
type ClientListResponse,
} from '@sar/api-interface';
import { ClientsService } from './clients.service';
class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
@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);
}
@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,239 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
ClientDetail,
ClientListQuery,
ClientListResponse,
ClientSummary,
ActivityStatus,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
// 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, "''");
}
// 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>) {}
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 (cod_vendedor = seu código)
const vendedorFilter = role === 'rep' ? `AND c.cod_vendedor = ${codVendedor}` : '';
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 findOne(idCliente: number): Promise<ClientDetail> {
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,
};
}
}

View File

@@ -13,7 +13,10 @@ export const EnvSchema = z
API_PORT: z.coerce.number().int().positive().default(3000),
API_HOST: z.string().min(1).default('0.0.0.0'),
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_ORIGINS: z
@@ -28,13 +31,20 @@ export const EnvSchema = z
// 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_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_REFRESH_EXPIRATION: z.coerce.number().int().positive().default(2_592_000),
// Multi-tenancy — workspace de dev (até master-login real entrar)
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)
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),
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
GROWTHBOOK_API_HOST: z.string().optional(),
GROWTHBOOK_CLIENT_KEY: z.string().optional(),
@@ -74,11 +89,16 @@ export const EnvSchema = z
ctx.addIssue({
code: 'custom',
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) {
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,23 @@
import { Controller, Get } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { RepDashboard, SupervisorDashboard } 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>,
) {}
@Get('rep')
repDashboard(): Promise<RepDashboard> {
return this.dashboard.repDashboard(this.cls.get('userId') ?? '');
}
@Get('supervisor')
supervisorDashboard(): Promise<SupervisorDashboard> {
return this.dashboard.supervisorDashboard();
}
}

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,417 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { RepDashboard, SupervisorDashboard } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
// 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 RepRow {
taxa_com: string;
permitir_flex: number; // 0 ou 1 (char do ERP convertido)
}
interface InativoRow {
id_cliente: number;
nome: string;
dt_ultima_compra: Date | null;
ultima_compra_valor: 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. Taxas do representante — fonte: gestao.vendedor (via vw_representantes)
const repRows = await prisma.$queryRawUnsafe<RepRow[]>(`
SELECT taxa_com::text, COALESCE(permitir_flex, 0) AS permitir_flex
FROM vw_representantes
WHERE codigo = ${codVendedor}
LIMIT 1
`);
const commissionRate = repRows[0] ? Number(repRows[0].taxa_com) : 3;
const permitirFlex = (repRows[0]?.permitir_flex ?? 0) === 1;
// 3. Taxa flex — fonte: sar.meta_representante (override SAR; default 1%)
const flexOverride = await prisma.metaRepresentante.findUnique({
where: { codVendedor_idEmpresa_ano_mes: { codVendedor, idEmpresa, ano: year, mes: month } },
select: { taxaFlex: true },
});
const flexRate = flexOverride ? Number(flexOverride.taxaFlex) : 1;
// 4. Atingido do mês — realizado = tudo menos Cancelado(5) e Pendente/não-transmitido(1).
// 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;
}
const [atingidoRows, pedidosMesRows, recentRows, realizadoGrupoRows] = 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
`),
]);
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);
const fixa = Math.round(atingido * commissionRate) / 100;
const flex =
permitirFlex && targetAmount > 0 && atingido >= targetAmount
? Math.round(atingido * flexRate) / 100
: 0;
// 7. Clientes inativos — sem pedido no ERP há >30 dias
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const inactiveClients = await prisma.$queryRawUnsafe<InativoRow[]>(`
SELECT
c.id_cliente,
c.nome,
MAX(p.dt_pedido) AS dt_ultima_compra,
MAX(p.total)::text AS ultima_compra_valor
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.cod_vendedor = ${codVendedor}
AND c.ativo = 1
GROUP BY c.id_cliente, c.nome
HAVING MAX(p.dt_pedido) IS NULL
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
ORDER BY dt_ultima_compra ASC NULLS FIRST
LIMIT 10
`);
// 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);
return {
meta: { atingido, total: targetAmount, pct, falta },
metaDimensao,
metasPorGrupo,
comissao: { fixa, flex, total: fixa + flex },
pedidosMes,
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,
})),
clientesInativos: inactiveClients.map((c) => ({
idCliente: Number(c.id_cliente),
nome: c.nome,
diasSemCompra: c.dt_ultima_compra
? Math.floor((now.getTime() - c.dt_ultima_compra.getTime()) / 86_400_000)
: 999,
ultimaCompraValor: c.ultima_compra_valor,
})),
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();
// 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 },
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}'
`),
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}'
`),
]);
// 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
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

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

View File

@@ -1,9 +1,12 @@
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { WorkspaceModule } from '../workspace/workspace.module';
import { HealthController } from './health.controller';
import { WorkspacePoolHealthIndicator } from './workspace-pool.health-indicator';
@Module({
imports: [TerminusModule],
imports: [TerminusModule, WorkspaceModule],
controllers: [HealthController],
providers: [WorkspacePoolHealthIndicator],
})
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,29 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Req } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import { SubscribePayloadSchema, type SubscribePayload } from '@sar/api-interface';
import type { AuthenticatedRequest } from '../auth/jwt.types';
import { NotificationsService } from './notifications.service';
class SubscribeDto extends createZodDto(SubscribePayloadSchema) {}
@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(@Body() body: { endpoint: string }): Promise<void> {
await this.svc.unsubscribe(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,93 @@
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,
},
});
}
async unsubscribe(endpoint: string): Promise<void> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
await prisma.pushSubscription.deleteMany({ where: { endpoint } });
}
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,86 @@
import {
Body,
Controller,
ForbiddenException,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { createZodDto } from 'nestjs-zod';
import {
AprovarPedidoSchema,
CreatePedidoSchema,
PedidoListQuerySchema,
RecusarPedidoSchema,
type AprovarPedido,
type CreatePedido,
type PedidoDetail,
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) {}
@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);
}
@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 {}

View File

@@ -0,0 +1,598 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { Prisma } from '@prisma/client';
import type {
AprovarPedido,
CreatePedido,
PedidoDetail,
PedidoListQuery,
PedidoListResponse,
PedidoSummary,
RecusarPedido,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { NotificationsService } from '../notifications/notifications.service';
// Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado
// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado
const SITUA_ORCAMENTO = 0;
const SITUA_PENDENTE = 1;
const SITUA_APROVADO = 2;
const SITUA_CANCELADO = 3;
// Mapeia situa SIG → situa SAR para exibição correta no frontend.
// SIG usa 5 para Cancelado; SAR usa 3. Demais valores coincidem.
function sigToSar(sigSitua: number): number {
return sigSitua === 5 ? 3 : sigSitua;
}
// Mapeia situa SAR → situa SIG para usar nos filtros SQL contra vw_pedidos_erp.
function sarToSig(sarSitua: number): number {
return sarSitua === 3 ? 5 : sarSitua;
}
function decimalToString(v: Prisma.Decimal | null | undefined): string {
return v ? v.toString() : '0';
}
@Injectable()
export class OrdersService {
constructor(
private readonly cls: ClsService<WorkspaceClsStore>,
private readonly notifications: NotificationsService,
) {}
async list(query: PedidoListQuery): Promise<PedidoListResponse> {
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 { idCliente, situa, numPedSar, from, to, page, limit } = query;
const offset = (page - 1) * limit;
// Filtro de vendedor: rep vê apenas seus pedidos
const vendedorFilter = role === 'rep' ? `AND e.cod_vendedor = ${codVendedor}` : '';
const clienteFilter = idCliente != null ? `AND e.id_cliente = ${idCliente}` : '';
// Converte situa SAR → SIG para filtrar corretamente contra vw_pedidos_erp
const sigSitua = situa != null ? sarToSig(situa) : null;
const situaFilter = sigSitua != null ? `AND e.situa = ${sigSitua}` : '';
const pedSarFilter = numPedSar ? `AND TRIM(e.num_ped_sar) ILIKE '%${numPedSar}%'` : '';
const fromFilter = from ? `AND e.dt_pedido >= '${from}'` : '';
const toFilter = to ? `AND e.dt_pedido <= '${to}'` : '';
const filters = `
WHERE e.id_empresa = ${idEmpresa}
${vendedorFilter} ${clienteFilter} ${situaFilter}
${pedSarFilter} ${fromFilter} ${toFilter}
`;
interface ErpRow {
id_pedido: number;
num_ped_sar: string;
numero: number;
id_cliente: number;
nome_cliente: string | null;
razao_cliente: string | null;
cod_vendedor: number;
nome_vendedor: string | null;
situa: number;
status_descr: string;
dt_pedido: Date;
total: string;
desconto_perc: string;
obs: string | null;
}
// Pedidos SAR-nativos (Orçamento/Transmitido) — ainda não estão no ERP.
const sarWhere: Prisma.PedidoWhereInput = {
idEmpresa,
...(role === 'rep' ? { codVendedor } : {}),
...(idCliente != null ? { idCliente } : {}),
...(situa != null ? { situa } : {}),
...(numPedSar ? { numPedSar: { contains: numPedSar, mode: 'insensitive' as const } } : {}),
...(from || to
? {
dtPedido: {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
},
}
: {}),
};
const [sarPedidos, countRows] = await Promise.all([
prisma.pedido.findMany({ where: sarWhere, orderBy: { dtPedido: 'desc' } }),
prisma.$queryRawUnsafe<[{ count: string }]>(`
SELECT COUNT(*)::text AS count FROM vw_pedidos_erp e ${filters}
`),
]);
const sarCount = sarPedidos.length;
const erpTotal = Number(countRows[0]?.count ?? 0);
const total = sarCount + erpTotal;
// Paginação combinada: SAR-nativos primeiro (ativos), depois histórico ERP.
const sarSlice = sarPedidos.slice(offset, offset + limit);
const erpNeeded = limit - sarSlice.length;
const erpOffset = Math.max(0, offset - sarCount);
const erpRows =
erpNeeded > 0
? await prisma.$queryRawUnsafe<ErpRow[]>(`
SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor,
e.situa, e.status_descr, e.dt_pedido, e.total::text, e.desconto_perc::text, e.obs,
c.nome AS nome_cliente, c.razao AS razao_cliente,
(SELECT r.nome FROM vw_representantes r
WHERE r.codigo = e.cod_vendedor
LIMIT 1) AS nome_vendedor
FROM vw_pedidos_erp e
LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente
${filters}
ORDER BY e.dt_pedido DESC
LIMIT ${erpNeeded} OFFSET ${erpOffset}
`)
: [];
// Resolve nomes (cliente/rep) dos pedidos SAR em lote — views globais.
const cliIds = [...new Set(sarSlice.map((p) => p.idCliente))];
const repCods = [...new Set(sarSlice.map((p) => p.codVendedor))];
const [cliNameRows, repNameRows] = await Promise.all([
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([]),
repCods.length
? prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${repCods.join(',')})`,
)
: Promise.resolve([]),
]);
const cliMap = new Map(cliNameRows.map((c) => [Number(c.id_cliente), c]));
const repMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
const sarData: PedidoSummary[] = sarSlice.map((p) => ({
id: p.id,
numPedSar: p.numPedSar,
idCliente: p.idCliente,
nomeCliente: cliMap.get(p.idCliente)?.nome ?? null,
razaoCliente: cliMap.get(p.idCliente)?.razao ?? null,
codVendedor: p.codVendedor,
nomeVendedor: repMap.get(p.codVendedor) ?? null,
situa: p.situa,
dtPedido: p.dtPedido.toISOString(),
total: decimalToString(p.total),
descontoPerc: decimalToString(p.descontoPerc),
obs: p.obs,
createdAt: p.createdAt.toISOString(),
fonte: 'sar' as const,
}));
const erpData: PedidoSummary[] = erpRows.map((o) => ({
id: `erp-${o.id_pedido}`,
numPedSar: (o.num_ped_sar ?? '').trim(),
numero: Number(o.numero),
idCliente: Number(o.id_cliente),
nomeCliente: o.nome_cliente ?? null,
razaoCliente: o.razao_cliente ?? null,
codVendedor: Number(o.cod_vendedor),
nomeVendedor: o.nome_vendedor ?? null,
// Normaliza situa SIG → SAR para consistência com pedidos SAR
situa: sigToSar(Number(o.situa)),
statusDescr: o.status_descr,
dtPedido: new Date(o.dt_pedido).toISOString(),
total: o.total ?? '0',
descontoPerc: o.desconto_perc ?? '0',
obs: o.obs ?? null,
createdAt: new Date(o.dt_pedido).toISOString(),
fonte: 'erp' as const,
}));
return { data: [...sarData, ...erpData], total, page, limit };
}
async findOne(id: string): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const role = this.cls.get('role');
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const repFilter = role === 'rep' ? { codVendedor } : {};
const o = await prisma.pedido.findFirst({
where: { id, idEmpresa, ...repFilter },
include: {
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
if (!o) throw new NotFoundException(`Pedido ${id} não encontrado`);
return this.mapDetail(o);
}
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A validação de alçada e a
// notificação ao supervisor acontecem no transmit(), não aqui.
// Idempotency-Key: retorna pedido existente se já processado (FR-4.3).
async create(dto: CreatePedido): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId') ?? '0';
const codVendedor = parseInt(userId, 10);
// Idempotency-Key: retorna pedido existente sem re-processar
if (dto.idempotencyKey) {
const existing = await prisma.pedido.findUnique({
where: { idempotencyKey: dto.idempotencyKey },
include: {
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
if (existing) return this.mapDetail(existing);
}
const itemsData = dto.itens.map((it) => {
const descontoValor =
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
const total = Math.round(it.qtd * it.precoUnitario * (1 - it.descontoPerc / 100) * 100) / 100;
return {
ordem: it.ordem,
idProduto: it.idProduto,
codProduto: it.codProduto ?? null,
descProduto: it.descProduto,
qtd: it.qtd,
precoUnitario: it.precoUnitario,
descontoPerc: it.descontoPerc,
descontoValor,
total,
};
});
const totalProdutos = itemsData.reduce((acc, it) => acc + it.total, 0);
const descontoValorGlobal = Math.round(totalProdutos * (dto.descontoPerc / 100) * 100) / 100;
const total = Math.round(totalProdutos * (1 - dto.descontoPerc / 100) * 100) / 100;
const situa = SITUA_ORCAMENTO;
// Gera número sequencial GLOBAL: SAR-NNNNN (numPedSar é unique entre empresas).
const lastOrder = await prisma.pedido.findFirst({
orderBy: { numPedSar: 'desc' },
select: { numPedSar: true },
});
const seq = lastOrder ? parseInt(lastOrder.numPedSar.replace('SAR-', ''), 10) + 1 : 1;
const numPedSar = `SAR-${String(seq).padStart(5, '0')}`;
const now = new Date();
const pedido = await prisma.pedido.create({
data: {
idEmpresa,
numPedSar,
idCliente: dto.idCliente,
codVendedor,
situa,
dtPedido: now,
idPauta: dto.idPauta ?? null,
codFormapag: dto.codFormapag ?? null,
totalProdutos,
total,
descontoPerc: dto.descontoPerc,
descontoValor: descontoValorGlobal,
obs: dto.obs ?? null,
idempotencyKey: dto.idempotencyKey ?? null,
itens: { create: itemsData },
historico: {
create: [
{
situaAnterior: null,
situaNova: situa,
changedBy: codVendedor,
changedAt: now,
},
],
},
},
include: {
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
return this.mapDetail(pedido);
}
// Transmite um Orçamento (situa 0) → Transmitido (situa 2).
// Alçada de desconto (codGrupo=0 = default) é BLOQUEIO DURO: desconto acima do
// máximo do rep barra a transmissão com mensagem — não há fila de aprovação.
async transmit(id: string): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const role = this.cls.get('role');
const userId = this.cls.get('userId') ?? '0';
const codVendedor = parseInt(userId, 10);
// Rep só transmite o próprio orçamento
const repFilter = role === 'rep' ? { codVendedor } : {};
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } });
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (pedido.situa !== SITUA_ORCAMENTO)
throw new BadRequestException(
`Pedido não é um orçamento (situa: ${pedido.situa}) — só orçamentos podem ser transmitidos`,
);
// Alçada do rep (codGrupo=0 = default; fallback 5%) — bloqueia se desconto acima.
const limitRows = await prisma.alcadaDesconto.findMany({ where: { codVendedor, idEmpresa } });
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
const limiteMax = limitMap.get(0) ?? 5;
const desconto = Number(pedido.descontoPerc);
if (desconto > limiteMax) {
throw new BadRequestException(
`Desconto de ${desconto}% acima do máximo permitido para você (${limiteMax}%). Reduza o desconto para transmitir o pedido.`,
);
}
const now = new Date();
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } });
await prisma.historicoPedido.create({
data: {
idPedido: id,
situaAnterior: SITUA_ORCAMENTO,
situaNova: SITUA_APROVADO,
changedBy: codVendedor,
changedAt: now,
nota: 'Transmitido',
},
});
const final = await prisma.pedido.findUniqueOrThrow({
where: { id },
include: {
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
return this.mapDetail(final);
}
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId') ?? '0';
const codVendedor = parseInt(userId, 10);
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (pedido.situa !== SITUA_PENDENTE)
throw new BadRequestException(
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
);
const now = new Date();
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc);
const newTotal =
Math.round(Number(pedido.totalProdutos) * (1 - newDescontoPerc / 100) * 100) / 100;
await prisma.pedido.update({
where: { id },
data: {
situa: SITUA_APROVADO,
descontoPerc: newDescontoPerc,
total: newTotal,
aprovadoPor: codVendedor,
aprovadoEm: now,
},
});
await prisma.historicoPedido.create({
data: {
idPedido: id,
situaAnterior: SITUA_PENDENTE,
situaNova: SITUA_APROVADO,
changedBy: codVendedor,
changedAt: now,
nota: dto.nota ?? null,
},
});
const final = await prisma.pedido.findUniqueOrThrow({
where: { id },
include: {
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
void this.notifications.notifyUser(String(pedido.codVendedor), {
title: 'Pedido aprovado',
body: `${final.numPedSar} aprovado${dto.descontoPerc !== undefined ? ` com ${newDescontoPerc}% de desconto` : ''}`,
url: `/pedidos/${id}`,
});
return this.mapDetail(final);
}
async reject(id: string, dto: RecusarPedido): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId') ?? '0';
const codVendedor = parseInt(userId, 10);
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (pedido.situa !== SITUA_PENDENTE)
throw new BadRequestException(
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
);
const now = new Date();
await prisma.pedido.update({
where: { id },
data: { situa: SITUA_CANCELADO, motivoRecusa: dto.motivo },
});
await prisma.historicoPedido.create({
data: {
idPedido: id,
situaAnterior: SITUA_PENDENTE,
situaNova: SITUA_CANCELADO,
changedBy: codVendedor,
changedAt: now,
nota: dto.motivo,
},
});
const final = await prisma.pedido.findUniqueOrThrow({
where: { id },
include: {
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
void this.notifications.notifyUser(String(pedido.codVendedor), {
title: 'Pedido recusado',
body: `${final.numPedSar}: ${dto.motivo}`,
url: `/pedidos/${id}`,
});
return this.mapDetail(final);
}
// Resolve nome do cliente (nome + razão) e nome do representante a partir dos
// códigos, lendo das views do ERP. Usado no detalhe de pedidos SAR-nativos.
private async lookupNames(
idCliente: number,
codVendedor: number,
): Promise<{
nomeCliente: string | null;
razaoCliente: string | null;
nomeVendedor: string | null;
}> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
// Cliente e representante são cadastros globais (sem id_empresa).
const [cliRows, repRows] = await Promise.all([
prisma.$queryRawUnsafe<{ nome: string | null; razao: string | null }[]>(
`SELECT nome, razao FROM vw_clientes WHERE id_cliente = ${idCliente} LIMIT 1`,
),
prisma.$queryRawUnsafe<{ nome: string | null }[]>(
`SELECT nome FROM vw_representantes WHERE codigo = ${codVendedor} LIMIT 1`,
),
]);
return {
nomeCliente: cliRows[0]?.nome ?? null,
razaoCliente: cliRows[0]?.razao ?? null,
nomeVendedor: repRows[0]?.nome ?? null,
};
}
private async mapDetail(o: {
id: string;
numPedSar: string;
idCliente: number;
codVendedor: number;
situa: number;
dtPedido: Date;
total: Prisma.Decimal;
descontoPerc: Prisma.Decimal;
descontoValor: Prisma.Decimal;
totalProdutos: Prisma.Decimal;
totalIpi: Prisma.Decimal;
totalIcmsst: Prisma.Decimal;
acrescimo: Prisma.Decimal;
comissao: Prisma.Decimal;
pedFlex: Prisma.Decimal;
aprovadoPor: number | null;
aprovadoEm: Date | null;
motivoRecusa: string | null;
obs: string | null;
idempotencyKey: string | null;
createdAt: Date;
updatedAt: Date;
itens: {
id: string;
idProduto: number;
codProduto: string | null;
descProduto: string | null;
ordem: number;
qtd: Prisma.Decimal;
precoUnitario: Prisma.Decimal;
descontoPerc: Prisma.Decimal;
total: Prisma.Decimal;
}[];
historico: {
id: string;
situaAnterior: number | null;
situaNova: number;
changedBy: number;
nota: string | null;
changedAt: Date;
}[];
}): Promise<PedidoDetail> {
const names = await this.lookupNames(o.idCliente, o.codVendedor);
return {
id: o.id,
numPedSar: o.numPedSar,
idCliente: o.idCliente,
nomeCliente: names.nomeCliente,
razaoCliente: names.razaoCliente,
codVendedor: o.codVendedor,
nomeVendedor: names.nomeVendedor,
situa: o.situa,
dtPedido: o.dtPedido.toISOString(),
total: decimalToString(o.total),
descontoPerc: decimalToString(o.descontoPerc),
obs: o.obs,
createdAt: o.createdAt.toISOString(),
totalProdutos: decimalToString(o.totalProdutos),
totalIpi: decimalToString(o.totalIpi),
totalIcmsst: decimalToString(o.totalIcmsst),
descontoValor: decimalToString(o.descontoValor),
acrescimo: decimalToString(o.acrescimo),
comissao: decimalToString(o.comissao),
pedFlex: decimalToString(o.pedFlex),
fonte: 'sar' as const,
aprovadoPor: o.aprovadoPor,
aprovadoEm: o.aprovadoEm?.toISOString() ?? null,
motivoRecusa: o.motivoRecusa,
idempotencyKey: o.idempotencyKey,
updatedAt: o.updatedAt.toISOString(),
itens: o.itens.map((it) => ({
id: it.id,
idProduto: it.idProduto,
codProduto: it.codProduto,
descProduto: it.descProduto,
ordem: it.ordem,
qtd: decimalToString(it.qtd),
precoUnitario: decimalToString(it.precoUnitario),
descontoPerc: decimalToString(it.descontoPerc),
total: decimalToString(it.total),
})),
historico: o.historico.map((h) => ({
id: h.id,
situaAnterior: h.situaAnterior,
situaNova: h.situaNova,
changedBy: h.changedBy,
nota: h.nota,
changedAt: h.changedAt.toISOString(),
})),
};
}
}

View File

@@ -2,6 +2,7 @@ import { Controller, Get } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { PingResponse } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { Public } from '../auth/public.decorator';
// Endpoint de verificação de fundação:
// - 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.
// Contrato: @sar/api-interface · PingResponseSchema (zod).
@Public()
@Controller({ path: 'ping' })
export class PingController {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
@@ -19,7 +21,7 @@ export class PingController {
status: 'ok',
service: 'sar-api',
version: process.env['npm_package_version'] ?? '0.1.0',
workspaceId: this.cls.get('workspaceId'),
idEmpresa: this.cls.get('idEmpresa'),
requestId: this.cls.get('requestId'),
uptimeSeconds: Math.round(process.uptime()),
now: new Date().toISOString(),

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 { ClsModule } from 'nestjs-cls';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { randomUUID } from 'node:crypto';
import type { Request, Response } from 'express';
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.
// Amanhã: workspaceId vem do JWT (PGD-AUTHZ-002); `prisma` é resolvido pelo
// WorkspacePrismaPool e injetado via cls.set('prisma', ...) aqui mesmo.
// CLS middleware roda ANTES dos guards (ordem NestJS).
// Aqui: apenas requestId + idEmpresa default (0 = não autenticado).
// 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({
imports: [
ClsModule.forRootAsync({
global: true,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService<Env, true>) => ({
useFactory: () => ({
middleware: {
mount: true,
generateId: true,
idGenerator: (req: Request) => {
// Prioridade: req.id (pino-http já gerou/leu header) > header bruto > novo UUID.
const fromPino = (req as Request & { id?: unknown }).id;
if (typeof fromPino === 'string' && fromPino.length > 0) return fromPino;
const headerVal = req.headers['x-request-id'];
return typeof headerVal === 'string' && headerVal.length > 0
? headerVal
: randomUUID();
return typeof headerVal === 'string' && headerVal.length > 0 ? headerVal : randomUUID();
},
setup: (cls, req: Request, res: Response) => {
const store = cls as unknown as {
set: <K extends keyof WorkspaceClsStore>(key: K, value: WorkspaceClsStore[K]) => void;
getId: () => string;
};
const requestId = store.getId();
res.setHeader('x-request-id', 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 {}

View File

@@ -1,13 +1,16 @@
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
// que faça `cls.get(...)`. Quando o PrismaClient por workspace entrar
// (ADR 0006), `prisma` virará obrigatório aqui — por hora segue opcional.
// Forma do CLS store por request — fonte da verdade para qualquer caller.
// CODING-RULES PGD-DB-009: nunca importe PrismaClient diretamente; use cls.get('prisma').
// 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 {
requestId: string;
workspaceId: string;
// userId virá quando master-login estiver plugado.
userId?: string;
// prisma: PrismaClient — adicionar quando WorkspacePrismaPool entrar.
idEmpresa: number; // era workspaceId: string — agora Int da empresa no ERP
userId?: string; // cod_vendedor como string; preenchido pelo JwtAuthGuard
role?: JwtRole; // preenchido pelo JwtAuthGuard após validar o token
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",
"projectType": "application",
"sourceRoot": "apps/web-e2e/src",
"tags": [],
"tags": ["scope:web", "type:e2e", "domain:shared"],
"implicitDependencies": ["web"],
"// targets": "to see all targets run: nx show project web-e2e --web",
"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 ──────────────────────────────────────────────────────────────────────
self.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) ─────────────────────────────────────────────────────────────────
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title ?? 'SAR', {
body: data.body ?? '',
icon: '/sar-icon.png',
badge: '/sar-icon.png',
data: data.url ? { url: data.url } : undefined,
}),
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url;
if (url) {
event.waitUntil(clients.openWindow(url));
}
});

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,135 @@
import { useState } from 'react';
import { Table, Input, Select, Space, Typography, Tag } from 'antd';
import type { TableColumnsType } from 'antd';
import type { ProdutoSummary } from '@sar/api-interface';
import { useCatalog, usePautas } from '../../lib/queries/catalog';
const { Title } = 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' }) : '—';
}
const columns: TableColumnsType<ProdutoSummary> = [
{
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 }}>{v.trim()}</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>
);
},
},
];
export function CatalogPage() {
const [q, setQ] = useState('');
const [idPauta, setIdPauta] = useState<number | undefined>();
const [page, setPage] = useState(1);
const limit = 50;
const { data: pautas, isLoading: pautasLoading } = usePautas();
const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit });
return (
<div style={{ padding: 24 }}>
<Title level={3} style={{ marginBottom: 16 }}>
Catálogo de Produtos
</Title>
<Space style={{ marginBottom: 16 }} wrap>
<Search
placeholder="Buscar por código ou descrição..."
allowClear
style={{ width: 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: 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),
}}
/>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { Button, Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd';
import type { TableColumnsType } from 'antd';
import { Link, useNavigate, useParams } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useClientDetail } from '../../lib/queries/clients';
import { useClientOrders } from '../../lib/queries/orders';
const { Title } = 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 orderColumns: TableColumnsType<PedidoSummary> = [
{
title: 'Nº',
dataIndex: 'numPedSar',
width: 120,
render: (num: string, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
{
title: 'Status',
dataIndex: 'situa',
width: 140,
render: (s: number) => {
const colorMap: Record<number, string> = {
1: 'warning',
2: 'processing',
3: 'error',
4: 'success',
};
return <Tag color={colorMap[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>;
},
},
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right',
render: (v: string) =>
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Data',
dataIndex: 'dtPedido',
width: 130,
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
];
export function ClientDetailPage() {
const { id } = useParams({ from: '/clientes/$id' });
const idNum = Number(id);
const navigate = useNavigate();
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(idNum);
const { data: orders, isLoading: ordersLoading } = useClientOrders(idNum);
if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
if (clientError || !client)
return <Alert type="error" message="Cliente não encontrado." style={{ margin: 24 }} />;
return (
<div style={{ padding: 24 }}>
<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
type="primary"
onClick={() => void navigate({ to: '/pedidos/novo', search: { clientId: id } })}
>
Novo Pedido
</Button>
</Space>
<Descriptions bordered size="small" column={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={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>
<Divider orientation="left">Últimos Pedidos</Divider>
<Table<PedidoSummary>
rowKey="id"
columns={orderColumns}
dataSource={orders ?? []}
loading={ordersLoading}
pagination={false}
size="small"
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
/>
<style>{`.row-pending td { background: #fffbe6 !important; }`}</style>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,783 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Alert,
App,
AutoComplete,
Button,
Card,
Col,
Empty,
Input,
InputNumber,
Row,
Select,
Space,
Table,
Tag,
Tooltip,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import {
ArrowLeftOutlined,
CheckCircleOutlined,
DeleteOutlined,
SearchOutlined,
ShoppingCartOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useNavigate, useSearch } from '@tanstack/react-router';
import type {
ClientSummary,
CreatePedido,
FormaPagamento,
Pauta,
ProdutoSummary,
} from '@sar/api-interface';
import { useClientList, useClientDetail } from '../../lib/queries/clients';
import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog';
import { apiFetch } from '../../lib/api-client';
import { enqueueOrder } from '../../lib/offline/order-queue';
const { Title, Text } = Typography;
// ─── Tipos internos ────────────────────────────────────────────────────────────
type CartItem = {
key: string;
idProduto: number;
codProduto: string;
descProduto: string;
unidade: string;
qtd: number;
precoUnitario: number;
descontoPerc: number;
};
type SearchParams = { clientId?: string };
// ─── Helpers ──────────────────────────────────────────────────────────────────
function fmt(n: number) {
return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function itemTotal(item: CartItem) {
return Math.round(item.qtd * item.precoUnitario * (1 - item.descontoPerc / 100) * 100) / 100;
}
// ─── Estilos compartilhados ───────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: 12,
boxShadow: '0 1px 6px rgba(0,0,0,0.07)',
border: '1px solid #EBF0F5',
marginBottom: 16,
};
const sectionLabel: React.CSSProperties = {
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.09em',
textTransform: 'uppercase',
color: '#003B8E',
marginBottom: 16,
display: 'block',
};
// ─── CustomerSearch ───────────────────────────────────────────────────────────
function CustomerSearch({
value,
selected,
onSearch,
onSelect,
}: {
value: string;
selected: ClientSummary | null;
onSearch: (v: string) => void;
onSelect: (client: ClientSummary) => void;
}) {
const { data, isFetching } = useClientList({ q: value || undefined, limit: 12 });
const options = (data?.data ?? []).map((c) => ({
value: String(c.idCliente),
label: (
<Space size={8}>
<UserOutlined style={{ color: '#64748B' }} />
<span style={{ fontWeight: 600, color: '#1F2937' }}>{c.razao ?? c.nome}</span>
{c.cgcpf && (
<Text type="secondary" style={{ fontSize: 12 }}>
{c.cgcpf}
</Text>
)}
</Space>
),
client: c,
}));
return (
<AutoComplete
value={selected ? (selected.razao ?? selected.nome) : value}
options={options}
onSearch={(v) => {
onSearch(v);
}}
onSelect={(_val, opt) => {
onSelect((opt as (typeof options)[0]).client);
}}
onChange={(v) => {
if (!v) {
onSearch('');
}
}}
style={{ width: '100%' }}
notFoundContent={
value.length > 1 && !isFetching ? (
<Text type="secondary" style={{ padding: '8px 12px', display: 'block' }}>
Nenhum cliente encontrado
</Text>
) : null
}
>
<Input
size="large"
prefix={<SearchOutlined style={{ color: '#64748B' }} />}
placeholder="Digite o nome fantasia, razão social ou CNPJ..."
allowClear
style={{ borderRadius: 8 }}
/>
</AutoComplete>
);
}
// ─── ProductSearch ────────────────────────────────────────────────────────────
function ProductSearch({
idPauta,
onAdd,
}: {
idPauta: number | undefined;
onAdd: (p: ProdutoSummary) => void;
}) {
const [q, setQ] = useState('');
const { data, isFetching } = useCatalog({ q: q || undefined, idPauta, limit: 15 });
const options = (data?.data ?? []).map((p) => ({
value: String(p.idErp),
label: (
<Space size={8} style={{ width: '100%', justifyContent: 'space-between' }}>
<Space size={6}>
<Tag style={{ margin: 0, fontSize: 11 }}>{p.codigo}</Tag>
<span style={{ color: '#1F2937' }}>{p.descricao}</span>
{p.unidade && (
<Text type="secondary" style={{ fontSize: 12 }}>
{p.unidade}
</Text>
)}
</Space>
<Text strong style={{ color: '#003B8E', whiteSpace: 'nowrap' }}>
{fmt(Number(p.vlPreco1))}
</Text>
</Space>
),
produto: p,
}));
return (
<AutoComplete
value={q}
options={options}
onSearch={setQ}
onSelect={(_val, opt) => {
onAdd((opt as (typeof options)[0]).produto);
setQ('');
}}
style={{ width: '100%' }}
notFoundContent={
q.length > 1 && !isFetching ? (
<Text type="secondary" style={{ padding: '8px 12px', display: 'block' }}>
Nenhum produto encontrado
</Text>
) : null
}
>
<Input
size="large"
prefix={<ShoppingCartOutlined style={{ color: '#64748B' }} />}
placeholder="Pesquise por nome, código ou código de barras..."
style={{ borderRadius: 8 }}
/>
</AutoComplete>
);
}
// ─── OrderItemsTable ──────────────────────────────────────────────────────────
function OrderItemsTable({
items,
onQtyChange,
onDiscChange,
onRemove,
}: {
items: CartItem[];
onQtyChange: (key: string, qty: number) => void;
onDiscChange: (key: string, disc: number) => void;
onRemove: (key: string) => void;
}) {
const columns: TableColumnsType<CartItem> = [
{
title: 'Cód.',
dataIndex: 'codProduto',
width: 80,
render: (v: string) => <Tag style={{ fontSize: 11 }}>{v}</Tag>,
},
{
title: 'Descrição',
dataIndex: 'descProduto',
ellipsis: true,
},
{
title: 'Qtd',
dataIndex: 'qtd',
width: 100,
render: (v: number, row: CartItem) => (
<InputNumber
min={0.001}
step={1}
value={v}
size="small"
style={{ width: 76 }}
onChange={(n) => onQtyChange(row.key, n ?? 1)}
/>
),
},
{
title: 'Un.',
dataIndex: 'unidade',
width: 60,
render: (v: string) => (
<Text type="secondary" style={{ fontSize: 12 }}>
{v || '—'}
</Text>
),
},
{
title: 'Preço Unit.',
dataIndex: 'precoUnitario',
width: 110,
align: 'right',
render: (v: number) => <span className="tabular-nums">{fmt(v)}</span>,
},
{
title: 'Desc %',
dataIndex: 'descontoPerc',
width: 100,
render: (v: number, row: CartItem) => (
<InputNumber
min={0}
max={100}
step={0.5}
value={v}
size="small"
style={{ width: 76 }}
addonAfter="%"
onChange={(n) => onDiscChange(row.key, n ?? 0)}
/>
),
},
{
title: 'Total',
width: 120,
align: 'right',
render: (_: unknown, row: CartItem) => (
<Text strong className="tabular-nums">
{fmt(itemTotal(row))}
</Text>
),
},
{
title: '',
width: 48,
render: (_: unknown, row: CartItem) => (
<Tooltip title="Remover item">
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => onRemove(row.key)}
/>
</Tooltip>
),
},
];
return (
<Table<CartItem>
rowKey="key"
columns={columns}
dataSource={items}
size="small"
pagination={false}
locale={{
emptyText: (
<Empty
image={<ShoppingCartOutlined style={{ fontSize: 48, color: '#D9E2EC' }} />}
imageStyle={{ height: 60 }}
description={
<Space direction="vertical" size={2}>
<Text type="secondary">Nenhum produto adicionado ao pedido ainda.</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
Pesquise acima para incluir itens.
</Text>
</Space>
}
/>
),
}}
style={{ marginTop: 16 }}
/>
);
}
// ─── OrderSummaryFooter ───────────────────────────────────────────────────────
function OrderSummaryFooter({
total,
submitting,
canSubmit,
onCancel,
onSubmit,
}: {
total: number;
submitting: boolean;
canSubmit: boolean;
onCancel: () => void;
onSubmit: () => void;
}) {
return (
<div
style={{
position: 'sticky',
bottom: 0,
zIndex: 100,
background: '#fff',
borderTop: '1px solid #EBF0F5',
boxShadow: '0 -2px 12px rgba(0,0,0,0.08)',
padding: '16px 32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Space direction="vertical" size={0}>
<Text
style={{
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#64748B',
}}
>
TOTAL DO PEDIDO
</Text>
<Title level={3} style={{ margin: 0, color: '#003B8E' }} className="tabular-nums">
{fmt(total)}
</Title>
</Space>
<Space size={12}>
<Button size="large" onClick={onCancel} style={{ borderRadius: 8, minWidth: 110 }}>
Cancelar
</Button>
<Button
type="primary"
size="large"
icon={<CheckCircleOutlined />}
loading={submitting}
disabled={!canSubmit}
onClick={onSubmit}
style={{
borderRadius: 8,
minWidth: 160,
backgroundColor: canSubmit ? '#389e0d' : undefined,
borderColor: canSubmit ? '#389e0d' : undefined,
fontWeight: 600,
}}
>
Finalizar Pedido
</Button>
</Space>
</div>
);
}
// ─── NewOrderPage ─────────────────────────────────────────────────────────────
export function NewOrderPage() {
const { clientId: clientIdParam } = useSearch({ strict: false }) as SearchParams;
const navigate = useNavigate();
const qc = useQueryClient();
const { message } = App.useApp();
// ── Dados do cliente ──
const [clientSearch, setClientSearch] = useState('');
const [selectedClient, setSelectedClient] = useState<ClientSummary | null>(null);
// Pré-carregar cliente quando vem ?clientId=X (ex.: botão "Novo Pedido" no detalhe)
const { data: preloadedClient } = useClientDetail(
clientIdParam ? Number(clientIdParam) : undefined,
);
const effectiveClient = selectedClient ?? preloadedClient ?? null;
// ── Campos comerciais ──
const { data: pautas = [] } = usePautas();
const { data: formasPagamento = [] } = useFormasPagamento();
const [idPauta, setIdPauta] = useState<number | undefined>();
const [codFormapag, setCodFormapag] = useState<number | undefined>();
const [contato, setContato] = useState('');
// ── Informações adicionais ──
const [numOC, setNumOC] = useState('');
const [obs, setObs] = useState('');
// ── Carrinho ──
const [cart, setCart] = useState<CartItem[]>([]);
// ── UI ──
const [error, setError] = useState<string | null>(null);
const totalPedido = cart.reduce((acc, it) => acc + itemTotal(it), 0);
const canSubmit = !!effectiveClient && cart.length > 0;
// ── Handlers do carrinho ──
const addToCart = (p: ProdutoSummary) => {
setCart((prev) => {
const existing = prev.find((it) => it.idProduto === p.idErp);
if (existing) {
return prev.map((it) => (it.idProduto === p.idErp ? { ...it, qtd: it.qtd + 1 } : it));
}
return [
...prev,
{
key: String(p.idErp),
idProduto: p.idErp,
codProduto: p.codigo,
descProduto: p.descricao,
unidade: p.unidade ?? '',
qtd: 1,
precoUnitario: Number(p.vlPreco1),
descontoPerc: 0,
},
];
});
};
const setQty = (key: string, qty: number) =>
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, qtd: qty } : it)));
const setDisc = (key: string, disc: number) =>
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, descontoPerc: disc } : it)));
const removeItem = (key: string) => setCart((prev) => prev.filter((it) => it.key !== key));
// ── Submissão ──
const mutation = useMutation({
mutationFn: async () => {
if (!effectiveClient) throw new Error('Selecione um cliente para continuar.');
if (cart.length === 0) throw new Error('Adicione ao menos um produto ao pedido.');
const obsCompleta = [
contato ? `Contato: ${contato}` : null,
numOC ? `OC: ${numOC}` : null,
obs || null,
]
.filter(Boolean)
.join(' | ');
const body: CreatePedido = {
idCliente: effectiveClient.idCliente,
idPauta,
codFormapag,
descontoPerc: 0,
obs: obsCompleta || undefined,
idempotencyKey: crypto.randomUUID(),
itens: cart.map((it, idx) => ({
idProduto: it.idProduto,
codProduto: it.codProduto,
descProduto: it.descProduto,
ordem: idx + 1,
qtd: it.qtd,
precoUnitario: it.precoUnitario,
descontoPerc: it.descontoPerc,
})),
};
// Offline: enfileira localmente e sincroniza ao reconectar (FR-4.2 / NFR-2.2)
if (!navigator.onLine) {
await enqueueOrder(body, effectiveClient.nome);
window.dispatchEvent(new CustomEvent('sar:order-queued'));
return null; // sinaliza fluxo offline para onSuccess
}
return apiFetch('/orders', { method: 'POST', body }) as Promise<{ id: string }>;
},
onSuccess: (created) => {
void qc.invalidateQueries({ queryKey: ['orders'] });
void qc.invalidateQueries({ queryKey: ['clients'] });
if (!created) {
// Offline: pedido enfileirado — mostra confirmação e fica na tela
setError(null);
setCart([]);
setSelectedClient(null);
setClientSearch('');
setCart([]);
setIdPauta(undefined);
setCodFormapag(undefined);
setObs('');
setContato('');
setNumOC('');
message.success('Pedido salvo offline — será transmitido ao reconectar');
return;
}
void navigate({ to: '/pedidos/$id', params: { id: created.id } });
},
onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao criar pedido'),
});
return (
<div style={{ maxWidth: 1100, margin: '0 auto' }}>
{/* ── Cabeçalho ─────────────────────────────────────────────────── */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 20,
}}
>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
Lançamento de Pedido
</Title>
<Button
icon={<ArrowLeftOutlined />}
onClick={() =>
void (clientIdParam
? navigate({ to: '/clientes/$id', params: { id: clientIdParam } })
: navigate({ to: '/pedidos' }))
}
style={{ borderRadius: 8 }}
>
Voltar
</Button>
</div>
{error && (
<Alert
type="error"
message={error}
showIcon
closable
onClose={() => setError(null)}
style={{ marginBottom: 16, borderRadius: 8 }}
/>
)}
{/* ── Card 1: Dados do Cliente e Comercial ───────────────────────── */}
<Card style={cardStyle} styles={{ body: { padding: '20px 24px' } }}>
<span style={sectionLabel}>Dados do Cliente e Comercial</span>
<CustomerSearch
value={clientSearch}
selected={selectedClient ?? preloadedClient ?? null}
onSearch={setClientSearch}
onSelect={(c) => {
setSelectedClient(c);
setClientSearch('');
}}
/>
{effectiveClient && (
<div
style={{
margin: '10px 0 16px',
padding: '8px 12px',
background: '#F0F7FF',
borderRadius: 8,
border: '1px solid #BAD7FF',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<CheckCircleOutlined style={{ color: '#0057D9' }} />
<Text style={{ color: '#003B8E', fontWeight: 600 }}>
{effectiveClient.razao ?? effectiveClient.nome}
</Text>
{effectiveClient.cgcpf && (
<Text type="secondary" style={{ fontSize: 12 }}>
{effectiveClient.cgcpf}
</Text>
)}
{effectiveClient.limiteCreditoStr && (
<Text type="secondary" style={{ fontSize: 12, marginLeft: 'auto' }}>
Limite: {fmt(Number(effectiveClient.limiteCreditoStr))}
</Text>
)}
</div>
)}
<Row gutter={[16, 16]} style={{ marginTop: effectiveClient ? 0 : 16 }}>
<Col xs={24} sm={8}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#64748B', marginBottom: 4 }}>
Pauta de Preço
</div>
<Select
style={{ width: '100%' }}
placeholder="Pauta padrão"
allowClear
value={idPauta}
onChange={setIdPauta}
options={pautas.map((p: Pauta) => ({ value: p.idPauta, label: p.descricao }))}
/>
</Col>
<Col xs={24} sm={8}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#64748B', marginBottom: 4 }}>
Condição de Pagamento
</div>
<Select
style={{ width: '100%' }}
placeholder="Selecione..."
allowClear
value={codFormapag}
onChange={setCodFormapag}
options={formasPagamento.map((f: FormaPagamento) => ({
value: f.codigo,
label: f.descricao,
}))}
/>
</Col>
<Col xs={24} sm={8}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#64748B', marginBottom: 4 }}>
Contato Responsável
</div>
<input
value={contato}
onChange={(e) => setContato(e.target.value)}
placeholder="Nome de quem está comprando"
style={{
width: '100%',
height: 32,
padding: '0 11px',
border: '1px solid #d9d9d9',
borderRadius: 6,
fontSize: 14,
outline: 'none',
boxSizing: 'border-box',
color: '#1F2937',
}}
/>
</Col>
</Row>
</Card>
{/* ── Card 2: Informações Adicionais ─────────────────────────────── */}
<Card style={cardStyle} styles={{ body: { padding: '20px 24px' } }}>
<span style={sectionLabel}>Informações Adicionais</span>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#64748B', marginBottom: 4 }}>
Ordem de Compra (Cliente)
</div>
<input
value={numOC}
onChange={(e) => setNumOC(e.target.value)}
placeholder="Ex: OC-98765"
style={{
width: '100%',
height: 32,
padding: '0 11px',
border: '1px solid #d9d9d9',
borderRadius: 6,
fontSize: 14,
outline: 'none',
boxSizing: 'border-box',
color: '#1F2937',
}}
/>
</Col>
<Col xs={24} sm={16}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#64748B', marginBottom: 4 }}>
Observações do Pedido
</div>
<textarea
value={obs}
onChange={(e) => setObs(e.target.value)}
placeholder="Instruções de entrega, detalhes logísticos, etc..."
maxLength={400}
rows={2}
style={{
width: '100%',
padding: '6px 11px',
border: '1px solid #d9d9d9',
borderRadius: 6,
fontSize: 14,
outline: 'none',
resize: 'vertical',
boxSizing: 'border-box',
color: '#1F2937',
fontFamily: 'inherit',
}}
/>
</Col>
</Row>
</Card>
{/* ── Card 3: Produtos ────────────────────────────────────────────── */}
<Card style={cardStyle} styles={{ body: { padding: '20px 24px' } }}>
<span style={sectionLabel}>Pesquisar e Adicionar Produtos</span>
<ProductSearch idPauta={idPauta} onAdd={addToCart} />
<OrderItemsTable
items={cart}
onQtyChange={setQty}
onDiscChange={setDisc}
onRemove={removeItem}
/>
{cart.length > 0 && (
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
marginTop: 12,
paddingTop: 12,
borderTop: '1px solid #EBF0F5',
gap: 24,
}}
>
<Text type="secondary">
{cart.length} item(ns) · {cart.reduce((a, i) => a + i.qtd, 0).toLocaleString('pt-BR')}{' '}
unidades
</Text>
</div>
)}
</Card>
{/* ── Rodapé fixo ─────────────────────────────────────────────────── */}
<OrderSummaryFooter
total={totalPedido}
submitting={mutation.isPending}
canSubmit={canSubmit}
onCancel={() =>
void navigate({ to: clientIdParam ? `/clientes/${clientIdParam}` : '/pedidos' })
}
onSubmit={() => mutation.mutate()}
/>
</div>
);
}

View File

@@ -0,0 +1,511 @@
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 } from '@ant-design/icons';
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',
children: (
<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 canAct = role !== 'rep' && order?.situa === 1;
const canTransmit = 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 [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>
)}
</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>
)}
<Descriptions.Item label="Total produtos">{fmt(order.totalProdutos)}</Descriptions.Item>
<Descriptions.Item label="Desc. Global">{order.descontoPerc}%</Descriptions.Item>
<Descriptions.Item label="Total">
<Text strong style={{ fontSize: 16 }}>
{fmt(order.total)}
</Text>
</Descriptions.Item>
{order.obs && (
<Descriptions.Item label="Observações" span={2}>
{order.obs}
</Descriptions.Item>
)}
{order.motivoRecusa && (
<Descriptions.Item label="Motivo Recusa" span={2}>
<Text type="danger">{order.motivoRecusa}</Text>
</Descriptions.Item>
)}
</Descriptions>
<Divider orientation="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 orientation="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 orientation="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}
/>
</div>
);
}

View File

@@ -0,0 +1,430 @@
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,
};
function Field({ k, v }: { k: string; v: React.ReactNode }) {
if (v == null || v === '' || v === '—') return null;
return (
<div style={{ marginBottom: 5 }}>
<span style={label}>{k}</span>
<span style={{ fontSize: 11.5, color: INK }}>{v}</span>
</div>
);
}
export function OrderPrintPage() {
const { id } = useParams({ from: '/pedidos/$id/imprimir' });
const navigate = useNavigate();
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>
{/* ── Cliente + Representante ─────────────────────────────────────── */}
<div style={{ display: 'flex', gap: 0 }}>
<div style={{ flex: 1.4, padding: '16px 28px', borderRight: `1px solid ${LINE}` }}>
<div
style={{
fontSize: 10,
fontWeight: 800,
color: BLUE,
letterSpacing: '0.1em',
marginBottom: 10,
}}
>
CLIENTE
</div>
<div style={{ fontSize: 13.5, fontWeight: 700, color: INK, marginBottom: 2 }}>
{clienteNome}
</div>
{tx(client?.nome) && tx(client?.razao) && tx(client?.nome) !== tx(client?.razao) && (
<div style={{ fontSize: 11, color: MUTED, marginBottom: 8 }}>{tx(client?.nome)}</div>
)}
<div style={{ marginTop: 8 }}>
<Field k={docLabel} v={doc(client?.cgcpf)} />
<Field k="Inscr. Estadual" v={tx(client?.inscricaoEstadual)} />
<Field k="Endereço" v={enderecoCli} />
<Field k="Telefone" v={phone(client?.telefone, client?.ddd)} />
<Field k="E-mail" v={tx(client?.email)} />
</div>
</div>
<div style={{ flex: 1, padding: '16px 28px' }}>
<div
style={{
fontSize: 10,
fontWeight: 800,
color: BLUE,
letterSpacing: '0.1em',
marginBottom: 10,
}}
>
REPRESENTANTE
</div>
<div style={{ fontSize: 13.5, fontWeight: 700, color: INK, marginBottom: 8 }}>
{tx(order.nomeVendedor) ?? `Cód. ${order.codVendedor}`}
</div>
<Field k="Código" v={String(order.codVendedor)} />
<Field k="Data do pedido" v={dateBR(order.dtPedido)} />
<Field k="Nº do pedido" v={order.numPedSar} />
</div>
</div>
{/* ── Itens ───────────────────────────────────────────────────────── */}
<div style={{ padding: '8px 28px 0' }}>
<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>
{/* ── Observações + rodapé ────────────────────────────────────────── */}
{order.obs && (
<div style={{ padding: '10px 28px 0' }}>
<span style={label}>Observações</span>
<div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>{order.obs}</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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,450 @@
import { Card, Col, Flex, Progress, Row, Skeleton, Space, Table, Tag, Typography } from 'antd';
import type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faArrowTrendUp,
faBullseye,
faCircleExclamation,
faClipboardList,
} from '@fortawesome/free-solid-svg-icons';
import { Link } from '@tanstack/react-router';
import type { MetaItem, PedidoSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth';
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 });
}
// Célula "realizado / meta" — realizado em destaque (verde se bateu), meta abaixo.
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)'}
/>
),
},
];
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={12}>
<Skeleton active />
</Col>
<Col xs={12} md={6}>
<Skeleton active />
</Col>
<Col xs={12} md={6}>
<Skeleton active />
</Col>
</Row>
</Flex>
);
}
const { meta, metasPorGrupo, comissao, pedidosMes, pedidosRecentes, clientesInativos, syncedAt } =
data;
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()}
{clientesInativos.length > 0 && (
<>
{' '}
·{' '}
<span style={{ color: 'var(--orange)' }}>
{clientesInativos.length} clientes inativos
</span>
</>
)}
</Text>
</Flex>
{/* Linha 1 — Meta + KPIs */}
<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 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)"
trailColor="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={12} md={6}>
<Card>
<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} /> últimos 30 dias
</Text>
</Space>
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
COMISSÃO ACUMULADA
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(comissao.total)}
</Title>
{comissao.flex > 0 && (
<Text type="success" style={{ fontSize: 'var(--text-sm)' }}>
FLEX: {fmt(comissao.flex)}
</Text>
)}
</Space>
</Card>
</Col>
</Row>
{/* Metas por Grupo — acompanhamento multi-medida do mês */}
{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>
)}
{/* Linha 2 — Clientes inativos + Pedidos recentes */}
<Row gutter={[24, 24]}>
<Col xs={24} lg={12}>
<Card
title={
<Space>
<FontAwesomeIcon icon={faCircleExclamation} style={{ color: 'var(--orange)' }} />
Clientes esfriando
</Space>
}
extra={
clientesInativos.length > 0 ? (
<Text type="secondary">{clientesInativos.length} clientes</Text>
) : null
}
>
{clientesInativos.length === 0 ? (
<Text type="secondary">Nenhum cliente inativo. Ótimo trabalho!</Text>
) : (
<Flex vertical gap={12}>
{clientesInativos.map((c) => (
<Flex
key={c.idCliente}
justify="space-between"
align="center"
style={{
padding: 'var(--space-sm) var(--space-md)',
borderRadius: 12,
background: c.diasSemCompra > 60 ? '#fff7e6' : 'var(--bg-surface-alt)',
}}
>
<Space orientation="vertical" size={0}>
<Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
<Text strong>{c.nome}</Text>
</Link>
{c.ultimaCompraValor && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Última compra:{' '}
<span className="tabular-nums">
{Number(c.ultimaCompraValor).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})}
</span>
</Text>
)}
</Space>
<Tag
color={c.diasSemCompra > 60 ? 'orange' : 'default'}
className="tabular-nums"
>
{c.diasSemCompra >= 999 ? 'nunca comprou' : `${c.diasSemCompra}d`}
</Tag>
</Flex>
))}
</Flex>
)}
</Card>
</Col>
<Col xs={24} lg={12}>
<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,72 @@
// 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 = empresa no ERP (dev default = 1)
// Em dev, o backend força DEV_REP_CODE=29 independente do userId enviado.
const DEV_USERS: DevUser[] = [
{ key: 'rep-29', userId: '29', role: 'rep', label: 'Representante (cód. 29)' },
{ key: 'sup-29', userId: '29', role: 'supervisor', label: 'Supervisor (cód. 29)' },
{ key: 'mgr-29', userId: '29', role: 'manager', label: 'Gerente (cód. 29)' },
];
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, idEmpresa: 1, 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,7 +1,11 @@
import { useState, type ReactNode } from 'react';
import { Flex } from 'antd';
import { Alert, Button, Flex, Tooltip } from 'antd';
import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
import { useNavigate } from '@tanstack/react-router';
import { Topbar } from './Topbar';
import { Sidebar } from './Sidebar';
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
interface AppShellProps {
children: ReactNode;
@@ -13,11 +17,23 @@ interface AppShellProps {
* Variante mobile (Rafael) com bottom nav virá em ShellMobile separado.
*/
export function AppShell({ children }: AppShellProps) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_sidebarOpen, setSidebarOpen] = useState(true);
const [, setSidebarOpen] = useState(true);
const navigate = useNavigate();
const isOnline = useNetworkStatus();
useOfflineSync();
return (
<Flex vertical style={{ minHeight: '100vh', background: 'var(--bg-body)' }}>
{!isOnline && (
<Alert
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 onToggleSidebar={() => setSidebarOpen((v) => !v)} />
<Flex flex={1}>
<Sidebar />
@@ -32,6 +48,31 @@ export function AppShell({ children }: AppShellProps) {
{children}
</main>
</Flex>
{/* FAB — Novo Pedido */}
<Tooltip title="Novo Pedido" placement="left">
<Button
type="primary"
shape="circle"
icon={<PlusOutlined />}
onClick={() => void navigate({ to: '/pedidos/novo' })}
style={{
position: 'fixed',
bottom: 32,
right: 32,
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>
);
}

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

View File

@@ -21,7 +21,6 @@ import type { ItemType } from 'antd/es/menu/interface';
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const items: ItemType[] = [
{
key: '/',
@@ -39,7 +38,7 @@ export function Sidebar() {
label: 'Clientes',
},
{
key: '/produtos',
key: '/catalogo',
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
label: 'Catálogo',
},

View File

@@ -1,7 +1,18 @@
import { Avatar, Badge, Button, Flex, Input } from 'antd';
import { Avatar, Badge, Button, Dropdown, Flex, Input, Typography } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBell, faMagnifyingGlass, faBars } from '@fortawesome/free-solid-svg-icons';
import {
faBell,
faMagnifyingGlass,
faBars,
faRightFromBracket,
} from '@fortawesome/free-solid-svg-icons';
import { useNavigate } from '@tanstack/react-router';
import { brandTokens } from '../../lib/theme';
import { FoundationStatus } from './FoundationStatus';
import { usePendingCount } from '../../lib/queries/notifications';
import { useCurrentUser } from '../../lib/queries/auth';
import { authStore } from '../../lib/auth-store';
interface TopbarProps {
onToggleSidebar?: () => void;
@@ -12,7 +23,53 @@ interface TopbarProps {
* Apple-inspired clean: logo à esquerda, search central, notif + perfil à direita.
* Variantes por cockpit: search desabilitada para Rafael mobile (vai pro bottom nav).
*/
function logout() {
authStore.clear();
window.location.reload();
}
export function Topbar({ onToggleSidebar }: TopbarProps) {
const navigate = useNavigate();
const { data: pendingData } = usePendingCount();
const pendingCount = pendingData?.count ?? 0;
const { data: user } = useCurrentUser();
const initials = user?.nome
? user.nome
.split(' ')
.slice(0, 2)
.map((w) => w[0])
.join('')
.toUpperCase()
: '?';
const userMenuItems = [
{
key: 'profile',
label: (
<Flex vertical gap={2} style={{ padding: '4px 0', minWidth: 180 }}>
<Typography.Text strong style={{ fontSize: 'var(--text-sm)' }}>
{user?.nome?.trim() ?? '—'}
</Typography.Text>
<Typography.Text
type="secondary"
style={{ fontSize: 'var(--text-xs)', textTransform: 'capitalize' }}
>
{user?.role ?? ''}
</Typography.Text>
</Flex>
),
disabled: true,
},
{ type: 'divider' as const },
{
key: 'logout',
danger: true,
icon: <FontAwesomeIcon icon={faRightFromBracket} />,
label: 'Sair',
onClick: logout,
},
];
return (
<Flex
align="center"
@@ -37,11 +94,7 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
style={{ display: 'inline-flex' }}
/>
<Flex align="center" gap={12}>
<img
src="/sar-icon.png"
alt="SAR"
style={{ height: 40, width: 'auto' }}
/>
<img src="/sar-icon.png" alt="SAR" style={{ height: 40, width: 'auto' }} />
<Flex vertical gap={0}>
<span
style={{
@@ -68,25 +121,31 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
</Flex>
</Flex>
{/* Centro: search (Sandra/Daniel/Alice) */}
{/* Centro: search (Supervisor/Admin) */}
<Flex flex={1} justify="center" style={{ maxWidth: 480, margin: '0 var(--space-2xl)' }}>
<Input
size="large"
placeholder="Buscar cliente, pedido, produto..."
prefix={
<FontAwesomeIcon
icon={faMagnifyingGlass}
style={{ color: 'var(--text-muted)' }}
/>
<FontAwesomeIcon icon={faMagnifyingGlass} style={{ color: 'var(--text-muted)' }} />
}
style={{ borderRadius: 12 }}
aria-label="Buscar"
/>
</Flex>
{/* Lado direito: notificações + perfil */}
{/* Lado direito: novo pedido + status fundação + notificações + perfil */}
<Flex align="center" gap={16}>
<Badge count={3} color={brandTokens.red} offset={[-4, 4]}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => void navigate({ to: '/pedidos/novo' })}
style={{ borderRadius: 8, fontWeight: 'var(--font-weight-semibold)' }}
>
Novo Pedido
</Button>
<FoundationStatus />
<Badge count={pendingCount} color={brandTokens.red} offset={[-4, 4]}>
<Button
type="text"
size="large"
@@ -94,17 +153,19 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
aria-label="Notificações"
/>
</Badge>
<Avatar
size={40}
style={{
background: 'var(--jcs-blue-light)',
color: 'var(--jcs-blue)',
fontWeight: 'var(--font-weight-semibold)',
cursor: 'pointer',
}}
>
R
</Avatar>
<Dropdown menu={{ items: userMenuItems }} trigger={['click']} placement="bottomRight">
<Avatar
size={40}
style={{
background: 'var(--jcs-blue-light)',
color: 'var(--jcs-blue)',
fontWeight: 'var(--font-weight-semibold)',
cursor: 'pointer',
}}
>
{initials}
</Avatar>
</Dropdown>
</Flex>
</Flex>
);

View File

@@ -0,0 +1,98 @@
// Cliente HTTP da SAR Web.
//
// Responsabilidades:
// - Encapsular fetch com base URL relativa (proxy Vite em dev, mesmo origin em prod).
// - Parsear RFC 9457 application/problem+json em ApiError estruturado.
// - NÃO faz validação Zod — isso é responsabilidade do caller (useQuery + Schema.parse).
//
// CODING-RULES §05: 422 = validação Zod; 4xx outros = erros de domínio; 5xx = retry pelo
// QueryClient (até 2x). O ApiError carrega tudo que o caller precisa pra decidir.
import { authStore } from './auth-store';
const PROBLEM_CONTENT_TYPE = 'application/problem+json';
// Prefixo canônico: proxy Vite (/api → :3000) + versão da API.
// Em produção o Nginx faz o mesmo roteamento pelo mesmo prefixo.
const API_BASE = '/api/v1';
export interface ProblemDetails {
type: string;
title: string;
status: number;
detail?: string;
instance?: string;
requestId?: string;
errors?: ReadonlyArray<{ path: string; message: string; code?: string }>;
}
export class ApiError extends Error {
readonly status: number;
readonly problem: ProblemDetails;
constructor(problem: ProblemDetails) {
super(problem.detail ?? problem.title);
this.name = 'ApiError';
this.status = problem.status;
this.problem = problem;
}
}
interface RequestOptions extends Omit<RequestInit, 'body'> {
body?: unknown;
}
export async function apiFetch(path: string, options: RequestOptions = {}): Promise<unknown> {
const { body, headers, ...rest } = options;
const token = authStore.get();
const init: RequestInit = {
...rest,
headers: {
Accept: 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers,
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
};
const response = await fetch(`${API_BASE}${path}`, init);
if (!response.ok) {
throw await toApiError(response);
}
// 204 No Content
if (response.status === 204) return undefined;
return response.json();
}
async function toApiError(response: Response): Promise<ApiError> {
const contentType = response.headers.get('Content-Type') ?? '';
if (contentType.includes(PROBLEM_CONTENT_TYPE) || contentType.includes('application/json')) {
try {
const body = (await response.json()) as ProblemDetails;
return new ApiError({
type: body.type ?? 'about:blank',
title: body.title ?? response.statusText,
status: body.status ?? response.status,
detail: body.detail,
instance: body.instance,
requestId: body.requestId,
errors: body.errors,
});
} catch {
// fall through
}
}
return new ApiError({
type: 'about:blank',
title: response.statusText || 'Request failed',
status: response.status,
});
}

View File

@@ -0,0 +1,16 @@
// Store minimalista para o token de acesso (dev: localStorage; prod: cookie httpOnly via BFF).
// Em produção o token virá do master-login real e não ficará em localStorage.
const TOKEN_KEY = 'sar_access_token';
export const authStore = {
get(): string | null {
return localStorage.getItem(TOKEN_KEY);
},
set(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
},
clear(): void {
localStorage.removeItem(TOKEN_KEY);
},
};

View File

@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react';
export function useNetworkStatus(): boolean {
const [isOnline, setIsOnline] = useState(() => navigator.onLine);
useEffect(() => {
const up = () => setIsOnline(true);
const down = () => setIsOnline(false);
window.addEventListener('online', up);
window.addEventListener('offline', down);
return () => {
window.removeEventListener('online', up);
window.removeEventListener('offline', down);
};
}, []);
return isOnline;
}

View File

@@ -0,0 +1,63 @@
// Auto-sync da fila offline ao recuperar conexão.
// NFR-2.3: detecta retorno de conexão e sincroniza sem ação do usuário.
// NFR-2.4: falhas visíveis — nunca descarta pedido silenciosamente.
import { useEffect, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
listPendingOrders,
removePendingOrder,
markOrderFailed,
type PendingOrder,
} from '../offline/order-queue';
import { apiFetch } from '../api-client';
export function useOfflineSync() {
const qc = useQueryClient();
const sync = useCallback(async () => {
const pending: PendingOrder[] = await listPendingOrders();
const toSync = pending.filter((o: PendingOrder) => o.status === 'pending');
if (toSync.length === 0) return;
for (const order of toSync as PendingOrder[]) {
try {
const created = (await apiFetch('/orders', {
method: 'POST',
body: order.payload,
})) as { id: string };
// Tenta transmitir — bloqueio duro se acima da alçada; deixa como Orçamento
try {
await apiFetch(`/orders/${created.id}/transmit`, { method: 'PATCH' });
} catch {
// Desconto acima da alçada: pedido fica como Orçamento, rep transmite manualmente
}
await removePendingOrder(order.idempotencyKey);
} catch (e) {
const reason = e instanceof Error ? e.message : 'Erro ao sincronizar pedido';
await markOrderFailed(order.idempotencyKey, reason);
}
}
// Notifica UI para re-render das listas
window.dispatchEvent(new CustomEvent('sar:sync-complete'));
void qc.invalidateQueries({ queryKey: ['orders'] });
void qc.invalidateQueries({ queryKey: ['dashboard'] });
}, [qc]);
useEffect(() => {
// Sync imediato no mount se houver fila e conexão
if (navigator.onLine) void sync();
const handleOnline = () => void sync();
const handleRequest = () => void sync();
window.addEventListener('online', handleOnline);
window.addEventListener('sar:sync-request', handleRequest);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('sar:sync-request', handleRequest);
};
}, [sync]);
}

View File

@@ -0,0 +1,27 @@
import { useState, useEffect, useCallback } from 'react';
import { listPendingOrders, type PendingOrder } from '../offline/order-queue';
export function usePendingOrders() {
const [orders, setOrders] = useState<PendingOrder[]>([]);
const refresh = useCallback(async () => {
const pending = await listPendingOrders();
setOrders(pending);
}, []);
useEffect(() => {
void refresh();
const handle = () => void refresh();
window.addEventListener('sar:sync-complete', handle);
window.addEventListener('sar:sync-request', handle);
window.addEventListener('sar:order-queued', handle);
return () => {
window.removeEventListener('sar:sync-complete', handle);
window.removeEventListener('sar:sync-request', handle);
window.removeEventListener('sar:order-queued', handle);
};
}, [refresh]);
return { orders, refresh };
}

View File

@@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { apiFetch } from '../api-client';
const VAPID_PUBLIC_KEY = import.meta.env['VITE_VAPID_PUBLIC_KEY'] as string | undefined;
function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(b64);
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
export function usePushRegistration() {
useEffect(() => {
if (!VAPID_PUBLIC_KEY || !('serviceWorker' in navigator) || !('PushManager' in window)) return;
const register = async () => {
try {
const reg = await navigator.serviceWorker.ready;
const existing = await reg.pushManager.getSubscription();
const sub =
existing ??
(await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
}));
const json = sub.toJSON();
await apiFetch('/notifications/subscribe', {
method: 'POST',
body: {
endpoint: sub.endpoint,
keys: { p256dh: json.keys?.['p256dh'] ?? '', auth: json.keys?.['auth'] ?? '' },
},
});
} catch {
// Push é opt-in — permissão negada ou SW não disponível é normal
}
};
void register();
}, []);
}

View File

@@ -0,0 +1,54 @@
// Wrappers mínimos sobre IndexedDB nativo — sem dependências externas.
// Todos os stores do SAR offline vivem em um único banco versionado.
const DB_NAME = 'sar-offline';
const DB_VERSION = 1;
export const STORE_PENDING_ORDERS = 'pending-orders';
let _db: IDBDatabase | null = null;
function openDB(): Promise<IDBDatabase> {
if (_db) return Promise.resolve(_db);
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const db = (e.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_PENDING_ORDERS)) {
db.createObjectStore(STORE_PENDING_ORDERS, { keyPath: 'idempotencyKey' });
}
};
req.onsuccess = () => {
_db = req.result;
resolve(_db);
};
req.onerror = () => reject(req.error);
});
}
export async function idbGetAll<T>(store: string): Promise<T[]> {
const db = await openDB();
return new Promise((resolve, reject) => {
const req = db.transaction(store, 'readonly').objectStore(store).getAll();
req.onsuccess = () => resolve(req.result as T[]);
req.onerror = () => reject(req.error);
});
}
export async function idbPut<T>(store: string, value: T): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const req = db.transaction(store, 'readwrite').objectStore(store).put(value);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
export async function idbDelete(store: string, key: string): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const req = db.transaction(store, 'readwrite').objectStore(store).delete(key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}

View File

@@ -0,0 +1,63 @@
// Fila de pedidos pendentes de sync (IndexedDB).
// FR-4.2: lançamento funciona completamente offline.
// FR-4.3: Idempotency-Key gerado localmente antes do envio.
// FR-4.11: falhas de sync nunca descartadas silenciosamente.
import type { CreatePedido } from '@sar/api-interface';
import { idbGetAll, idbPut, idbDelete, STORE_PENDING_ORDERS } from './idb';
export interface PendingOrder {
idempotencyKey: string; // keyPath do IndexedDB
payload: CreatePedido;
clienteNome: string;
status: 'pending' | 'failed';
failReason?: string;
createdAt: string;
}
export function listPendingOrders(): Promise<PendingOrder[]> {
return idbGetAll<PendingOrder>(STORE_PENDING_ORDERS);
}
export async function enqueueOrder(
payload: CreatePedido,
clienteNome: string,
): Promise<PendingOrder> {
const key = payload.idempotencyKey ?? crypto.randomUUID();
const order: PendingOrder = {
idempotencyKey: key,
payload: { ...payload, idempotencyKey: key },
clienteNome,
status: 'pending',
createdAt: new Date().toISOString(),
};
await idbPut<PendingOrder>(STORE_PENDING_ORDERS, order);
return order;
}
export async function removePendingOrder(idempotencyKey: string): Promise<void> {
return idbDelete(STORE_PENDING_ORDERS, idempotencyKey);
}
export async function markOrderFailed(idempotencyKey: string, reason: string): Promise<void> {
const all = await listPendingOrders();
const order = all.find((o) => o.idempotencyKey === idempotencyKey);
if (!order) return;
await idbPut<PendingOrder>(STORE_PENDING_ORDERS, {
...order,
status: 'failed',
failReason: reason,
});
}
export async function retryPendingOrder(idempotencyKey: string): Promise<void> {
const all = await listPendingOrders();
const order = all.find((o) => o.idempotencyKey === idempotencyKey);
if (!order) return;
await idbPut<PendingOrder>(STORE_PENDING_ORDERS, {
...order,
status: 'pending',
failReason: undefined,
});
window.dispatchEvent(new CustomEvent('sar:sync-request'));
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { UserProfileSchema, type UserProfile } from '@sar/api-interface';
import { apiFetch } from '../api-client';
export const AUTH_KEYS = {
me: ['auth', 'me'] as const,
};
export function useCurrentUser() {
return useQuery<UserProfile, Error>({
queryKey: AUTH_KEYS.me,
queryFn: async () => {
const res = await apiFetch('/auth/me');
return UserProfileSchema.parse(res);
},
staleTime: 5 * 60 * 1000,
});
}

View File

@@ -0,0 +1,66 @@
import { useQuery } from '@tanstack/react-query';
import {
FormaPagamentoSchema,
PautaSchema,
ProdutoListResponseSchema,
ProdutoDetailSchema,
type FormaPagamento,
type ProdutoListQuery,
type ProdutoListResponse,
type ProdutoDetail,
type Pauta,
} from '@sar/api-interface';
import { z } from 'zod';
import { apiFetch } from '../api-client';
export function usePautas() {
return useQuery<Pauta[]>({
queryKey: ['catalog', 'pautas'],
queryFn: async () => {
const res = await apiFetch('/catalog/pautas');
return z.array(PautaSchema).parse(res);
},
staleTime: 10 * 60 * 1000,
});
}
export function useFormasPagamento() {
return useQuery<FormaPagamento[]>({
queryKey: ['catalog', 'payment-methods'],
queryFn: async () => {
const res = await apiFetch('/catalog/payment-methods');
return z.array(FormaPagamentoSchema).parse(res);
},
staleTime: 60 * 60 * 1000,
});
}
export function useCatalog(params: Partial<ProdutoListQuery> = {}) {
const search = new URLSearchParams();
if (params.q) search.set('q', params.q);
if (params.codGrupo) search.set('codGrupo', String(params.codGrupo));
if (params.idPauta) search.set('idPauta', String(params.idPauta));
if (params.page) search.set('page', String(params.page));
if (params.limit) search.set('limit', String(params.limit));
const qs = search.toString();
return useQuery<ProdutoListResponse>({
queryKey: ['catalog', params],
queryFn: async () => {
const res = await apiFetch(`/catalog${qs ? `?${qs}` : ''}`);
return ProdutoListResponseSchema.parse(res);
},
staleTime: 4 * 60 * 60 * 1000,
});
}
export function useProdutoDetail(id: number | undefined) {
return useQuery<ProdutoDetail>({
queryKey: ['catalog', id],
enabled: id != null,
queryFn: async () => {
const res = await apiFetch(`/catalog/${id}`);
return ProdutoDetailSchema.parse(res);
},
});
}

View File

@@ -0,0 +1,44 @@
import { useQuery } from '@tanstack/react-query';
import {
ClientListResponseSchema,
ClientDetailSchema,
type ClientListQuery,
type ClientListResponse,
type ClientDetail,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
export const CLIENT_KEYS = {
all: ['clients'] as const,
list: (params: Partial<ClientListQuery>) => ['clients', 'list', params] as const,
detail: (id: number) => ['clients', 'detail', id] as const,
};
export function useClientList(params: Partial<ClientListQuery> = {}) {
const qs = new URLSearchParams();
if (params.q) qs.set('q', params.q);
if (params.status) qs.set('status', params.status);
if (params.page) qs.set('page', String(params.page));
if (params.limit) qs.set('limit', String(params.limit));
const query = qs.toString();
return useQuery<ClientListResponse, Error>({
queryKey: CLIENT_KEYS.list(params),
queryFn: async () => {
const res = await apiFetch(`/clients${query ? `?${query}` : ''}`);
return ClientListResponseSchema.parse(res);
},
});
}
export function useClientDetail(id: number | string | undefined) {
return useQuery<ClientDetail, Error>({
queryKey: CLIENT_KEYS.detail(Number(id)),
queryFn: async () => {
const res = await apiFetch(`/clients/${id}`);
return ClientDetailSchema.parse(res);
},
enabled: !!id,
});
}

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { EmpresaInfoSchema, type EmpresaInfo } from '@sar/api-interface';
import { apiFetch } from '../api-client';
// Dados da empresa matriz (cabeçalho do PDF do pedido). Cache longo — muda raramente.
export function useCompany() {
return useQuery<EmpresaInfo, Error>({
queryKey: ['company'],
queryFn: async () => EmpresaInfoSchema.parse(await apiFetch('/catalog/company')),
staleTime: 1000 * 60 * 30,
});
}

View File

@@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query';
import {
RepDashboardSchema,
SupervisorDashboardSchema,
type RepDashboard,
type SupervisorDashboard,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
export function useRepDashboard() {
return useQuery<RepDashboard>({
queryKey: ['dashboard', 'rep'],
queryFn: async () => {
const raw = await apiFetch('/dashboard/rep');
return RepDashboardSchema.parse(raw);
},
staleTime: 5 * 60 * 1000,
});
}
export function useSupervisorDashboard() {
return useQuery<SupervisorDashboard>({
queryKey: ['dashboard', 'supervisor'],
queryFn: async () => {
const raw = await apiFetch('/dashboard/supervisor');
return SupervisorDashboardSchema.parse(raw);
},
staleTime: 30 * 1000, // 30s — simula near-real-time até C6 (SSE)
refetchInterval: 30 * 1000,
});
}

View File

@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { PendingCountResponseSchema } from '@sar/api-interface';
import { apiFetch } from '../api-client';
export function usePendingCount() {
return useQuery({
queryKey: ['notifications', 'pending-count'],
queryFn: async () => {
const res = await apiFetch('/notifications/pending-count');
return PendingCountResponseSchema.parse(res);
},
refetchInterval: 30_000,
staleTime: 20_000,
});
}

View File

@@ -0,0 +1,53 @@
import { useQuery } from '@tanstack/react-query';
import {
PedidoListResponseSchema,
PedidoDetailSchema,
type PedidoListQuery,
type PedidoListResponse,
type PedidoDetail,
type PedidoSummary,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
export function useOrderList(params: Partial<PedidoListQuery> = {}) {
const search = new URLSearchParams();
if (params.idCliente) search.set('idCliente', String(params.idCliente));
if (params.situa) search.set('situa', String(params.situa));
if (params.numPedSar) search.set('numPedSar', params.numPedSar);
if (params.from) search.set('from', params.from);
if (params.to) search.set('to', params.to);
if (params.page) search.set('page', String(params.page));
if (params.limit) search.set('limit', String(params.limit));
const qs = search.toString();
return useQuery<PedidoListResponse>({
queryKey: ['orders', params],
queryFn: async () => {
const res = await apiFetch(`/orders${qs ? `?${qs}` : ''}`);
return PedidoListResponseSchema.parse(res);
},
});
}
export function useOrderDetail(id: string | undefined) {
return useQuery<PedidoDetail>({
queryKey: ['orders', id],
enabled: !!id,
queryFn: async () => {
const res = await apiFetch(`/orders/${id}`);
return PedidoDetailSchema.parse(res);
},
});
}
export function useClientOrders(idCliente: number | undefined) {
return useQuery<PedidoSummary[]>({
queryKey: ['clients', idCliente, 'orders'],
enabled: idCliente != null,
queryFn: async () => {
const res = await apiFetch(`/orders?idCliente=${idCliente}&limit=10`);
const data = PedidoListResponseSchema.parse(res);
return data.data;
},
});
}

View File

@@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import { PingResponseSchema, type PingResponse } from '@sar/api-interface';
import { apiFetch } from '../api-client';
// useApiPing — prova de conectividade ponta-a-ponta API↔Web.
//
// O contrato é o schema Zod compartilhado (@sar/api-interface). Qualquer drift
// no servidor (campo removido, tipo trocado) falha alto via .parse() ANTES de
// chegar nos componentes — o erro vai pra TanStack `error` e mostramos pill 🔴.
//
// refetchInterval 30s = "sereno" (Visual DNA) — sem flash de loading constante.
export const PING_QUERY_KEY = ['health', 'ping'] as const;
export function useApiPing() {
return useQuery<PingResponse, Error>({
queryKey: PING_QUERY_KEY,
queryFn: async () => {
const raw = await apiFetch('/ping');
return PingResponseSchema.parse(raw);
},
refetchInterval: 30_000,
refetchOnWindowFocus: false,
staleTime: 25_000,
});
}

View File

@@ -1,6 +1,52 @@
import { createRouter, createRootRoute, createRoute, Outlet } from '@tanstack/react-router';
import {
createRouter,
createRootRoute,
createRoute,
Outlet,
notFound,
} from '@tanstack/react-router';
import { Typography } from 'antd';
import { AppShell } from '../components/layout/AppShell';
import { RafaelPainel } from '../cockpits/rafael/RafaelPainel';
import { RepPainel } from '../cockpits/rep/RepPainel';
import { ClientsPage } from '../cockpits/rep/ClientsPage';
import { ClientDetailPage } from '../cockpits/rep/ClientDetailPage';
import { OrdersPage } from '../cockpits/rep/OrdersPage';
import { OrderDetailPage } from '../cockpits/rep/OrderDetailPage';
import { OrderPrintPage } from '../cockpits/rep/OrderPrintPage';
import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
import { CatalogPage } from '../cockpits/rep/CatalogPage';
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
import { authStore } from './auth-store';
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';
}
}
function HomeRoute() {
const role = getRoleFromToken();
return role === 'supervisor' || role === 'manager' ? <SupervisorPainel /> : <RepPainel />;
}
function NotFoundPage() {
return (
<div style={{ padding: 48, textAlign: 'center' }}>
<Typography.Title level={3} type="secondary">
Página não encontrada
</Typography.Title>
</div>
);
}
// Suprime aviso de notFound não utilizado — usado via throw notFound() em loaders futuros.
void notFound;
const rootRoute = createRootRoute({
component: () => (
@@ -8,22 +54,81 @@ const rootRoute = createRootRoute({
<Outlet />
</AppShell>
),
notFoundComponent: NotFoundPage,
});
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: RafaelPainel,
component: HomeRoute,
});
// Placeholder routes (cockpits a implementar)
const rafaelRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/rep',
component: RafaelPainel,
component: RepPainel,
});
const routeTree = rootRoute.addChildren([indexRoute, rafaelRoute]);
const clientesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/clientes',
component: ClientsPage,
});
const clienteDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/clientes/$id',
component: ClientDetailPage,
});
const pedidosRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/pedidos',
component: OrdersPage,
});
const novoOrderRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/pedidos/novo',
component: NewOrderPage,
});
const pedidoDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/pedidos/$id',
component: OrderDetailPage,
});
const pedidoPrintRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/pedidos/$id/imprimir',
component: OrderPrintPage,
});
const catalogoRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/catalogo',
component: CatalogPage,
});
const aprovacoes = createRoute({
getParentRoute: () => rootRoute,
path: '/aprovacoes',
component: ApprovalQueuePage,
});
const routeTree = rootRoute.addChildren([
indexRoute,
rafaelRoute,
clientesRoute,
clienteDetailRoute,
pedidosRoute,
novoOrderRoute,
pedidoDetailRoute,
pedidoPrintRoute,
catalogoRoute,
aprovacoes,
]);
export const router = createRouter({
routeTree,

View File

@@ -1,4 +1,4 @@
import { StrictMode } from 'react';
import { StrictMode, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { ConfigProvider, App as AntdApp } from 'antd';
import ptBR from 'antd/locale/pt_BR';
@@ -12,9 +12,30 @@ import './styles/global.css';
import { sarTheme } from './lib/theme';
import { queryClient } from './lib/query-client';
import { router } from './lib/router';
import { authStore } from './lib/auth-store';
import { DevLogin } from './components/dev/DevLogin';
dayjs.locale('pt-br');
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {
// SW é opt-in — falha silenciosa não impede o app
});
}
const isDev = import.meta.env.DEV;
function Root() {
const [hasToken, setHasToken] = useState(() => !!authStore.get());
// Em dev, exibe DevLogin se não houver token. Em prod, fluxo de auth real virá aqui.
if (isDev && !hasToken) {
return <DevLogin onLogin={() => setHasToken(true)} />;
}
return <RouterProvider router={router} />;
}
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Root element not found');
@@ -25,7 +46,7 @@ createRoot(rootEl).render(
<ConfigProvider theme={sarTheme} locale={ptBR} componentSize="middle">
<AntdApp>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Root />
</QueryClientProvider>
</AntdApp>
</ConfigProvider>

View File

@@ -0,0 +1 @@
{"fileNames":[],"fileInfos":[],"root":[],"options":{"allowJs":false,"allowSyntheticDefaultImports":true,"composite":false,"declaration":true,"declarationMap":true,"emitDecoratorMetadata":true,"esModuleInterop":false,"experimentalDecorators":true,"jsx":4,"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

@@ -10,6 +10,15 @@ export default defineConfig(() => ({
server: {
port: 4200,
host: 'localhost',
// Proxy /api/* → API Nest em :3000 (default API_PORT).
// Evita CORS em dev e mantém URL relativa no código da Web — em produção,
// mesmo origin via Nginx (/api/* → backend).
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: false,
},
},
},
preview: {
port: 4200,

16
commitlint.config.js Normal file
View File

@@ -0,0 +1,16 @@
// Commitlint — Conventional Commits canon JCS SAR.
// Tipos extras: docs, perf, ci, revert (além dos convencionais).
export default {
extends: ['@commitlint/config-conventional'],
rules: {
// Escopo opcional mas encorajado (api | web | shared | infra | docs)
'scope-enum': [
1, // warn, não error — novo escopo pode surgir legitimamente
'always',
['api', 'web', 'shared', 'infra', 'docs', 'e2e', 'config'],
],
// Corpo pode ter qualquer comprimento (mensagens longas são bem-vindas)
'body-max-line-length': [0, 'always', Infinity],
'footer-max-line-length': [0, 'always', Infinity],
},
};

View File

@@ -319,6 +319,126 @@
4. **Master-login stub + WorkspacePrismaPool** (frente arquitetural pesada — depende de #3).
5. **OpenTelemetry SDK** plugar quando entrar em catálogo (stub atual mantém posição).
### 2026-05-27 — Web→API ponta-a-ponta (loop B+C fechado) CONCLUÍDO ✅
- **Escopo da sessão:** Pendência #1 do roadmap — provar que `@sar/api-interface` é honrado pelos dois lados em runtime, não só em build time. Loop B (Web foundation) + C (Zod contracts) fechado.
- **Arquivos novos:**
- `apps/web/src/lib/api-client.ts` — fetch wrapper que parseia `application/problem+json` (RFC 9457) em `ApiError` estruturado carregando status+type+title+detail+requestId+errors[]. Sem validação Zod aqui — responsabilidade do caller (CODING-RULES §05).
- `apps/web/src/lib/queries/ping.ts``useApiPing()` TanStack Query chamando `/api/v1/ping` + `PingResponseSchema.parse(...)`. Drift servidor falha alto **antes** de chegar nos componentes. `refetchInterval: 30s` (Visual DNA "sereno").
- `apps/web/src/components/layout/FoundationStatus.tsx` — pill discreto na Topbar (verde/vermelho/cinza) com Tooltip detalhando service+version+workspaceId+requestId+uptime. Pulse `processing` no refetch silencioso. Conscientemente temporário — quando produto entrar em normal, vira indicador só em `/health`.
- **Modificados:**
- `apps/web/vite.config.mts``server.proxy['/api']: http://localhost:3000`. Evita CORS em dev e mantém URL relativa no código da Web; em produção, Nginx roteia mesmo origin. `changeOrigin: false` (mesma host).
- `apps/web/src/components/layout/Topbar.tsx``<FoundationStatus />` antes do sino.
- **Validação ponta-a-ponta:**
- `nx run web:lint` ✅ · `nx run web:build` ✅ (821ms, 309KB gzip)
- `curl :4200/api/v1/ping` via proxy Vite → `200 application/json` com payload contratual completo (`status=ok`, `workspaceId=dev-workspace`, `requestId`, `uptimeSeconds=144`, `now`)
- `curl :4200/api/v1/nope``404 application/problem+json` com `type/title/detail/instance/requestId` — prova que `ApiError` captura erro estruturado quando servidor falhar
- Headers helmet, x-request-id, CORS expose-headers passam pelo proxy intactos
- **Decisão arquitetural confirmada:** Web consome lib `@sar/api-interface` sem arrastar nada do Nest. `PingResponseSchema` viaja como Zod puro; `ApiError` na Web não conhece `HttpException` do Nest — só o contrato HTTP+JSON. Alinhamento com regra "lib stays framework-free" da sessão anterior.
- **Pegadinhas notadas (não bloquearam):**
- `_sidebarOpen` no AppShell ainda tem `eslint-disable` — fica pra Frente D ou primeiro responsivo mobile.
- Bundle Web 976KB (309KB gzip) — code-splitting esperado quando rotas de cockpit virarem separadas (TanStack Router suporta nativo). Não age agora.
- **Pendente próxima sessão (ordem atualizada):**
1. **Frente D — ESLint boundaries** (tags Nx `scope:* · type:* · domain:*`) + Husky + gitleaks. Higiene de PR antes de feature pesada.
2. **PRD WDS** via `/bmad-prd create` antes de modelar domínio.
3. **Master-login stub + WorkspacePrismaPool** (frente arquitetural — depende do PRD).
4. **OpenTelemetry SDK** plugar quando entrar no catálogo.
### 2026-05-27 — Frente D (ESLint boundaries + Husky + commitlint + gitleaks) CONCLUÍDA ✅
- **Escopo da sessão:** Higiene de PR — guardrails de qualidade antes da primeira feature de domínio.
- **D1 — Tags Nx + depConstraints:**
- Tags `scope:api|web|shared`, `type:app|e2e|util`, `domain:shared` adicionadas em todos os 5 projetos (e2e estavam vazios).
- `eslint.config.mjs` depConstraints substituído por regras explícitas em 3 eixos:
- `scope`: api só usa api+shared; web só usa web+shared; shared não importa código de app-scope.
- `type`: apps dependem só de libs (feature/util/data); e2e só do seu app-par + utils; utils são folha.
- `nx run-many --skip-nx-cache` verde em todos os 3 projetos com as novas regras.
- **D2 — Husky + lint-staged:**
- `husky 9` + `lint-staged 17` instalados. `prepare: "husky"` no `package.json`.
- `pre-commit`: `eslint --max-warnings=0` + `prettier --check` só nos arquivos staged (rápido, sem varrer repo inteiro).
- **D3 — commitlint:**
- `@commitlint/cli` + `@commitlint/config-conventional` instalados.
- `commitlint.config.js`: tipo obrigatório, subject lowercase, scope enum como `warn` (não `error` — permite escopos novos sem bloquear), body/footer ilimitados.
- Hook `commit-msg` ativo. Smoke test: mensagem inválida → 2 erros; mensagem válida → pass.
- **D4 — gitleaks via Docker:**
- `.gitleaks.toml` criado com `useDefault = true` + allowlist para `.agents/`, `.claude/`, `tmp/`, `.env.example`, `pnpm-lock.yaml`.
- Iteração em 3 rodadas para zerar falsos positivos: (1) JWTs de exemplo em BMad skills → excluir `.agents/` e `.claude/`; (2) `tmp/gitleaks-report.json` autopoluindo scan → excluir `tmp/` + adicionar ao `.gitignore`; (3) zero leaks no tree completo.
- Pre-commit roda via `docker run --rm -v ... zricethezav/gitleaks:latest`; fallback silencioso se Docker indisponível (socket sem permissão no contexto do hook — comportamento correto; CI usa binário nativo).
- **Pegadinha do commit-msg:** subject-case do commitlint rejeita "F" maiúsculo em "Frente D" — subject deve ser lowercase. Corrigido na mensagem de commit.
- **Pegadinha Docker no hook:** `sg docker` não funciona em hooks não-interativos. Solução: fallback com `echo` + warning, e usar `sudo usermod -aG docker $USER` + logout/login para resolver permanentemente no desktop dev.
- **Pendente próxima sessão (ordem atualizada):**
1. **PRD WDS** via `/bmad-prd create` antes de modelar qualquer domínio. Desbloqueia master-login + WorkspacePrismaPool.
2. **Master-login stub + WorkspacePrismaPool** (frente arquitetural pesada — depende do PRD).
3. **OpenTelemetry SDK** plugar quando entrar no catálogo.
### 2026-05-27 — PRD MVP FINALIZADO ✅ (`status: final`)
- **Workspace:** `_bmad-output/planning-artifacts/prds/prd-sar-2026-05-27/`
- **Artefatos:** `prd.md` (final) + `.decision-log.md` (8 decisões registradas)
- **Working mode:** Fast Path — Julian definiu escopo em uma frase; documentos Phase 1+2 foram fonte de verdade via extração por subagentes.
- **Escopo MVP fechado:** 9 capacidades (C1C9) · 45 FRs · 3 jornadas de usuário · 6 NFRs — cockpits Rafael + Sandra.
- **Cockpits fora do MVP:** Daniel e Alice → telas placeholder apenas.
- **Decisões chave:**
- WhatsApp = Share API nativa (sem Meta Cloud API no MVP)
- ERP = importação manual (CSV/JSON); sem integração automática
- Aprovação de desconto inclusa no MVP (confirmado explicitamente)
- Limite de crédito numérico e inadimplência requerem conexão (não cacheados offline)
- Falha de sync: Pedido retorna com status `falha de sync` + motivo; nunca descartado silenciosamente
- **OQs abertas (6):** OQ-1/OQ-4 são phase-blockers para C2/C4 — dependem do primeiro cliente. OQ-3/OQ-6 são non-blockers.
- **Reviewer gate:** 1 revisor subagente — veredito "Aprovado com Ressalvas"; 3 achados incorporados antes do `final`.
- **Pendente próxima sessão (ordem atualizada):**
1. **OpenTelemetry SDK** plugar quando entrar no catálogo.
2. **Design C2/C4** (Consulta de Clientes + Lançamento de Pedido) — após resolver OQ-1 e OQ-4 com o primeiro cliente.
### 2026-05-27 — Master-login stub + WorkspacePrismaPool COMPLETO ✅
**Entregas (M1M7):**
- **M1 — Prisma 7 config corrigida:** `prisma.config.ts` usa `datasource.url` (não `migrate.adapter`) — API correta do Prisma 7. `prisma migrate dev` e `prisma generate` funcionando. Schema vazio (modelos virão com C2/C3).
- **M2 — WorkspacePrismaPool:** LRU cache (max 10) de `PrismaClient` por `workspaceId`. `getOrCreate(workspaceId, dbUrl)`, `health(k=3)`, `onModuleDestroy`. Usa `@prisma/adapter-pg` + `pg.Pool` por workspace (ADR 0006).
- **M3 — JwtAuthGuard:** Guard global (`APP_GUARD`) com `jose` HS256. Valida Bearer token, popula `req.user` com `{sub, workspace_id, role}`. Atualiza CLS com `workspaceId`, `userId` e `prisma` após validação. `@Public()` decorator para ping/health/dev-auth.
- **M4 — Auth dev stub:** `POST /api/v1/auth/dev/token` — emite JWT HS256 com claims `{sub, workspace_id, role}`. Retorna 404 em produção. Contrato `DevTokenRequestSchema` + `AuthTokenResponseSchema` em `@sar/api-interface`.
- **M5 — WorkspaceModule:** CLS setup simplificado (middleware só define `requestId` + `workspaceId` default). Guard sobrescreve workspace real do JWT. Pool não injetado no middleware (limitação do nestjs-cls `ClsRootModule`).
- **M6 — Health ready:** `WorkspacePoolHealthIndicator` adicionado ao `/health/ready`. Amostra top-3 LRU — nunca O(N). `active: 0` quando nenhum workspace criado ainda.
- **M7 — Smoke test:** API sobe limpo. `/health/live` ✓, `/health/ready` ✓ (pool ativo=0), `/ping` público ✓, `POST /auth/dev/token` emite token com claims corretos.
**Decisões técnicas:**
- `@prisma/client-runtime-utils` adicionado como dependência direta no workspace root (pnpm isolated mode não o hoista automaticamente).
- Guard atualiza CLS depois do middleware (ordem correta NestJS: middleware → guard → handler).
- Pool não injetado no ClsRootAsync devido a limitação de DI do nestjs-cls; guard faz a resolução.
**Pendente próxima sessão:**
1. **OpenTelemetry SDK** plugar quando entrar no catálogo.
2. **C2 ficha do cliente**`ClientDetailPage` (web), endpoint detalhe já existe; precisa de UI.
3. **C3 Consulta de Pedidos Históricos** — modelo `Order` + `OrderItem` no Prisma + endpoint.
### 2026-05-27 — C2 Consulta de Clientes COMPLETO ✅
**OQ-4 resolvida:** Limite de crédito gerenciado no SAR (admin/supervisor define; SAR é fonte da verdade).
**Entregas:**
- **Prisma schema:** modelo `Client` + enum `FinancialStatus`. Migração `20260527225728_add_client` aplicada.
Campos: taxId (unique), endereço (JSON), creditLimit (Decimal, null = não definido), repId, lastOrderAt/Value (desnorm. de Orders), openOrdersCount, erpCode, syncedAt, deletedAt (soft delete).
- **Contratos Zod:** `@sar/api-interface``ClientSummarySchema`, `ClientDetailSchema`, `ClientListResponseSchema`, `ClientListQuerySchema`. `activityStatus` calculado em runtime (não persiste — evita drift).
- **API:** `ClientsModule``GET /api/v1/clients` (list, search, paginação, filtro atividade) + `GET /api/v1/clients/:id`. Rep vê só carteira (`repId = userId`); supervisor/manager/admin vê tudo. `activityStatus` computado de `lastOrderAt` (thresholds: 30d alert, 60d inactive — FR-2.3).
- **Seed dev:** 10 clientes fictícios brasileiros (8 do user-001, 2 do user-002) com dados variados de atividade e situação financeira.
- **Web:** `ClientsPage` (Rafael cockpit) com tabela AntD (busca, filtro atividade, paginação). `DevLogin` para adquirir token em dev. `authStore` (localStorage dev). `/clientes` e `/clientes/:id` no router.
- **Smoke test:** `GET /clients` sem token → 401 ✓. Com token rep user-001 → 8 clientes ✓. Filtro `?status=inactive` → 1 resultado ✓. Busca `?q=padaria` → 1 resultado ✓.
**Pendente próxima sessão:**
1. `ClientDetailPage` — UI da ficha (web); endpoint já existe.
2. C3 — modelo `Order` + `OrderItem` + endpoint `GET /clients/:id/orders`.
3. OpenTelemetry SDK.
---
## About This Folder

View File

@@ -21,10 +21,33 @@ export default [
enforceBuildableLibDependency: true,
allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$'],
depConstraints: [
// ── scope ────────────────────────────────────────────────────────
// api só usa libs api ou shared; web só usa libs web ou shared
{ sourceTag: 'scope:api', onlyDependOnLibsWithTags: ['scope:api', 'scope:shared'] },
{ sourceTag: 'scope:web', onlyDependOnLibsWithTags: ['scope:web', 'scope:shared'] },
// shared não pode importar código de app-scope
{ sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
// ── type ─────────────────────────────────────────────────────────
// apps só dependem de libs (feature/util/data), nunca de outro app
{
sourceTag: '*',
onlyDependOnLibsWithTags: ['*'],
sourceTag: 'type:app',
onlyDependOnLibsWithTags: ['type:feature', 'type:util', 'type:data'],
},
// e2e depende do seu app-par e de utils; nunca de outro app
{
sourceTag: 'type:e2e',
onlyDependOnLibsWithTags: ['type:app', 'type:util'],
},
// features dependem de features, utils e dados — não de apps
{
sourceTag: 'type:feature',
onlyDependOnLibsWithTags: ['type:feature', 'type:util', 'type:data'],
},
// utils são folha — não importam features nem apps
{ sourceTag: 'type:util', onlyDependOnLibsWithTags: ['type:util', 'type:data'] },
// data é camada mais baixa — só pode depender de outra camada data
{ sourceTag: 'type:data', onlyDependOnLibsWithTags: ['type:data'] },
],
},
],

View File

@@ -1 +1,8 @@
export * from './lib/ping.contract';
export * from './lib/auth.contract';
export * from './lib/client.contract';
export * from './lib/order.contract';
export * from './lib/product.contract';
export * from './lib/dashboard.contract';
export * from './lib/notifications.contract';
export * from './lib/company.contract';

View File

@@ -0,0 +1,32 @@
import { z } from 'zod';
// Contrato do auth dev stub — POST /api/v1/auth/dev/token.
// Endpoint existe APENAS em development/test (NODE_ENV !== 'production').
// CODING-RULES PGD-SEC-002: never use dev secret in production.
// ADR 0006 revogado: workspaceId: string → idEmpresa: number (empresa no ERP)
const JwtRoleSchema = z.enum(['rep', 'supervisor', 'manager', 'admin']);
export const DevTokenRequestSchema = z.object({
userId: z.string().min(1),
idEmpresa: z.coerce.number().int().positive(),
role: JwtRoleSchema,
});
export const AuthTokenResponseSchema = z.object({
accessToken: z.string().min(1),
tokenType: z.literal('Bearer'),
expiresIn: z.number().int().positive(),
});
export const UserProfileSchema = z.object({
codVendedor: z.number().int(),
nome: z.string(),
role: JwtRoleSchema,
idEmpresa: z.number().int(),
});
export type DevTokenRequest = z.infer<typeof DevTokenRequestSchema>;
export type AuthTokenResponse = z.infer<typeof AuthTokenResponseSchema>;
export type JwtRole = z.infer<typeof JwtRoleSchema>;
export type UserProfile = z.infer<typeof UserProfileSchema>;

View File

@@ -0,0 +1,64 @@
import { z } from 'zod';
// Contratos canônicos de C2 — Consulta de Clientes.
// Consumidos pela API (output tipado) e pela Web (TanStack Query + parse).
// ADR 0006 revogado: id UUID → idCliente Int (sig.corrent.id_corrent)
// ─── Enums ────────────────────────────────────────────────────────────────────
export const ActivityStatusSchema = z.enum(['active', 'alert', 'inactive']);
export type ActivityStatus = z.infer<typeof ActivityStatusSchema>;
// ─── Client Summary (lista) ───────────────────────────────────────────────────
export const ClientSummarySchema = z.object({
idCliente: z.number().int(),
idEmpresa: z.number().int(),
nome: z.string(),
razao: z.string().nullable(),
cgcpf: z.string().nullable(),
email: z.string().nullable(),
telefone: z.string().nullable(),
codVendedor: z.number().int(),
nomeVendedor: z.string().nullable().optional(),
limiteCreditoStr: z.string().nullable(),
activityStatus: ActivityStatusSchema,
dtUltimaCompra: z.iso.datetime().nullable(),
});
export type ClientSummary = z.infer<typeof ClientSummarySchema>;
// ─── Client Detail (ficha) ───────────────────────────────────────────────────
export const ClientDetailSchema = ClientSummarySchema.extend({
ativo: z.number().int(),
pessoa: z.number().int().nullable(),
inscricaoEstadual: z.string().nullable(),
endereco: z.string().nullable(),
numEndereco: z.string().nullable(),
bairro: z.string().nullable(),
cep: z.string().nullable(),
ddd: z.string().nullable(),
obs: z.string().nullable(),
codPauta: z.number().int().nullable(),
dtCadastro: z.string().nullable(),
dtAtual: z.string().nullable(),
});
export type ClientDetail = z.infer<typeof ClientDetailSchema>;
// ─── List query + response ────────────────────────────────────────────────────
export const ClientListQuerySchema = z.object({
q: z.string().optional(),
status: ActivityStatusSchema.optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
export type ClientListQuery = z.infer<typeof ClientListQuerySchema>;
export const ClientListResponseSchema = z.object({
data: z.array(ClientSummarySchema),
total: z.number().int().nonnegative(),
page: z.number().int().positive(),
limit: z.number().int().positive(),
});
export type ClientListResponse = z.infer<typeof ClientListResponseSchema>;

View File

@@ -0,0 +1,21 @@
import { z } from 'zod';
// Dados legais da empresa matriz (a que fatura o pedido) — cabeçalho do PDF.
// Fonte: gestao.empresa (matriz) + vw_municipios.
export const EmpresaInfoSchema = z.object({
idEmpresa: z.number().int(),
razaoSocial: z.string(),
nomeFantasia: z.string().nullable(),
cnpj: z.string().nullable(),
inscricaoEstadual: z.string().nullable(),
endereco: z.string().nullable(),
numero: z.string().nullable(),
complemento: z.string().nullable(),
bairro: z.string().nullable(),
cidade: z.string().nullable(),
uf: z.string().nullable(),
cep: z.string().nullable(),
telefone: z.string().nullable(),
email: z.string().nullable(),
});
export type EmpresaInfo = z.infer<typeof EmpresaInfoSchema>;

View File

@@ -0,0 +1,79 @@
import { z } from 'zod';
import { PedidoSummarySchema } from './order.contract';
// ADR 0006 revogado: OrderSummary → PedidoSummary, ids numéricos.
export const ClienteInativoSchema = z.object({
idCliente: z.number().int(),
nome: z.string(),
diasSemCompra: z.number().int(),
ultimaCompraValor: z.string().nullable(),
});
export type ClienteInativo = z.infer<typeof ClienteInativoSchema>;
// Dimensão de meta. O ERP (vw_metas.tipo) define como o cliente acompanha metas:
// GL = global, GR = por grupo. Motor único; outras dimensões (marca/subgrupo/
// produto) entram aqui depois sem mudar a forma.
export const MetaDimensaoSchema = z.enum(['global', 'grupo']);
export type MetaDimensao = z.infer<typeof MetaDimensaoSchema>;
// Linha de meta vs realizado por grupo, multi-medida (codigo=null = rollup global).
// fator = R$/kg (valor/peso); o ERP guarda vl_fator na meta.
export const MetaItemSchema = z.object({
codigo: z.number().int().nullable(),
rotulo: z.string(),
pedidos: z.number().int(), // qtd de pedidos faturados no grupo (realizado)
valorMeta: z.number(),
valorReal: z.number(),
qtdMeta: z.number(),
qtdReal: z.number(),
pesoMeta: z.number(),
pesoReal: z.number(),
fatorMeta: z.number(),
fatorReal: z.number(),
pct: z.number(), // % de valor (real/meta) — base da barra de progresso
falta: z.number(), // valor faltante p/ a meta
});
export type MetaItem = z.infer<typeof MetaItemSchema>;
export const RepDashboardSchema = z.object({
meta: z.object({
atingido: z.number(),
total: z.number(),
pct: z.number(),
falta: z.number(),
}),
// Dimensão detectada do ERP e detalhamento por grupo (vazio quando global).
metaDimensao: MetaDimensaoSchema.default('global'),
metasPorGrupo: z.array(MetaItemSchema).default([]),
comissao: z.object({
fixa: z.number(),
flex: z.number(),
total: z.number(),
}),
pedidosMes: z.number().int(),
pedidosRecentes: z.array(PedidoSummarySchema),
clientesInativos: z.array(ClienteInativoSchema),
syncedAt: z.iso.datetime(),
});
export type RepDashboard = z.infer<typeof RepDashboardSchema>;
export const RepInativosSummarySchema = z.object({
codVendedor: z.number().int(),
nomeVendedor: z.string().nullable().optional(),
inativosCount: z.number().int(),
});
export type RepInativosSummary = z.infer<typeof RepInativosSummarySchema>;
export const SupervisorDashboardSchema = z.object({
approvalQueue: z.array(PedidoSummarySchema),
pedidosDia: z.object({
count: z.number().int(),
total: z.number(),
countSemanaAnterior: z.number().int(),
totalSemanaAnterior: z.number(),
}),
inativosPorRep: z.array(RepInativosSummarySchema),
syncedAt: z.iso.datetime(),
});
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
// Contratos canônicos de C6 — Notificações e Push.
export const SubscribePayloadSchema = z.object({
endpoint: z.string().url(),
keys: z.object({
p256dh: z.string().min(1),
auth: z.string().min(1),
}),
});
export type SubscribePayload = z.infer<typeof SubscribePayloadSchema>;
export const PendingCountResponseSchema = z.object({
count: z.number().int().min(0),
});
export type PendingCountResponse = z.infer<typeof PendingCountResponseSchema>;

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