Compare commits
38 Commits
4649289213
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f16dc8274 | |||
| 6fbf8bfb8e | |||
| a3c68f9f05 | |||
| 1647871a39 | |||
| 70d5a2d1e4 | |||
| 2d4f342697 | |||
| fb6df551b7 | |||
| 7fad03475e | |||
| a00a5c6a53 | |||
| 20b0793227 | |||
| f9d5f8a84c | |||
| e7cbadcf7e | |||
| 1f8a9d872a | |||
| 24408ecd83 | |||
| 2abe5e8697 | |||
| f41d9c2f16 | |||
| 6cdb4c578e | |||
| f363d22d90 | |||
| b0b60d7a14 | |||
| 246eb28bb1 | |||
| a1a852c44d | |||
| e3587e680a | |||
| da2f1020d1 | |||
| 56ca650962 | |||
| acab5b8a55 | |||
| a9c15ac4ec | |||
| 93bf906eec | |||
| 36103eaa87 | |||
| 6028bf1ba9 | |||
| 356c8e3c2c | |||
| 6769a0d82a | |||
| c36451dd33 | |||
| 14c8350216 | |||
| 2a8be3fd82 | |||
| bca2e3ebb3 | |||
| 70ecfdc927 | |||
| fdbf40cd1a | |||
| 29321f54c0 |
@@ -45,6 +45,14 @@ OTEL_TRACES_SAMPLER=parentbased_traceidratio
|
|||||||
OTEL_TRACES_SAMPLER_ARG=1.0
|
OTEL_TRACES_SAMPLER_ARG=1.0
|
||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
|
|
||||||
|
# Web Push VAPID (C6) — gerar com: node -e "const wp=require('web-push'); const k=wp.generateVAPIDKeys(); console.log(k)"
|
||||||
|
# Em prod: Vault injeta. Em dev: opcional — push fica desabilitado se vazio.
|
||||||
|
VAPID_PUBLIC_KEY=
|
||||||
|
VAPID_PRIVATE_KEY=
|
||||||
|
VAPID_CONTACT=mailto:noreply@sar.dev
|
||||||
|
# Chave pública VAPID para o front-end (mesmo valor de VAPID_PUBLIC_KEY)
|
||||||
|
VITE_VAPID_PUBLIC_KEY=
|
||||||
|
|
||||||
# Feature flags (DEV: bypass. Prod: GrowthBook self-host)
|
# Feature flags (DEV: bypass. Prod: GrowthBook self-host)
|
||||||
GROWTHBOOK_API_HOST=http://localhost:3100
|
GROWTHBOOK_API_HOST=http://localhost:3100
|
||||||
GROWTHBOOK_CLIENT_KEY=
|
GROWTHBOOK_CLIENT_KEY=
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
out
|
out
|
||||||
|
tmp
|
||||||
.nx
|
.nx
|
||||||
.next
|
.next
|
||||||
.turbo
|
.turbo
|
||||||
|
|||||||
32
.gitleaks.toml
Normal file
32
.gitleaks.toml
Normal 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
2
.husky/commit-msg
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
pnpm exec commitlint --edit "$1"
|
||||||
21
.husky/pre-commit
Executable file
21
.husky/pre-commit
Executable 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
1
.npmrc
@@ -6,4 +6,5 @@ shamefully-hoist=false
|
|||||||
public-hoist-pattern[]=*types*
|
public-hoist-pattern[]=*types*
|
||||||
public-hoist-pattern[]=*eslint*
|
public-hoist-pattern[]=*eslint*
|
||||||
public-hoist-pattern[]=*prettier*
|
public-hoist-pattern[]=*prettier*
|
||||||
|
public-hoist-pattern[]=@prisma/client-runtime-utils
|
||||||
node-linker=isolated
|
node-linker=isolated
|
||||||
|
|||||||
@@ -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`.
|
||||||
348
_bmad-output/planning-artifacts/architecture.md
Normal file
348
_bmad-output/planning-artifacts/architecture.md
Normal 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.*
|
||||||
@@ -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 |
|
||||||
416
_bmad-output/planning-artifacts/prds/prd-sar-2026-05-27/prd.md
Normal file
416
_bmad-output/planning-artifacts/prds/prd-sar-2026-05-27/prd.md
Normal 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 5–50 Representantes externos. Substitui o app Android/Desktop legado da JCS e resolve a dor central: **donos e supervisores decidem no escuro** enquanto 5–10% 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 2–3 anos antes de concorrentes se modernizarem. O primeiro cliente pagante em 3–4 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)
|
||||||
|
- 10–20 clientes pagantes até o mês 12
|
||||||
|
- ARR R$ 200k–600k
|
||||||
|
- 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:** 30–50 anos, vendedor B2B externo, comissionado, atende 50–200 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:** 35–55 anos, gerente comercial, coordena 5–30 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` = 30–60 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 (7h–21h BRT, seg–sáb).
|
||||||
|
|
||||||
|
**NFR-6.2** Janela de manutenção: domingos das 2h–6h 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.*
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "api-e2e",
|
"name": "api-e2e",
|
||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
|
"tags": ["scope:api", "type:e2e", "domain:shared"],
|
||||||
"implicitDependencies": ["api"],
|
"implicitDependencies": ["api"],
|
||||||
"targets": {
|
"targets": {
|
||||||
"e2e": {
|
"e2e": {
|
||||||
|
|||||||
@@ -2,5 +2,14 @@
|
|||||||
"name": "@sar/api",
|
"name": "@sar/api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "SAR · API (NestJS 11 — CommonJS conforme CODING-RULES.md PGD-DB-004)"
|
"description": "SAR · API (NestJS 11 — CommonJS conforme CODING-RULES.md PGD-DB-004)",
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"web-push": "^3.6.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/web-push": "^3.6.4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
apps/api/prisma.config.ts
Normal file
20
apps/api/prisma.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
@@ -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");
|
||||||
@@ -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");
|
||||||
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
3
apps/api/prisma/migrations/migration_lock.toml
Normal 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"
|
||||||
169
apps/api/prisma/schema.prisma
Normal file
169
apps/api/prisma/schema.prisma
Normal 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
1137
apps/api/prisma/seed.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,40 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
import { APP_FILTER, APP_GUARD, APP_PIPE } from '@nestjs/core';
|
||||||
import { ZodValidationPipe } from 'nestjs-zod';
|
import { ZodValidationPipe } from 'nestjs-zod';
|
||||||
import { EnvModule } from './config/env.module';
|
import { EnvModule } from './config/env.module';
|
||||||
import { LoggerModule } from './logger/logger.module';
|
import { LoggerModule } from './logger/logger.module';
|
||||||
import { WorkspaceModule } from './workspace/workspace.module';
|
import { WorkspaceModule } from './workspace/workspace.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { PingModule } from './ping/ping.module';
|
import { PingModule } from './ping/ping.module';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||||
|
import { ClientsModule } from './clients/clients.module';
|
||||||
|
import { OrdersModule } from './orders/orders.module';
|
||||||
|
import { CatalogModule } from './catalog/catalog.module';
|
||||||
|
import { DashboardModule } from './dashboard/dashboard.module';
|
||||||
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
// Ordem importa: Env primeiro (fail-fast), depois Logger, CLS, módulos de domínio.
|
// Ordem: Env primeiro (fail-fast), depois Logger, CLS, Auth, módulos de domínio.
|
||||||
EnvModule,
|
EnvModule,
|
||||||
LoggerModule,
|
LoggerModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
|
AuthModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
PingModule,
|
PingModule,
|
||||||
|
ClientsModule,
|
||||||
|
OrdersModule,
|
||||||
|
CatalogModule,
|
||||||
|
DashboardModule,
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Pipe global: nestjs-zod converte ZodSchema (via createZodDto) em validação automática.
|
|
||||||
// CODING-RULES §06: schema é o contrato; DTO é a classe que o expõe.
|
|
||||||
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
||||||
// Filter global: RFC 9457. Zod → 422.
|
|
||||||
{ provide: APP_FILTER, useClass: ProblemDetailsFilter },
|
{ provide: APP_FILTER, useClass: ProblemDetailsFilter },
|
||||||
|
// Guard global — exige JWT em todas as rotas exceto as com @Public().
|
||||||
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
34
apps/api/src/app/auth/auth.controller.ts
Normal file
34
apps/api/src/app/auth/auth.controller.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/app/auth/auth.module.ts
Normal file
13
apps/api/src/app/auth/auth.module.ts
Normal 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 {}
|
||||||
47
apps/api/src/app/auth/dev-auth.controller.ts
Normal file
47
apps/api/src/app/auth/dev-auth.controller.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
89
apps/api/src/app/auth/jwt-auth.guard.ts
Normal file
89
apps/api/src/app/auth/jwt-auth.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/api/src/app/auth/jwt.types.ts
Normal file
18
apps/api/src/app/auth/jwt.types.ts
Normal 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;
|
||||||
|
}
|
||||||
6
apps/api/src/app/auth/public.decorator.ts
Normal file
6
apps/api/src/app/auth/public.decorator.ts
Normal 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);
|
||||||
49
apps/api/src/app/catalog/catalog.controller.ts
Normal file
49
apps/api/src/app/catalog/catalog.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/app/catalog/catalog.module.ts
Normal file
10
apps/api/src/app/catalog/catalog.module.ts
Normal 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 {}
|
||||||
340
apps/api/src/app/catalog/catalog.service.ts
Normal file
340
apps/api/src/app/catalog/catalog.service.ts
Normal 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',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
27
apps/api/src/app/clients/clients.controller.ts
Normal file
27
apps/api/src/app/clients/clients.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/app/clients/clients.module.ts
Normal file
9
apps/api/src/app/clients/clients.module.ts
Normal 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 {}
|
||||||
239
apps/api/src/app/clients/clients.service.ts
Normal file
239
apps/api/src/app/clients/clients.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,10 @@ export const EnvSchema = z
|
|||||||
API_PORT: z.coerce.number().int().positive().default(3000),
|
API_PORT: z.coerce.number().int().positive().default(3000),
|
||||||
API_HOST: z.string().min(1).default('0.0.0.0'),
|
API_HOST: z.string().min(1).default('0.0.0.0'),
|
||||||
API_GLOBAL_PREFIX: z.string().min(1).default('api'),
|
API_GLOBAL_PREFIX: z.string().min(1).default('api'),
|
||||||
API_VERSION: z.string().regex(/^v\d+$/).default('v1'),
|
API_VERSION: z
|
||||||
|
.string()
|
||||||
|
.regex(/^v\d+$/)
|
||||||
|
.default('v1'),
|
||||||
|
|
||||||
// CORS — origens permitidas (Web em dev: http://localhost:4200)
|
// CORS — origens permitidas (Web em dev: http://localhost:4200)
|
||||||
CORS_ORIGINS: z
|
CORS_ORIGINS: z
|
||||||
@@ -28,13 +31,20 @@ export const EnvSchema = z
|
|||||||
|
|
||||||
// Master-login (DEV stub — IdP real virá na próxima sessão)
|
// Master-login (DEV stub — IdP real virá na próxima sessão)
|
||||||
MASTER_LOGIN_URL: z.url().default('http://localhost:3000/auth/dev'),
|
MASTER_LOGIN_URL: z.url().default('http://localhost:3000/auth/dev'),
|
||||||
MASTER_LOGIN_JWT_SECRET: z.string().min(32).default('dev_jwt_secret_change_in_prod_use_vault_xxxxx'),
|
MASTER_LOGIN_JWT_SECRET: z
|
||||||
|
.string()
|
||||||
|
.min(32)
|
||||||
|
.default('dev_jwt_secret_change_in_prod_use_vault_xxxxx'),
|
||||||
JWT_ACCESS_EXPIRATION: z.coerce.number().int().positive().default(900),
|
JWT_ACCESS_EXPIRATION: z.coerce.number().int().positive().default(900),
|
||||||
JWT_REFRESH_EXPIRATION: z.coerce.number().int().positive().default(2_592_000),
|
JWT_REFRESH_EXPIRATION: z.coerce.number().int().positive().default(2_592_000),
|
||||||
|
|
||||||
// Multi-tenancy — workspace de dev (até master-login real entrar)
|
// Multi-tenancy — workspace de dev (até master-login real entrar)
|
||||||
DEFAULT_WORKSPACE_ID: z.string().min(1).default('dev-workspace'),
|
DEFAULT_WORKSPACE_ID: z.string().min(1).default('dev-workspace'),
|
||||||
|
|
||||||
|
// Representante fixo de dev — forçado no guard enquanto não há login real
|
||||||
|
DEV_REP_CODE: z.coerce.number().int().positive().default(29),
|
||||||
|
DEV_EMPRESA_ID: z.coerce.number().int().positive().default(1),
|
||||||
|
|
||||||
// Postgres (Prisma virá depois)
|
// Postgres (Prisma virá depois)
|
||||||
DATABASE_URL: z.string().optional(),
|
DATABASE_URL: z.string().optional(),
|
||||||
MIGRATION_DATABASE_URL: z.string().optional(),
|
MIGRATION_DATABASE_URL: z.string().optional(),
|
||||||
@@ -61,6 +71,11 @@ export const EnvSchema = z
|
|||||||
OTEL_TRACES_SAMPLER_ARG: z.coerce.number().min(0).max(1).default(1.0),
|
OTEL_TRACES_SAMPLER_ARG: z.coerce.number().min(0).max(1).default(1.0),
|
||||||
SENTRY_DSN: z.string().optional(),
|
SENTRY_DSN: z.string().optional(),
|
||||||
|
|
||||||
|
// Web Push VAPID (C6) — gerado via web-push generateVAPIDKeys()
|
||||||
|
VAPID_PUBLIC_KEY: z.string().optional(),
|
||||||
|
VAPID_PRIVATE_KEY: z.string().optional(),
|
||||||
|
VAPID_CONTACT: z.string().default('mailto:noreply@sar.dev'),
|
||||||
|
|
||||||
// Feature flags
|
// Feature flags
|
||||||
GROWTHBOOK_API_HOST: z.string().optional(),
|
GROWTHBOOK_API_HOST: z.string().optional(),
|
||||||
GROWTHBOOK_CLIENT_KEY: z.string().optional(),
|
GROWTHBOOK_CLIENT_KEY: z.string().optional(),
|
||||||
@@ -74,11 +89,16 @@ export const EnvSchema = z
|
|||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: 'custom',
|
code: 'custom',
|
||||||
path: ['MASTER_LOGIN_JWT_SECRET'],
|
path: ['MASTER_LOGIN_JWT_SECRET'],
|
||||||
message: 'JWT secret de DEV não pode ser usada em produção (CODING-RULES §08, PGD-SEC-002).',
|
message:
|
||||||
|
'JWT secret de DEV não pode ser usada em produção (CODING-RULES §08, PGD-SEC-002).',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!env.DATABASE_URL) {
|
if (!env.DATABASE_URL) {
|
||||||
ctx.addIssue({ code: 'custom', path: ['DATABASE_URL'], message: 'obrigatório em produção' });
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['DATABASE_URL'],
|
||||||
|
message: 'obrigatório em produção',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
23
apps/api/src/app/dashboard/dashboard.controller.ts
Normal file
23
apps/api/src/app/dashboard/dashboard.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/app/dashboard/dashboard.module.ts
Normal file
9
apps/api/src/app/dashboard/dashboard.module.ts
Normal 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 {}
|
||||||
417
apps/api/src/app/dashboard/dashboard.service.ts
Normal file
417
apps/api/src/app/dashboard/dashboard.service.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,22 +5,22 @@ import {
|
|||||||
HealthCheckService,
|
HealthCheckService,
|
||||||
MemoryHealthIndicator,
|
MemoryHealthIndicator,
|
||||||
} from '@nestjs/terminus';
|
} from '@nestjs/terminus';
|
||||||
|
import { Public } from '../auth/public.decorator';
|
||||||
|
import { WorkspacePoolHealthIndicator } from './workspace-pool.health-indicator';
|
||||||
|
|
||||||
// CODING-RULES §20 (PGD-OBS-003):
|
// CODING-RULES §20 (PGD-OBS-003):
|
||||||
// /health/live → liveness só com memory.checkHeap(350MB).
|
// /health/live → liveness: memory.checkHeap(350MB).
|
||||||
// /health/ready → readiness pinga master-login + amostra LRU (K=3) dos pools
|
// /health/ready → readiness: heap + amostra LRU (K=3) do WorkspacePrismaPool.
|
||||||
// quentes do WorkspacePrismaPool + Valkey + BullMQ.
|
// Próximos: MasterLoginHealthIndicator, ValkeyHealthIndicator, BullMQHealthIndicator.
|
||||||
// NUNCA percorrer todos os workspaces (O(N) → false negative).
|
// NUNCA percorrer todos os workspaces (O(N)).
|
||||||
//
|
|
||||||
// Hoje o "ready" só checa heap, idêntico ao live. Quando master-login,
|
|
||||||
// WorkspacePrismaPool, Valkey e BullMQ entrarem, cada um adiciona seu indicator
|
|
||||||
// aqui — sem nunca virar O(N) sobre workspaces.
|
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Controller({ path: 'health' })
|
@Controller({ path: 'health' })
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly health: HealthCheckService,
|
private readonly health: HealthCheckService,
|
||||||
private readonly memory: MemoryHealthIndicator,
|
private readonly memory: MemoryHealthIndicator,
|
||||||
|
private readonly workspacePool: WorkspacePoolHealthIndicator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('live')
|
@Get('live')
|
||||||
@@ -32,11 +32,9 @@ export class HealthController {
|
|||||||
@Get('ready')
|
@Get('ready')
|
||||||
@HealthCheck()
|
@HealthCheck()
|
||||||
ready(): Promise<HealthCheckResult> {
|
ready(): Promise<HealthCheckResult> {
|
||||||
// Skeleton: por enquanto idêntico ao live. Próximas frentes:
|
return this.health.check([
|
||||||
// - MasterLoginHealthIndicator (obrigatório)
|
() => this.memory.checkHeap('heap', 350 * 1024 * 1024),
|
||||||
// - WorkspacePoolLruHealthIndicator (K=3 amostra)
|
() => this.workspacePool.check('workspace_pool', 3),
|
||||||
// - ValkeyHealthIndicator
|
]);
|
||||||
// - BullMQHealthIndicator
|
|
||||||
return this.health.check([() => this.memory.checkHeap('heap', 350 * 1024 * 1024)]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TerminusModule } from '@nestjs/terminus';
|
import { TerminusModule } from '@nestjs/terminus';
|
||||||
|
import { WorkspaceModule } from '../workspace/workspace.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
import { WorkspacePoolHealthIndicator } from './workspace-pool.health-indicator';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TerminusModule],
|
imports: [TerminusModule, WorkspaceModule],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
|
providers: [WorkspacePoolHealthIndicator],
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
export class HealthModule {}
|
||||||
|
|||||||
35
apps/api/src/app/health/workspace-pool.health-indicator.ts
Normal file
35
apps/api/src/app/health/workspace-pool.health-indicator.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
apps/api/src/app/notifications/notifications.controller.ts
Normal file
29
apps/api/src/app/notifications/notifications.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/src/app/notifications/notifications.module.ts
Normal file
11
apps/api/src/app/notifications/notifications.module.ts
Normal 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 {}
|
||||||
93
apps/api/src/app/notifications/notifications.service.ts
Normal file
93
apps/api/src/app/notifications/notifications.service.ts
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/api/src/app/notifications/push.service.ts
Normal file
51
apps/api/src/app/notifications/push.service.ts
Normal 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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
apps/api/src/app/orders/orders.controller.ts
Normal file
92
apps/api/src/app/orders/orders.controller.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import {
|
||||||
|
AprovarPedidoSchema,
|
||||||
|
CreatePedidoSchema,
|
||||||
|
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('erp/:idPedido')
|
||||||
|
findOneErp(@Param('idPedido', ParseIntPipe) idPedido: number): Promise<PedidoDetail> {
|
||||||
|
return this.orders.findOneErp(idPedido);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
|
||||||
|
return this.orders.findOne(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/app/orders/orders.module.ts
Normal file
12
apps/api/src/app/orders/orders.module.ts
Normal 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 {}
|
||||||
725
apps/api/src/app/orders/orders.service.ts
Normal file
725
apps/api/src/app/orders/orders.service.ts
Normal file
@@ -0,0 +1,725 @@
|
|||||||
|
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(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOneErp(idPedido: number): Promise<PedidoDetail> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const role = this.cls.get('role');
|
||||||
|
const userId = this.cls.get('userId') ?? '0';
|
||||||
|
const codVendedor = parseInt(userId, 10);
|
||||||
|
|
||||||
|
interface ErpHeader {
|
||||||
|
id_pedido: number;
|
||||||
|
num_ped_sar: string;
|
||||||
|
numero: number;
|
||||||
|
id_cliente: number;
|
||||||
|
cod_vendedor: number;
|
||||||
|
situa: number;
|
||||||
|
status_descr: string;
|
||||||
|
dt_pedido: Date;
|
||||||
|
total_produtos: string;
|
||||||
|
total_ipi: string;
|
||||||
|
total_icmsst: string;
|
||||||
|
total: string;
|
||||||
|
desconto_perc: string;
|
||||||
|
desconto_valor: string;
|
||||||
|
acrescimo: string;
|
||||||
|
comissao: string;
|
||||||
|
ped_flex: string;
|
||||||
|
obs: string | null;
|
||||||
|
forma_pagamento: string | null;
|
||||||
|
nome_cliente: string | null;
|
||||||
|
razao_cliente: string | null;
|
||||||
|
nome_vendedor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErpItem {
|
||||||
|
ordem: number;
|
||||||
|
id_produto: number;
|
||||||
|
codigo: string | null;
|
||||||
|
descricao: string | null;
|
||||||
|
qtd: string;
|
||||||
|
preco_unitario: string;
|
||||||
|
desconto_perc: string;
|
||||||
|
total: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendedorFilter = role === 'rep' ? `AND e.cod_vendedor = ${codVendedor}` : '';
|
||||||
|
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
|
||||||
|
const [headerRows, itemRows] = await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<ErpHeader[]>(`
|
||||||
|
SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor,
|
||||||
|
e.situa, e.status_descr, e.dt_pedido,
|
||||||
|
e.total_produtos::text, e.total_ipi::text, e.total_icmsst::text,
|
||||||
|
e.total::text, e.desconto_perc::text, e.desconto_valor::text,
|
||||||
|
e.acrescimo::text, e.comissao::text, e.ped_flex::text, e.obs,
|
||||||
|
TRIM(e.forma_pagamento) AS forma_pagamento,
|
||||||
|
c.nome AS nome_cliente, c.razao AS razao_cliente,
|
||||||
|
(SELECT r.nome FROM vw_representantes r
|
||||||
|
WHERE r.codigo = e.cod_vendedor LIMIT 1) AS nome_vendedor
|
||||||
|
FROM vw_pedidos_erp e
|
||||||
|
LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente
|
||||||
|
WHERE e.id_empresa = ${idEmpresa}
|
||||||
|
AND e.id_pedido = ${idPedido}
|
||||||
|
${vendedorFilter}
|
||||||
|
LIMIT 1
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<ErpItem[]>(`
|
||||||
|
SELECT ei.ordem, ei.id_produto,
|
||||||
|
TRIM(p.codigo) AS codigo, TRIM(p.descricao) AS descricao,
|
||||||
|
ei.qtd::text, ei.preco_unitario::text,
|
||||||
|
ei.desconto_perc::text, ei.total::text
|
||||||
|
FROM vw_peditens_erp ei
|
||||||
|
LEFT JOIN vw_produtos p
|
||||||
|
ON p.id_erp = ei.id_produto
|
||||||
|
AND p.id_empresa = ${idMatriz}
|
||||||
|
WHERE ei.id_pedido = ${idPedido}
|
||||||
|
ORDER BY ei.ordem
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!headerRows[0]) throw new NotFoundException(`Pedido ERP ${idPedido} não encontrado`);
|
||||||
|
const h = headerRows[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `erp-${h.id_pedido}`,
|
||||||
|
numPedSar: (h.num_ped_sar ?? '').trim(),
|
||||||
|
numero: Number(h.numero),
|
||||||
|
idCliente: Number(h.id_cliente),
|
||||||
|
nomeCliente: h.nome_cliente ?? null,
|
||||||
|
razaoCliente: h.razao_cliente ?? null,
|
||||||
|
codVendedor: Number(h.cod_vendedor),
|
||||||
|
nomeVendedor: h.nome_vendedor ?? null,
|
||||||
|
situa: sigToSar(Number(h.situa)),
|
||||||
|
statusDescr: h.status_descr,
|
||||||
|
dtPedido: new Date(h.dt_pedido).toISOString(),
|
||||||
|
total: h.total ?? '0',
|
||||||
|
descontoPerc: h.desconto_perc ?? '0',
|
||||||
|
obs: h.obs?.trim() || null,
|
||||||
|
createdAt: new Date(h.dt_pedido).toISOString(),
|
||||||
|
updatedAt: new Date(h.dt_pedido).toISOString(),
|
||||||
|
fonte: 'erp' as const,
|
||||||
|
formaPagamento: h.forma_pagamento || null,
|
||||||
|
totalProdutos: h.total_produtos ?? '0',
|
||||||
|
totalIpi: h.total_ipi ?? '0',
|
||||||
|
totalIcmsst: h.total_icmsst ?? '0',
|
||||||
|
descontoValor: h.desconto_valor ?? '0',
|
||||||
|
acrescimo: h.acrescimo ?? '0',
|
||||||
|
comissao: h.comissao ?? '0',
|
||||||
|
pedFlex: h.ped_flex ?? '0',
|
||||||
|
aprovadoPor: null,
|
||||||
|
aprovadoEm: null,
|
||||||
|
motivoRecusa: null,
|
||||||
|
idempotencyKey: null,
|
||||||
|
itens: itemRows.map((it) => ({
|
||||||
|
id: `${idPedido}-${it.ordem}`,
|
||||||
|
idProduto: Number(it.id_produto),
|
||||||
|
codProduto: it.codigo ?? null,
|
||||||
|
descProduto: it.descricao ?? null,
|
||||||
|
ordem: Number(it.ordem),
|
||||||
|
qtd: it.qtd,
|
||||||
|
precoUnitario: it.preco_unitario,
|
||||||
|
descontoPerc: it.desconto_perc,
|
||||||
|
total: it.total,
|
||||||
|
})),
|
||||||
|
historico: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Controller, Get } from '@nestjs/common';
|
|||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import type { PingResponse } from '@sar/api-interface';
|
import type { PingResponse } from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
import { Public } from '../auth/public.decorator';
|
||||||
|
|
||||||
// Endpoint de verificação de fundação:
|
// Endpoint de verificação de fundação:
|
||||||
// - confirma que CLS está populando workspaceId + requestId;
|
// - confirma que CLS está populando workspaceId + requestId;
|
||||||
@@ -9,6 +10,7 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
|||||||
// - usado pela Web (Frente B) para validar conectividade real.
|
// - usado pela Web (Frente B) para validar conectividade real.
|
||||||
// Contrato: @sar/api-interface · PingResponseSchema (zod).
|
// Contrato: @sar/api-interface · PingResponseSchema (zod).
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Controller({ path: 'ping' })
|
@Controller({ path: 'ping' })
|
||||||
export class PingController {
|
export class PingController {
|
||||||
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
@@ -19,7 +21,7 @@ export class PingController {
|
|||||||
status: 'ok',
|
status: 'ok',
|
||||||
service: 'sar-api',
|
service: 'sar-api',
|
||||||
version: process.env['npm_package_version'] ?? '0.1.0',
|
version: process.env['npm_package_version'] ?? '0.1.0',
|
||||||
workspaceId: this.cls.get('workspaceId'),
|
idEmpresa: this.cls.get('idEmpresa'),
|
||||||
requestId: this.cls.get('requestId'),
|
requestId: this.cls.get('requestId'),
|
||||||
uptimeSeconds: Math.round(process.uptime()),
|
uptimeSeconds: Math.round(process.uptime()),
|
||||||
now: new Date().toISOString(),
|
now: new Date().toISOString(),
|
||||||
|
|||||||
86
apps/api/src/app/workspace/workspace-prisma-pool.service.ts
Normal file
86
apps/api/src/app/workspace/workspace-prisma-pool.service.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +1,48 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ClsModule } from 'nestjs-cls';
|
import { ClsModule } from 'nestjs-cls';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { WorkspaceClsStore } from './workspace.types';
|
import type { WorkspaceClsStore } from './workspace.types';
|
||||||
import type { Env } from '../config/env.schema';
|
import { WorkspacePrismaPool } from './workspace-prisma-pool.service';
|
||||||
|
|
||||||
// CLS popula contexto por request. Hoje: requestId + DEFAULT_WORKSPACE_ID do env.
|
// CLS middleware roda ANTES dos guards (ordem NestJS).
|
||||||
// Amanhã: workspaceId vem do JWT (PGD-AUTHZ-002); `prisma` é resolvido pelo
|
// Aqui: apenas requestId + idEmpresa default (0 = não autenticado).
|
||||||
// WorkspacePrismaPool e injetado via cls.set('prisma', ...) aqui mesmo.
|
// JwtAuthGuard atualiza idEmpresa, userId e prisma após validar o token.
|
||||||
|
// CODING-RULES PGD-DB-009: prisma via cls.get('prisma'), nunca singleton.
|
||||||
|
// CODING-RULES PGD-AUTHZ-002: idEmpresa real vem do JWT (guard), não do env.
|
||||||
|
// ADR 0006 revogado: workspaceId: string → idEmpresa: number
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ClsModule.forRootAsync({
|
ClsModule.forRootAsync({
|
||||||
global: true,
|
global: true,
|
||||||
imports: [ConfigModule],
|
useFactory: () => ({
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (config: ConfigService<Env, true>) => ({
|
|
||||||
middleware: {
|
middleware: {
|
||||||
mount: true,
|
mount: true,
|
||||||
generateId: true,
|
generateId: true,
|
||||||
idGenerator: (req: Request) => {
|
idGenerator: (req: Request) => {
|
||||||
// Prioridade: req.id (pino-http já gerou/leu header) > header bruto > novo UUID.
|
|
||||||
const fromPino = (req as Request & { id?: unknown }).id;
|
const fromPino = (req as Request & { id?: unknown }).id;
|
||||||
if (typeof fromPino === 'string' && fromPino.length > 0) return fromPino;
|
if (typeof fromPino === 'string' && fromPino.length > 0) return fromPino;
|
||||||
const headerVal = req.headers['x-request-id'];
|
const headerVal = req.headers['x-request-id'];
|
||||||
return typeof headerVal === 'string' && headerVal.length > 0
|
return typeof headerVal === 'string' && headerVal.length > 0 ? headerVal : randomUUID();
|
||||||
? headerVal
|
|
||||||
: randomUUID();
|
|
||||||
},
|
},
|
||||||
setup: (cls, req: Request, res: Response) => {
|
setup: (cls, req: Request, res: Response) => {
|
||||||
const store = cls as unknown as {
|
const store = cls as unknown as {
|
||||||
set: <K extends keyof WorkspaceClsStore>(key: K, value: WorkspaceClsStore[K]) => void;
|
set: <K extends keyof WorkspaceClsStore>(key: K, value: WorkspaceClsStore[K]) => void;
|
||||||
getId: () => string;
|
getId: () => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestId = store.getId();
|
const requestId = store.getId();
|
||||||
res.setHeader('x-request-id', requestId);
|
res.setHeader('x-request-id', requestId);
|
||||||
store.set('requestId', requestId);
|
store.set('requestId', requestId);
|
||||||
store.set('workspaceId', config.get('DEFAULT_WORKSPACE_ID', { infer: true }));
|
// Fallback para rotas públicas (ping, health). Guard sobrescreve em rotas protegidas.
|
||||||
|
store.set('idEmpresa', 0);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
exports: [ClsModule],
|
providers: [WorkspacePrismaPool],
|
||||||
|
exports: [ClsModule, WorkspacePrismaPool],
|
||||||
})
|
})
|
||||||
export class WorkspaceModule {}
|
export class WorkspaceModule {}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import type { ClsStore } from 'nestjs-cls';
|
import type { ClsStore } from 'nestjs-cls';
|
||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
import type { JwtRole } from '../auth/jwt.types';
|
||||||
|
|
||||||
// Forma do CLS store por request — fonte da verdade para qualquer caller
|
// Forma do CLS store por request — fonte da verdade para qualquer caller.
|
||||||
// que faça `cls.get(...)`. Quando o PrismaClient por workspace entrar
|
// CODING-RULES PGD-DB-009: nunca importe PrismaClient diretamente; use cls.get('prisma').
|
||||||
// (ADR 0006), `prisma` virará obrigatório aqui — por hora segue opcional.
|
// CODING-RULES PGD-AUTHZ-002: idEmpresa vem sempre do JWT, nunca de body/param/query.
|
||||||
|
// ADR 0006 revogado: workspaceId: string → idEmpresa: number
|
||||||
|
|
||||||
export interface WorkspaceClsStore extends ClsStore {
|
export interface WorkspaceClsStore extends ClsStore {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
workspaceId: string;
|
idEmpresa: number; // era workspaceId: string — agora Int da empresa no ERP
|
||||||
// userId virá quando master-login estiver plugado.
|
userId?: string; // cod_vendedor como string; preenchido pelo JwtAuthGuard
|
||||||
userId?: string;
|
role?: JwtRole; // preenchido pelo JwtAuthGuard após validar o token
|
||||||
// prisma: PrismaClient — adicionar quando WorkspacePrismaPool entrar.
|
prisma?: PrismaClient; // preenchido pelo JwtAuthGuard via WorkspacePrismaPool
|
||||||
}
|
}
|
||||||
|
|||||||
1
apps/api/tsconfig.tsbuildinfo
Normal file
1
apps/api/tsconfig.tsbuildinfo
Normal 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"}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"sourceRoot": "apps/web-e2e/src",
|
"sourceRoot": "apps/web-e2e/src",
|
||||||
"tags": [],
|
"tags": ["scope:web", "type:e2e", "domain:shared"],
|
||||||
"implicitDependencies": ["web"],
|
"implicitDependencies": ["web"],
|
||||||
"// targets": "to see all targets run: nx show project web-e2e --web",
|
"// targets": "to see all targets run: nx show project web-e2e --web",
|
||||||
"targets": {}
|
"targets": {}
|
||||||
|
|||||||
123
apps/web/public/sw.js
Normal file
123
apps/web/public/sw.js
Normal 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));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
135
apps/web/src/cockpits/rep/CatalogPage.tsx
Normal file
135
apps/web/src/cockpits/rep/CatalogPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
apps/web/src/cockpits/rep/ClientDetailPage.tsx
Normal file
137
apps/web/src/cockpits/rep/ClientDetailPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1577
apps/web/src/cockpits/rep/ClientsPage.tsx
Normal file
1577
apps/web/src/cockpits/rep/ClientsPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
783
apps/web/src/cockpits/rep/NewOrderPage.tsx
Normal file
783
apps/web/src/cockpits/rep/NewOrderPage.tsx
Normal 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 }}>
|
||||||
|
Nº 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
517
apps/web/src/cockpits/rep/OrderDetailPage.tsx
Normal file
517
apps/web/src/cockpits/rep/OrderDetailPage.tsx
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
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 isErp = order?.fonte === 'erp';
|
||||||
|
const canAct = !isErp && role !== 'rep' && order?.situa === 1;
|
||||||
|
const canTransmit = !isErp && role === 'rep' && order?.situa === 0;
|
||||||
|
const canShare =
|
||||||
|
role === 'rep' &&
|
||||||
|
(order?.situa === 2 || order?.situa === 4) &&
|
||||||
|
typeof navigator !== 'undefined' &&
|
||||||
|
!!navigator.share;
|
||||||
|
|
||||||
|
const [approveOpen, setApproveOpen] = useState(false);
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
const [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>
|
||||||
|
)}
|
||||||
|
{order.formaPagamento && (
|
||||||
|
<Descriptions.Item label="Cond. Pagamento" span={2}>
|
||||||
|
{order.formaPagamento}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
<Descriptions.Item label="Total produtos">{fmt(order.totalProdutos)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Desc. Global">{order.descontoPerc}%</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Total">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
430
apps/web/src/cockpits/rep/OrderPrintPage.tsx
Normal file
430
apps/web/src/cockpits/rep/OrderPrintPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1043
apps/web/src/cockpits/rep/OrdersPage.tsx
Normal file
1043
apps/web/src/cockpits/rep/OrdersPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
457
apps/web/src/cockpits/rep/RepPainel.tsx
Normal file
457
apps/web/src/cockpits/rep/RepPainel.tsx
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
apps/web/src/cockpits/supervisor/ApprovalQueuePage.tsx
Normal file
108
apps/web/src/cockpits/supervisor/ApprovalQueuePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
341
apps/web/src/cockpits/supervisor/SupervisorPainel.tsx
Normal file
341
apps/web/src/cockpits/supervisor/SupervisorPainel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
apps/web/src/components/dev/DevLogin.tsx
Normal file
72
apps/web/src/components/dev/DevLogin.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
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 { Topbar } from './Topbar';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
|
||||||
|
import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -13,11 +17,23 @@ interface AppShellProps {
|
|||||||
* Variante mobile (Rafael) com bottom nav virá em ShellMobile separado.
|
* Variante mobile (Rafael) com bottom nav virá em ShellMobile separado.
|
||||||
*/
|
*/
|
||||||
export function AppShell({ children }: AppShellProps) {
|
export function AppShell({ children }: AppShellProps) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
const [, setSidebarOpen] = useState(true);
|
||||||
const [_sidebarOpen, setSidebarOpen] = useState(true);
|
const navigate = useNavigate();
|
||||||
|
const isOnline = useNetworkStatus();
|
||||||
|
useOfflineSync();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical style={{ minHeight: '100vh', background: 'var(--bg-body)' }}>
|
<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)} />
|
<Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} />
|
||||||
<Flex flex={1}>
|
<Flex flex={1}>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@@ -32,6 +48,31 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</Flex>
|
</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>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
118
apps/web/src/components/layout/FoundationStatus.tsx
Normal file
118
apps/web/src/components/layout/FoundationStatus.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,6 @@ import type { ItemType } from 'antd/es/menu/interface';
|
|||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const items: ItemType[] = [
|
const items: ItemType[] = [
|
||||||
{
|
{
|
||||||
key: '/',
|
key: '/',
|
||||||
@@ -39,7 +38,7 @@ export function Sidebar() {
|
|||||||
label: 'Clientes',
|
label: 'Clientes',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '/produtos',
|
key: '/catalogo',
|
||||||
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
|
||||||
label: 'Catálogo',
|
label: 'Catálogo',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { 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 { 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 {
|
interface TopbarProps {
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
@@ -12,7 +23,53 @@ interface TopbarProps {
|
|||||||
* Apple-inspired clean: logo à esquerda, search central, notif + perfil à direita.
|
* Apple-inspired clean: logo à esquerda, search central, notif + perfil à direita.
|
||||||
* Variantes por cockpit: search desabilitada para Rafael mobile (vai pro bottom nav).
|
* Variantes por cockpit: search desabilitada para Rafael mobile (vai pro bottom nav).
|
||||||
*/
|
*/
|
||||||
|
function logout() {
|
||||||
|
authStore.clear();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
export function Topbar({ onToggleSidebar }: TopbarProps) {
|
export function Topbar({ 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 (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
align="center"
|
align="center"
|
||||||
@@ -37,11 +94,7 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
|||||||
style={{ display: 'inline-flex' }}
|
style={{ display: 'inline-flex' }}
|
||||||
/>
|
/>
|
||||||
<Flex align="center" gap={12}>
|
<Flex align="center" gap={12}>
|
||||||
<img
|
<img src="/sar-icon.png" alt="SAR" style={{ height: 40, width: 'auto' }} />
|
||||||
src="/sar-icon.png"
|
|
||||||
alt="SAR"
|
|
||||||
style={{ height: 40, width: 'auto' }}
|
|
||||||
/>
|
|
||||||
<Flex vertical gap={0}>
|
<Flex vertical gap={0}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@@ -68,25 +121,31 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* Centro: search (Sandra/Daniel/Alice) */}
|
{/* Centro: search (Supervisor/Admin) */}
|
||||||
<Flex flex={1} justify="center" style={{ maxWidth: 480, margin: '0 var(--space-2xl)' }}>
|
<Flex flex={1} justify="center" style={{ maxWidth: 480, margin: '0 var(--space-2xl)' }}>
|
||||||
<Input
|
<Input
|
||||||
size="large"
|
size="large"
|
||||||
placeholder="Buscar cliente, pedido, produto..."
|
placeholder="Buscar cliente, pedido, produto..."
|
||||||
prefix={
|
prefix={
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon icon={faMagnifyingGlass} style={{ color: 'var(--text-muted)' }} />
|
||||||
icon={faMagnifyingGlass}
|
|
||||||
style={{ color: 'var(--text-muted)' }}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
style={{ borderRadius: 12 }}
|
style={{ borderRadius: 12 }}
|
||||||
aria-label="Buscar"
|
aria-label="Buscar"
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* Lado direito: notificações + perfil */}
|
{/* Lado direito: novo pedido + status fundação + notificações + perfil */}
|
||||||
<Flex align="center" gap={16}>
|
<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
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
size="large"
|
size="large"
|
||||||
@@ -94,17 +153,19 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
|||||||
aria-label="Notificações"
|
aria-label="Notificações"
|
||||||
/>
|
/>
|
||||||
</Badge>
|
</Badge>
|
||||||
<Avatar
|
<Dropdown menu={{ items: userMenuItems }} trigger={['click']} placement="bottomRight">
|
||||||
size={40}
|
<Avatar
|
||||||
style={{
|
size={40}
|
||||||
background: 'var(--jcs-blue-light)',
|
style={{
|
||||||
color: 'var(--jcs-blue)',
|
background: 'var(--jcs-blue-light)',
|
||||||
fontWeight: 'var(--font-weight-semibold)',
|
color: 'var(--jcs-blue)',
|
||||||
cursor: 'pointer',
|
fontWeight: 'var(--font-weight-semibold)',
|
||||||
}}
|
cursor: 'pointer',
|
||||||
>
|
}}
|
||||||
R
|
>
|
||||||
</Avatar>
|
{initials}
|
||||||
|
</Avatar>
|
||||||
|
</Dropdown>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|||||||
98
apps/web/src/lib/api-client.ts
Normal file
98
apps/web/src/lib/api-client.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
16
apps/web/src/lib/auth-store.ts
Normal file
16
apps/web/src/lib/auth-store.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
18
apps/web/src/lib/hooks/useNetworkStatus.ts
Normal file
18
apps/web/src/lib/hooks/useNetworkStatus.ts
Normal 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;
|
||||||
|
}
|
||||||
63
apps/web/src/lib/hooks/useOfflineSync.ts
Normal file
63
apps/web/src/lib/hooks/useOfflineSync.ts
Normal 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]);
|
||||||
|
}
|
||||||
27
apps/web/src/lib/hooks/usePendingOrders.ts
Normal file
27
apps/web/src/lib/hooks/usePendingOrders.ts
Normal 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 };
|
||||||
|
}
|
||||||
42
apps/web/src/lib/hooks/usePushRegistration.ts
Normal file
42
apps/web/src/lib/hooks/usePushRegistration.ts
Normal 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();
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
54
apps/web/src/lib/offline/idb.ts
Normal file
54
apps/web/src/lib/offline/idb.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
63
apps/web/src/lib/offline/order-queue.ts
Normal file
63
apps/web/src/lib/offline/order-queue.ts
Normal 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'));
|
||||||
|
}
|
||||||
18
apps/web/src/lib/queries/auth.ts
Normal file
18
apps/web/src/lib/queries/auth.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
66
apps/web/src/lib/queries/catalog.ts
Normal file
66
apps/web/src/lib/queries/catalog.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
44
apps/web/src/lib/queries/clients.ts
Normal file
44
apps/web/src/lib/queries/clients.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
12
apps/web/src/lib/queries/company.ts
Normal file
12
apps/web/src/lib/queries/company.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
31
apps/web/src/lib/queries/dashboard.ts
Normal file
31
apps/web/src/lib/queries/dashboard.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
15
apps/web/src/lib/queries/notifications.ts
Normal file
15
apps/web/src/lib/queries/notifications.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
57
apps/web/src/lib/queries/orders.ts
Normal file
57
apps/web/src/lib/queries/orders.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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 () => {
|
||||||
|
// Pedidos ERP têm id 'erp-{idPedido}' — endpoint separado sem ParseUUIDPipe
|
||||||
|
const path = id?.startsWith('erp-')
|
||||||
|
? `/orders/erp/${id.replace('erp-', '')}`
|
||||||
|
: `/orders/${id}`;
|
||||||
|
const res = await apiFetch(path);
|
||||||
|
return PedidoDetailSchema.parse(res);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
26
apps/web/src/lib/queries/ping.ts
Normal file
26
apps/web/src/lib/queries/ping.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 { 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({
|
const rootRoute = createRootRoute({
|
||||||
component: () => (
|
component: () => (
|
||||||
@@ -8,22 +54,81 @@ const rootRoute = createRootRoute({
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
),
|
),
|
||||||
|
notFoundComponent: NotFoundPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
const indexRoute = createRoute({
|
const indexRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/',
|
path: '/',
|
||||||
component: RafaelPainel,
|
component: HomeRoute,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Placeholder routes (cockpits a implementar)
|
|
||||||
const rafaelRoute = createRoute({
|
const rafaelRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/rep',
|
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({
|
export const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode, useState } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { ConfigProvider, App as AntdApp } from 'antd';
|
import { ConfigProvider, App as AntdApp } from 'antd';
|
||||||
import ptBR from 'antd/locale/pt_BR';
|
import ptBR from 'antd/locale/pt_BR';
|
||||||
@@ -12,9 +12,30 @@ import './styles/global.css';
|
|||||||
import { sarTheme } from './lib/theme';
|
import { sarTheme } from './lib/theme';
|
||||||
import { queryClient } from './lib/query-client';
|
import { queryClient } from './lib/query-client';
|
||||||
import { router } from './lib/router';
|
import { router } from './lib/router';
|
||||||
|
import { authStore } from './lib/auth-store';
|
||||||
|
import { DevLogin } from './components/dev/DevLogin';
|
||||||
|
|
||||||
dayjs.locale('pt-br');
|
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');
|
const rootEl = document.getElementById('root');
|
||||||
if (!rootEl) {
|
if (!rootEl) {
|
||||||
throw new Error('Root element not found');
|
throw new Error('Root element not found');
|
||||||
@@ -25,7 +46,7 @@ createRoot(rootEl).render(
|
|||||||
<ConfigProvider theme={sarTheme} locale={ptBR} componentSize="middle">
|
<ConfigProvider theme={sarTheme} locale={ptBR} componentSize="middle">
|
||||||
<AntdApp>
|
<AntdApp>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RouterProvider router={router} />
|
<Root />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</AntdApp>
|
</AntdApp>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
|
|||||||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal 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"}
|
||||||
@@ -10,6 +10,15 @@ export default defineConfig(() => ({
|
|||||||
server: {
|
server: {
|
||||||
port: 4200,
|
port: 4200,
|
||||||
host: 'localhost',
|
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: {
|
preview: {
|
||||||
port: 4200,
|
port: 4200,
|
||||||
|
|||||||
16
commitlint.config.js
Normal file
16
commitlint.config.js
Normal 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],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -319,6 +319,126 @@
|
|||||||
4. **Master-login stub + WorkspacePrismaPool** (frente arquitetural pesada — depende de #3).
|
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).
|
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 (C1–C9) · 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 (M1–M7):**
|
||||||
|
|
||||||
|
- **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
|
## About This Folder
|
||||||
|
|||||||
@@ -21,10 +21,33 @@ export default [
|
|||||||
enforceBuildableLibDependency: true,
|
enforceBuildableLibDependency: true,
|
||||||
allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$'],
|
allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$'],
|
||||||
depConstraints: [
|
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: '*',
|
sourceTag: 'type:app',
|
||||||
onlyDependOnLibsWithTags: ['*'],
|
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'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1 +1,8 @@
|
|||||||
export * from './lib/ping.contract';
|
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';
|
||||||
|
|||||||
32
libs/shared/api-interface/src/lib/auth.contract.ts
Normal file
32
libs/shared/api-interface/src/lib/auth.contract.ts
Normal 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>;
|
||||||
64
libs/shared/api-interface/src/lib/client.contract.ts
Normal file
64
libs/shared/api-interface/src/lib/client.contract.ts
Normal 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>;
|
||||||
21
libs/shared/api-interface/src/lib/company.contract.ts
Normal file
21
libs/shared/api-interface/src/lib/company.contract.ts
Normal 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>;
|
||||||
79
libs/shared/api-interface/src/lib/dashboard.contract.ts
Normal file
79
libs/shared/api-interface/src/lib/dashboard.contract.ts
Normal 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>;
|
||||||
17
libs/shared/api-interface/src/lib/notifications.contract.ts
Normal file
17
libs/shared/api-interface/src/lib/notifications.contract.ts
Normal 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
Reference in New Issue
Block a user