Compare commits
36 Commits
4649289213
...
a3c68f9f05
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
SENTRY_DSN=
|
||||
|
||||
# Web Push VAPID (C6) — gerar com: node -e "const wp=require('web-push'); const k=wp.generateVAPIDKeys(); console.log(k)"
|
||||
# Em prod: Vault injeta. Em dev: opcional — push fica desabilitado se vazio.
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_CONTACT=mailto:noreply@sar.dev
|
||||
# Chave pública VAPID para o front-end (mesmo valor de VAPID_PUBLIC_KEY)
|
||||
VITE_VAPID_PUBLIC_KEY=
|
||||
|
||||
# Feature flags (DEV: bypass. Prod: GrowthBook self-host)
|
||||
GROWTHBOOK_API_HOST=http://localhost:3100
|
||||
GROWTHBOOK_CLIENT_KEY=
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ node_modules
|
||||
dist
|
||||
build
|
||||
out
|
||||
tmp
|
||||
.nx
|
||||
.next
|
||||
.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[]=*eslint*
|
||||
public-hoist-pattern[]=*prettier*
|
||||
public-hoist-pattern[]=@prisma/client-runtime-utils
|
||||
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",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"tags": ["scope:api", "type:e2e", "domain:shared"],
|
||||
"implicitDependencies": ["api"],
|
||||
"targets": {
|
||||
"e2e": {
|
||||
|
||||
@@ -2,5 +2,14 @@
|
||||
"name": "@sar/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "SAR · API (NestJS 11 — CommonJS conforme CODING-RULES.md PGD-DB-004)"
|
||||
"description": "SAR · API (NestJS 11 — CommonJS conforme CODING-RULES.md PGD-DB-004)",
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/web-push": "^3.6.4"
|
||||
}
|
||||
}
|
||||
|
||||
20
apps/api/prisma.config.ts
Normal file
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 { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
||||
import { APP_FILTER, APP_GUARD, APP_PIPE } from '@nestjs/core';
|
||||
import { ZodValidationPipe } from 'nestjs-zod';
|
||||
import { EnvModule } from './config/env.module';
|
||||
import { LoggerModule } from './logger/logger.module';
|
||||
import { WorkspaceModule } from './workspace/workspace.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PingModule } from './ping/ping.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
import { ClientsModule } from './clients/clients.module';
|
||||
import { OrdersModule } from './orders/orders.module';
|
||||
import { CatalogModule } from './catalog/catalog.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Ordem importa: Env primeiro (fail-fast), depois Logger, CLS, módulos de domínio.
|
||||
// Ordem: Env primeiro (fail-fast), depois Logger, CLS, Auth, módulos de domínio.
|
||||
EnvModule,
|
||||
LoggerModule,
|
||||
WorkspaceModule,
|
||||
AuthModule,
|
||||
HealthModule,
|
||||
PingModule,
|
||||
ClientsModule,
|
||||
OrdersModule,
|
||||
CatalogModule,
|
||||
DashboardModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
providers: [
|
||||
// Pipe global: nestjs-zod converte ZodSchema (via createZodDto) em validação automática.
|
||||
// CODING-RULES §06: schema é o contrato; DTO é a classe que o expõe.
|
||||
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
||||
// Filter global: RFC 9457. Zod → 422.
|
||||
{ provide: APP_FILTER, useClass: ProblemDetailsFilter },
|
||||
// Guard global — exige JWT em todas as rotas exceto as com @Public().
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
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_HOST: z.string().min(1).default('0.0.0.0'),
|
||||
API_GLOBAL_PREFIX: z.string().min(1).default('api'),
|
||||
API_VERSION: z.string().regex(/^v\d+$/).default('v1'),
|
||||
API_VERSION: z
|
||||
.string()
|
||||
.regex(/^v\d+$/)
|
||||
.default('v1'),
|
||||
|
||||
// CORS — origens permitidas (Web em dev: http://localhost:4200)
|
||||
CORS_ORIGINS: z
|
||||
@@ -28,13 +31,20 @@ export const EnvSchema = z
|
||||
|
||||
// Master-login (DEV stub — IdP real virá na próxima sessão)
|
||||
MASTER_LOGIN_URL: z.url().default('http://localhost:3000/auth/dev'),
|
||||
MASTER_LOGIN_JWT_SECRET: z.string().min(32).default('dev_jwt_secret_change_in_prod_use_vault_xxxxx'),
|
||||
MASTER_LOGIN_JWT_SECRET: z
|
||||
.string()
|
||||
.min(32)
|
||||
.default('dev_jwt_secret_change_in_prod_use_vault_xxxxx'),
|
||||
JWT_ACCESS_EXPIRATION: z.coerce.number().int().positive().default(900),
|
||||
JWT_REFRESH_EXPIRATION: z.coerce.number().int().positive().default(2_592_000),
|
||||
|
||||
// Multi-tenancy — workspace de dev (até master-login real entrar)
|
||||
DEFAULT_WORKSPACE_ID: z.string().min(1).default('dev-workspace'),
|
||||
|
||||
// Representante fixo de dev — forçado no guard enquanto não há login real
|
||||
DEV_REP_CODE: z.coerce.number().int().positive().default(29),
|
||||
DEV_EMPRESA_ID: z.coerce.number().int().positive().default(1),
|
||||
|
||||
// Postgres (Prisma virá depois)
|
||||
DATABASE_URL: z.string().optional(),
|
||||
MIGRATION_DATABASE_URL: z.string().optional(),
|
||||
@@ -61,6 +71,11 @@ export const EnvSchema = z
|
||||
OTEL_TRACES_SAMPLER_ARG: z.coerce.number().min(0).max(1).default(1.0),
|
||||
SENTRY_DSN: z.string().optional(),
|
||||
|
||||
// Web Push VAPID (C6) — gerado via web-push generateVAPIDKeys()
|
||||
VAPID_PUBLIC_KEY: z.string().optional(),
|
||||
VAPID_PRIVATE_KEY: z.string().optional(),
|
||||
VAPID_CONTACT: z.string().default('mailto:noreply@sar.dev'),
|
||||
|
||||
// Feature flags
|
||||
GROWTHBOOK_API_HOST: z.string().optional(),
|
||||
GROWTHBOOK_CLIENT_KEY: z.string().optional(),
|
||||
@@ -74,11 +89,16 @@ export const EnvSchema = z
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['MASTER_LOGIN_JWT_SECRET'],
|
||||
message: 'JWT secret de DEV não pode ser usada em produção (CODING-RULES §08, PGD-SEC-002).',
|
||||
message:
|
||||
'JWT secret de DEV não pode ser usada em produção (CODING-RULES §08, PGD-SEC-002).',
|
||||
});
|
||||
}
|
||||
if (!env.DATABASE_URL) {
|
||||
ctx.addIssue({ code: 'custom', path: ['DATABASE_URL'], message: 'obrigatório em produção' });
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['DATABASE_URL'],
|
||||
message: 'obrigatório em produção',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
MemoryHealthIndicator,
|
||||
} from '@nestjs/terminus';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import { WorkspacePoolHealthIndicator } from './workspace-pool.health-indicator';
|
||||
|
||||
// CODING-RULES §20 (PGD-OBS-003):
|
||||
// /health/live → liveness só com memory.checkHeap(350MB).
|
||||
// /health/ready → readiness pinga master-login + amostra LRU (K=3) dos pools
|
||||
// quentes do WorkspacePrismaPool + Valkey + BullMQ.
|
||||
// NUNCA percorrer todos os workspaces (O(N) → false negative).
|
||||
//
|
||||
// Hoje o "ready" só checa heap, idêntico ao live. Quando master-login,
|
||||
// WorkspacePrismaPool, Valkey e BullMQ entrarem, cada um adiciona seu indicator
|
||||
// aqui — sem nunca virar O(N) sobre workspaces.
|
||||
// /health/live → liveness: memory.checkHeap(350MB).
|
||||
// /health/ready → readiness: heap + amostra LRU (K=3) do WorkspacePrismaPool.
|
||||
// Próximos: MasterLoginHealthIndicator, ValkeyHealthIndicator, BullMQHealthIndicator.
|
||||
// NUNCA percorrer todos os workspaces (O(N)).
|
||||
|
||||
@Public()
|
||||
@Controller({ path: 'health' })
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private readonly health: HealthCheckService,
|
||||
private readonly memory: MemoryHealthIndicator,
|
||||
private readonly workspacePool: WorkspacePoolHealthIndicator,
|
||||
) {}
|
||||
|
||||
@Get('live')
|
||||
@@ -32,11 +32,9 @@ export class HealthController {
|
||||
@Get('ready')
|
||||
@HealthCheck()
|
||||
ready(): Promise<HealthCheckResult> {
|
||||
// Skeleton: por enquanto idêntico ao live. Próximas frentes:
|
||||
// - MasterLoginHealthIndicator (obrigatório)
|
||||
// - WorkspacePoolLruHealthIndicator (K=3 amostra)
|
||||
// - ValkeyHealthIndicator
|
||||
// - BullMQHealthIndicator
|
||||
return this.health.check([() => this.memory.checkHeap('heap', 350 * 1024 * 1024)]);
|
||||
return this.health.check([
|
||||
() => this.memory.checkHeap('heap', 350 * 1024 * 1024),
|
||||
() => this.workspacePool.check('workspace_pool', 3),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
import { WorkspaceModule } from '../workspace/workspace.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import { WorkspacePoolHealthIndicator } from './workspace-pool.health-indicator';
|
||||
|
||||
@Module({
|
||||
imports: [TerminusModule],
|
||||
imports: [TerminusModule, WorkspaceModule],
|
||||
controllers: [HealthController],
|
||||
providers: [WorkspacePoolHealthIndicator],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
86
apps/api/src/app/orders/orders.controller.ts
Normal file
86
apps/api/src/app/orders/orders.controller.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import {
|
||||
AprovarPedidoSchema,
|
||||
CreatePedidoSchema,
|
||||
PedidoListQuerySchema,
|
||||
RecusarPedidoSchema,
|
||||
type AprovarPedido,
|
||||
type CreatePedido,
|
||||
type PedidoDetail,
|
||||
type PedidoListQuery,
|
||||
type PedidoListResponse,
|
||||
type RecusarPedido,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
|
||||
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
|
||||
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
|
||||
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
|
||||
|
||||
@Controller({ path: 'orders' })
|
||||
export class OrdersController {
|
||||
constructor(
|
||||
private readonly orders: OrdersService,
|
||||
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: PedidoListQueryDto): Promise<PedidoListResponse> {
|
||||
const parsed = PedidoListQuerySchema.parse(query) as PedidoListQuery;
|
||||
return this.orders.list(parsed);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(201)
|
||||
create(@Body() body: CreatePedidoDto): Promise<PedidoDetail> {
|
||||
const parsed = CreatePedidoSchema.parse(body) as CreatePedido;
|
||||
return this.orders.create(parsed);
|
||||
}
|
||||
|
||||
@Patch(':id/transmit')
|
||||
transmit(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
|
||||
return this.orders.transmit(id);
|
||||
}
|
||||
|
||||
@Patch(':id/approve')
|
||||
approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: AprovarPedidoDto,
|
||||
): Promise<PedidoDetail> {
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem aprovar pedidos');
|
||||
const parsed = AprovarPedidoSchema.parse(body) as AprovarPedido;
|
||||
return this.orders.approve(id, parsed);
|
||||
}
|
||||
|
||||
@Patch(':id/reject')
|
||||
reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: RecusarPedidoDto,
|
||||
): Promise<PedidoDetail> {
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem recusar pedidos');
|
||||
const parsed = RecusarPedidoSchema.parse(body) as RecusarPedido;
|
||||
return this.orders.reject(id, parsed);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
|
||||
return this.orders.findOne(id);
|
||||
}
|
||||
}
|
||||
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 {}
|
||||
598
apps/api/src/app/orders/orders.service.ts
Normal file
598
apps/api/src/app/orders/orders.service.ts
Normal file
@@ -0,0 +1,598 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
AprovarPedido,
|
||||
CreatePedido,
|
||||
PedidoDetail,
|
||||
PedidoListQuery,
|
||||
PedidoListResponse,
|
||||
PedidoSummary,
|
||||
RecusarPedido,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
|
||||
// Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado
|
||||
// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado
|
||||
const SITUA_ORCAMENTO = 0;
|
||||
const SITUA_PENDENTE = 1;
|
||||
const SITUA_APROVADO = 2;
|
||||
const SITUA_CANCELADO = 3;
|
||||
|
||||
// Mapeia situa SIG → situa SAR para exibição correta no frontend.
|
||||
// SIG usa 5 para Cancelado; SAR usa 3. Demais valores coincidem.
|
||||
function sigToSar(sigSitua: number): number {
|
||||
return sigSitua === 5 ? 3 : sigSitua;
|
||||
}
|
||||
|
||||
// Mapeia situa SAR → situa SIG para usar nos filtros SQL contra vw_pedidos_erp.
|
||||
function sarToSig(sarSitua: number): number {
|
||||
return sarSitua === 3 ? 5 : sarSitua;
|
||||
}
|
||||
|
||||
function decimalToString(v: Prisma.Decimal | null | undefined): string {
|
||||
return v ? v.toString() : '0';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(
|
||||
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
async list(query: PedidoListQuery): Promise<PedidoListResponse> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const role = this.cls.get('role');
|
||||
const userId = this.cls.get('userId');
|
||||
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||
|
||||
const { idCliente, situa, numPedSar, from, to, page, limit } = query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Filtro de vendedor: rep vê apenas seus pedidos
|
||||
const vendedorFilter = role === 'rep' ? `AND e.cod_vendedor = ${codVendedor}` : '';
|
||||
const clienteFilter = idCliente != null ? `AND e.id_cliente = ${idCliente}` : '';
|
||||
|
||||
// Converte situa SAR → SIG para filtrar corretamente contra vw_pedidos_erp
|
||||
const sigSitua = situa != null ? sarToSig(situa) : null;
|
||||
const situaFilter = sigSitua != null ? `AND e.situa = ${sigSitua}` : '';
|
||||
|
||||
const pedSarFilter = numPedSar ? `AND TRIM(e.num_ped_sar) ILIKE '%${numPedSar}%'` : '';
|
||||
const fromFilter = from ? `AND e.dt_pedido >= '${from}'` : '';
|
||||
const toFilter = to ? `AND e.dt_pedido <= '${to}'` : '';
|
||||
|
||||
const filters = `
|
||||
WHERE e.id_empresa = ${idEmpresa}
|
||||
${vendedorFilter} ${clienteFilter} ${situaFilter}
|
||||
${pedSarFilter} ${fromFilter} ${toFilter}
|
||||
`;
|
||||
|
||||
interface ErpRow {
|
||||
id_pedido: number;
|
||||
num_ped_sar: string;
|
||||
numero: number;
|
||||
id_cliente: number;
|
||||
nome_cliente: string | null;
|
||||
razao_cliente: string | null;
|
||||
cod_vendedor: number;
|
||||
nome_vendedor: string | null;
|
||||
situa: number;
|
||||
status_descr: string;
|
||||
dt_pedido: Date;
|
||||
total: string;
|
||||
desconto_perc: string;
|
||||
obs: string | null;
|
||||
}
|
||||
|
||||
// Pedidos SAR-nativos (Orçamento/Transmitido) — ainda não estão no ERP.
|
||||
const sarWhere: Prisma.PedidoWhereInput = {
|
||||
idEmpresa,
|
||||
...(role === 'rep' ? { codVendedor } : {}),
|
||||
...(idCliente != null ? { idCliente } : {}),
|
||||
...(situa != null ? { situa } : {}),
|
||||
...(numPedSar ? { numPedSar: { contains: numPedSar, mode: 'insensitive' as const } } : {}),
|
||||
...(from || to
|
||||
? {
|
||||
dtPedido: {
|
||||
...(from ? { gte: new Date(from) } : {}),
|
||||
...(to ? { lte: new Date(to) } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [sarPedidos, countRows] = await Promise.all([
|
||||
prisma.pedido.findMany({ where: sarWhere, orderBy: { dtPedido: 'desc' } }),
|
||||
prisma.$queryRawUnsafe<[{ count: string }]>(`
|
||||
SELECT COUNT(*)::text AS count FROM vw_pedidos_erp e ${filters}
|
||||
`),
|
||||
]);
|
||||
|
||||
const sarCount = sarPedidos.length;
|
||||
const erpTotal = Number(countRows[0]?.count ?? 0);
|
||||
const total = sarCount + erpTotal;
|
||||
|
||||
// Paginação combinada: SAR-nativos primeiro (ativos), depois histórico ERP.
|
||||
const sarSlice = sarPedidos.slice(offset, offset + limit);
|
||||
const erpNeeded = limit - sarSlice.length;
|
||||
const erpOffset = Math.max(0, offset - sarCount);
|
||||
|
||||
const erpRows =
|
||||
erpNeeded > 0
|
||||
? await prisma.$queryRawUnsafe<ErpRow[]>(`
|
||||
SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor,
|
||||
e.situa, e.status_descr, e.dt_pedido, e.total::text, e.desconto_perc::text, e.obs,
|
||||
c.nome AS nome_cliente, c.razao AS razao_cliente,
|
||||
(SELECT r.nome FROM vw_representantes r
|
||||
WHERE r.codigo = e.cod_vendedor
|
||||
LIMIT 1) AS nome_vendedor
|
||||
FROM vw_pedidos_erp e
|
||||
LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente
|
||||
${filters}
|
||||
ORDER BY e.dt_pedido DESC
|
||||
LIMIT ${erpNeeded} OFFSET ${erpOffset}
|
||||
`)
|
||||
: [];
|
||||
|
||||
// Resolve nomes (cliente/rep) dos pedidos SAR em lote — views globais.
|
||||
const cliIds = [...new Set(sarSlice.map((p) => p.idCliente))];
|
||||
const repCods = [...new Set(sarSlice.map((p) => p.codVendedor))];
|
||||
const [cliNameRows, repNameRows] = await Promise.all([
|
||||
cliIds.length
|
||||
? prisma.$queryRawUnsafe<
|
||||
{ id_cliente: number; nome: string | null; razao: string | null }[]
|
||||
>(
|
||||
`SELECT id_cliente, nome, razao FROM vw_clientes WHERE id_cliente IN (${cliIds.join(',')})`,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
repCods.length
|
||||
? prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
|
||||
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${repCods.join(',')})`,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const cliMap = new Map(cliNameRows.map((c) => [Number(c.id_cliente), c]));
|
||||
const repMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
|
||||
|
||||
const sarData: PedidoSummary[] = sarSlice.map((p) => ({
|
||||
id: p.id,
|
||||
numPedSar: p.numPedSar,
|
||||
idCliente: p.idCliente,
|
||||
nomeCliente: cliMap.get(p.idCliente)?.nome ?? null,
|
||||
razaoCliente: cliMap.get(p.idCliente)?.razao ?? null,
|
||||
codVendedor: p.codVendedor,
|
||||
nomeVendedor: repMap.get(p.codVendedor) ?? null,
|
||||
situa: p.situa,
|
||||
dtPedido: p.dtPedido.toISOString(),
|
||||
total: decimalToString(p.total),
|
||||
descontoPerc: decimalToString(p.descontoPerc),
|
||||
obs: p.obs,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
fonte: 'sar' as const,
|
||||
}));
|
||||
|
||||
const erpData: PedidoSummary[] = erpRows.map((o) => ({
|
||||
id: `erp-${o.id_pedido}`,
|
||||
numPedSar: (o.num_ped_sar ?? '').trim(),
|
||||
numero: Number(o.numero),
|
||||
idCliente: Number(o.id_cliente),
|
||||
nomeCliente: o.nome_cliente ?? null,
|
||||
razaoCliente: o.razao_cliente ?? null,
|
||||
codVendedor: Number(o.cod_vendedor),
|
||||
nomeVendedor: o.nome_vendedor ?? null,
|
||||
// Normaliza situa SIG → SAR para consistência com pedidos SAR
|
||||
situa: sigToSar(Number(o.situa)),
|
||||
statusDescr: o.status_descr,
|
||||
dtPedido: new Date(o.dt_pedido).toISOString(),
|
||||
total: o.total ?? '0',
|
||||
descontoPerc: o.desconto_perc ?? '0',
|
||||
obs: o.obs ?? null,
|
||||
createdAt: new Date(o.dt_pedido).toISOString(),
|
||||
fonte: 'erp' as const,
|
||||
}));
|
||||
|
||||
return { data: [...sarData, ...erpData], total, page, limit };
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<PedidoDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const role = this.cls.get('role');
|
||||
const userId = this.cls.get('userId');
|
||||
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||
|
||||
const repFilter = role === 'rep' ? { codVendedor } : {};
|
||||
|
||||
const o = await prisma.pedido.findFirst({
|
||||
where: { id, idEmpresa, ...repFilter },
|
||||
include: {
|
||||
itens: { orderBy: { ordem: 'asc' } },
|
||||
historico: { orderBy: { changedAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!o) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||
|
||||
return this.mapDetail(o);
|
||||
}
|
||||
|
||||
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A validação de alçada e a
|
||||
// notificação ao supervisor acontecem no transmit(), não aqui.
|
||||
// Idempotency-Key: retorna pedido existente se já processado (FR-4.3).
|
||||
async create(dto: CreatePedido): Promise<PedidoDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const userId = this.cls.get('userId') ?? '0';
|
||||
const codVendedor = parseInt(userId, 10);
|
||||
|
||||
// Idempotency-Key: retorna pedido existente sem re-processar
|
||||
if (dto.idempotencyKey) {
|
||||
const existing = await prisma.pedido.findUnique({
|
||||
where: { idempotencyKey: dto.idempotencyKey },
|
||||
include: {
|
||||
itens: { orderBy: { ordem: 'asc' } },
|
||||
historico: { orderBy: { changedAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (existing) return this.mapDetail(existing);
|
||||
}
|
||||
|
||||
const itemsData = dto.itens.map((it) => {
|
||||
const descontoValor =
|
||||
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
|
||||
const total = Math.round(it.qtd * it.precoUnitario * (1 - it.descontoPerc / 100) * 100) / 100;
|
||||
return {
|
||||
ordem: it.ordem,
|
||||
idProduto: it.idProduto,
|
||||
codProduto: it.codProduto ?? null,
|
||||
descProduto: it.descProduto,
|
||||
qtd: it.qtd,
|
||||
precoUnitario: it.precoUnitario,
|
||||
descontoPerc: it.descontoPerc,
|
||||
descontoValor,
|
||||
total,
|
||||
};
|
||||
});
|
||||
|
||||
const totalProdutos = itemsData.reduce((acc, it) => acc + it.total, 0);
|
||||
const descontoValorGlobal = Math.round(totalProdutos * (dto.descontoPerc / 100) * 100) / 100;
|
||||
const total = Math.round(totalProdutos * (1 - dto.descontoPerc / 100) * 100) / 100;
|
||||
|
||||
const situa = SITUA_ORCAMENTO;
|
||||
|
||||
// Gera número sequencial GLOBAL: SAR-NNNNN (numPedSar é unique entre empresas).
|
||||
const lastOrder = await prisma.pedido.findFirst({
|
||||
orderBy: { numPedSar: 'desc' },
|
||||
select: { numPedSar: true },
|
||||
});
|
||||
const seq = lastOrder ? parseInt(lastOrder.numPedSar.replace('SAR-', ''), 10) + 1 : 1;
|
||||
const numPedSar = `SAR-${String(seq).padStart(5, '0')}`;
|
||||
|
||||
const now = new Date();
|
||||
const pedido = await prisma.pedido.create({
|
||||
data: {
|
||||
idEmpresa,
|
||||
numPedSar,
|
||||
idCliente: dto.idCliente,
|
||||
codVendedor,
|
||||
situa,
|
||||
dtPedido: now,
|
||||
idPauta: dto.idPauta ?? null,
|
||||
codFormapag: dto.codFormapag ?? null,
|
||||
totalProdutos,
|
||||
total,
|
||||
descontoPerc: dto.descontoPerc,
|
||||
descontoValor: descontoValorGlobal,
|
||||
obs: dto.obs ?? null,
|
||||
idempotencyKey: dto.idempotencyKey ?? null,
|
||||
itens: { create: itemsData },
|
||||
historico: {
|
||||
create: [
|
||||
{
|
||||
situaAnterior: null,
|
||||
situaNova: situa,
|
||||
changedBy: codVendedor,
|
||||
changedAt: now,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
itens: { orderBy: { ordem: 'asc' } },
|
||||
historico: { orderBy: { changedAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
return this.mapDetail(pedido);
|
||||
}
|
||||
|
||||
// Transmite um Orçamento (situa 0) → Transmitido (situa 2).
|
||||
// Alçada de desconto (codGrupo=0 = default) é BLOQUEIO DURO: desconto acima do
|
||||
// máximo do rep barra a transmissão com mensagem — não há fila de aprovação.
|
||||
async transmit(id: string): Promise<PedidoDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const role = this.cls.get('role');
|
||||
const userId = this.cls.get('userId') ?? '0';
|
||||
const codVendedor = parseInt(userId, 10);
|
||||
|
||||
// Rep só transmite o próprio orçamento
|
||||
const repFilter = role === 'rep' ? { codVendedor } : {};
|
||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } });
|
||||
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||
if (pedido.situa !== SITUA_ORCAMENTO)
|
||||
throw new BadRequestException(
|
||||
`Pedido não é um orçamento (situa: ${pedido.situa}) — só orçamentos podem ser transmitidos`,
|
||||
);
|
||||
|
||||
// Alçada do rep (codGrupo=0 = default; fallback 5%) — bloqueia se desconto acima.
|
||||
const limitRows = await prisma.alcadaDesconto.findMany({ where: { codVendedor, idEmpresa } });
|
||||
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
|
||||
const limiteMax = limitMap.get(0) ?? 5;
|
||||
const desconto = Number(pedido.descontoPerc);
|
||||
if (desconto > limiteMax) {
|
||||
throw new BadRequestException(
|
||||
`Desconto de ${desconto}% acima do máximo permitido para você (${limiteMax}%). Reduza o desconto para transmitir o pedido.`,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } });
|
||||
await prisma.historicoPedido.create({
|
||||
data: {
|
||||
idPedido: id,
|
||||
situaAnterior: SITUA_ORCAMENTO,
|
||||
situaNova: SITUA_APROVADO,
|
||||
changedBy: codVendedor,
|
||||
changedAt: now,
|
||||
nota: 'Transmitido',
|
||||
},
|
||||
});
|
||||
|
||||
const final = await prisma.pedido.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
itens: { orderBy: { ordem: 'asc' } },
|
||||
historico: { orderBy: { changedAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
return this.mapDetail(final);
|
||||
}
|
||||
|
||||
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const userId = this.cls.get('userId') ?? '0';
|
||||
const codVendedor = parseInt(userId, 10);
|
||||
|
||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
|
||||
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||
if (pedido.situa !== SITUA_PENDENTE)
|
||||
throw new BadRequestException(
|
||||
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc);
|
||||
const newTotal =
|
||||
Math.round(Number(pedido.totalProdutos) * (1 - newDescontoPerc / 100) * 100) / 100;
|
||||
|
||||
await prisma.pedido.update({
|
||||
where: { id },
|
||||
data: {
|
||||
situa: SITUA_APROVADO,
|
||||
descontoPerc: newDescontoPerc,
|
||||
total: newTotal,
|
||||
aprovadoPor: codVendedor,
|
||||
aprovadoEm: now,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.historicoPedido.create({
|
||||
data: {
|
||||
idPedido: id,
|
||||
situaAnterior: SITUA_PENDENTE,
|
||||
situaNova: SITUA_APROVADO,
|
||||
changedBy: codVendedor,
|
||||
changedAt: now,
|
||||
nota: dto.nota ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const final = await prisma.pedido.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
itens: { orderBy: { ordem: 'asc' } },
|
||||
historico: { orderBy: { changedAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
void this.notifications.notifyUser(String(pedido.codVendedor), {
|
||||
title: 'Pedido aprovado',
|
||||
body: `${final.numPedSar} aprovado${dto.descontoPerc !== undefined ? ` com ${newDescontoPerc}% de desconto` : ''}`,
|
||||
url: `/pedidos/${id}`,
|
||||
});
|
||||
|
||||
return this.mapDetail(final);
|
||||
}
|
||||
|
||||
async reject(id: string, dto: RecusarPedido): Promise<PedidoDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const userId = this.cls.get('userId') ?? '0';
|
||||
const codVendedor = parseInt(userId, 10);
|
||||
|
||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
|
||||
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||
if (pedido.situa !== SITUA_PENDENTE)
|
||||
throw new BadRequestException(
|
||||
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await prisma.pedido.update({
|
||||
where: { id },
|
||||
data: { situa: SITUA_CANCELADO, motivoRecusa: dto.motivo },
|
||||
});
|
||||
|
||||
await prisma.historicoPedido.create({
|
||||
data: {
|
||||
idPedido: id,
|
||||
situaAnterior: SITUA_PENDENTE,
|
||||
situaNova: SITUA_CANCELADO,
|
||||
changedBy: codVendedor,
|
||||
changedAt: now,
|
||||
nota: dto.motivo,
|
||||
},
|
||||
});
|
||||
|
||||
const final = await prisma.pedido.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
itens: { orderBy: { ordem: 'asc' } },
|
||||
historico: { orderBy: { changedAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
void this.notifications.notifyUser(String(pedido.codVendedor), {
|
||||
title: 'Pedido recusado',
|
||||
body: `${final.numPedSar}: ${dto.motivo}`,
|
||||
url: `/pedidos/${id}`,
|
||||
});
|
||||
|
||||
return this.mapDetail(final);
|
||||
}
|
||||
|
||||
// Resolve nome do cliente (nome + razão) e nome do representante a partir dos
|
||||
// códigos, lendo das views do ERP. Usado no detalhe de pedidos SAR-nativos.
|
||||
private async lookupNames(
|
||||
idCliente: number,
|
||||
codVendedor: number,
|
||||
): Promise<{
|
||||
nomeCliente: string | null;
|
||||
razaoCliente: string | null;
|
||||
nomeVendedor: string | null;
|
||||
}> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
|
||||
// Cliente e representante são cadastros globais (sem id_empresa).
|
||||
const [cliRows, repRows] = await Promise.all([
|
||||
prisma.$queryRawUnsafe<{ nome: string | null; razao: string | null }[]>(
|
||||
`SELECT nome, razao FROM vw_clientes WHERE id_cliente = ${idCliente} LIMIT 1`,
|
||||
),
|
||||
prisma.$queryRawUnsafe<{ nome: string | null }[]>(
|
||||
`SELECT nome FROM vw_representantes WHERE codigo = ${codVendedor} LIMIT 1`,
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
nomeCliente: cliRows[0]?.nome ?? null,
|
||||
razaoCliente: cliRows[0]?.razao ?? null,
|
||||
nomeVendedor: repRows[0]?.nome ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async mapDetail(o: {
|
||||
id: string;
|
||||
numPedSar: string;
|
||||
idCliente: number;
|
||||
codVendedor: number;
|
||||
situa: number;
|
||||
dtPedido: Date;
|
||||
total: Prisma.Decimal;
|
||||
descontoPerc: Prisma.Decimal;
|
||||
descontoValor: Prisma.Decimal;
|
||||
totalProdutos: Prisma.Decimal;
|
||||
totalIpi: Prisma.Decimal;
|
||||
totalIcmsst: Prisma.Decimal;
|
||||
acrescimo: Prisma.Decimal;
|
||||
comissao: Prisma.Decimal;
|
||||
pedFlex: Prisma.Decimal;
|
||||
aprovadoPor: number | null;
|
||||
aprovadoEm: Date | null;
|
||||
motivoRecusa: string | null;
|
||||
obs: string | null;
|
||||
idempotencyKey: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
itens: {
|
||||
id: string;
|
||||
idProduto: number;
|
||||
codProduto: string | null;
|
||||
descProduto: string | null;
|
||||
ordem: number;
|
||||
qtd: Prisma.Decimal;
|
||||
precoUnitario: Prisma.Decimal;
|
||||
descontoPerc: Prisma.Decimal;
|
||||
total: Prisma.Decimal;
|
||||
}[];
|
||||
historico: {
|
||||
id: string;
|
||||
situaAnterior: number | null;
|
||||
situaNova: number;
|
||||
changedBy: number;
|
||||
nota: string | null;
|
||||
changedAt: Date;
|
||||
}[];
|
||||
}): Promise<PedidoDetail> {
|
||||
const names = await this.lookupNames(o.idCliente, o.codVendedor);
|
||||
return {
|
||||
id: o.id,
|
||||
numPedSar: o.numPedSar,
|
||||
idCliente: o.idCliente,
|
||||
nomeCliente: names.nomeCliente,
|
||||
razaoCliente: names.razaoCliente,
|
||||
codVendedor: o.codVendedor,
|
||||
nomeVendedor: names.nomeVendedor,
|
||||
situa: o.situa,
|
||||
dtPedido: o.dtPedido.toISOString(),
|
||||
total: decimalToString(o.total),
|
||||
descontoPerc: decimalToString(o.descontoPerc),
|
||||
obs: o.obs,
|
||||
createdAt: o.createdAt.toISOString(),
|
||||
totalProdutos: decimalToString(o.totalProdutos),
|
||||
totalIpi: decimalToString(o.totalIpi),
|
||||
totalIcmsst: decimalToString(o.totalIcmsst),
|
||||
descontoValor: decimalToString(o.descontoValor),
|
||||
acrescimo: decimalToString(o.acrescimo),
|
||||
comissao: decimalToString(o.comissao),
|
||||
pedFlex: decimalToString(o.pedFlex),
|
||||
fonte: 'sar' as const,
|
||||
aprovadoPor: o.aprovadoPor,
|
||||
aprovadoEm: o.aprovadoEm?.toISOString() ?? null,
|
||||
motivoRecusa: o.motivoRecusa,
|
||||
idempotencyKey: o.idempotencyKey,
|
||||
updatedAt: o.updatedAt.toISOString(),
|
||||
itens: o.itens.map((it) => ({
|
||||
id: it.id,
|
||||
idProduto: it.idProduto,
|
||||
codProduto: it.codProduto,
|
||||
descProduto: it.descProduto,
|
||||
ordem: it.ordem,
|
||||
qtd: decimalToString(it.qtd),
|
||||
precoUnitario: decimalToString(it.precoUnitario),
|
||||
descontoPerc: decimalToString(it.descontoPerc),
|
||||
total: decimalToString(it.total),
|
||||
})),
|
||||
historico: o.historico.map((h) => ({
|
||||
id: h.id,
|
||||
situaAnterior: h.situaAnterior,
|
||||
situaNova: h.situaNova,
|
||||
changedBy: h.changedBy,
|
||||
nota: h.nota,
|
||||
changedAt: h.changedAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Controller, Get } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type { PingResponse } from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
|
||||
// Endpoint de verificação de fundação:
|
||||
// - confirma que CLS está populando workspaceId + requestId;
|
||||
@@ -9,6 +10,7 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
// - usado pela Web (Frente B) para validar conectividade real.
|
||||
// Contrato: @sar/api-interface · PingResponseSchema (zod).
|
||||
|
||||
@Public()
|
||||
@Controller({ path: 'ping' })
|
||||
export class PingController {
|
||||
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||
@@ -19,7 +21,7 @@ export class PingController {
|
||||
status: 'ok',
|
||||
service: 'sar-api',
|
||||
version: process.env['npm_package_version'] ?? '0.1.0',
|
||||
workspaceId: this.cls.get('workspaceId'),
|
||||
idEmpresa: this.cls.get('idEmpresa'),
|
||||
requestId: this.cls.get('requestId'),
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
now: new Date().toISOString(),
|
||||
|
||||
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 { ClsModule } from 'nestjs-cls';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { WorkspaceClsStore } from './workspace.types';
|
||||
import type { Env } from '../config/env.schema';
|
||||
import { WorkspacePrismaPool } from './workspace-prisma-pool.service';
|
||||
|
||||
// CLS popula contexto por request. Hoje: requestId + DEFAULT_WORKSPACE_ID do env.
|
||||
// Amanhã: workspaceId vem do JWT (PGD-AUTHZ-002); `prisma` é resolvido pelo
|
||||
// WorkspacePrismaPool e injetado via cls.set('prisma', ...) aqui mesmo.
|
||||
// CLS middleware roda ANTES dos guards (ordem NestJS).
|
||||
// Aqui: apenas requestId + idEmpresa default (0 = não autenticado).
|
||||
// JwtAuthGuard atualiza idEmpresa, userId e prisma após validar o token.
|
||||
// CODING-RULES PGD-DB-009: prisma via cls.get('prisma'), nunca singleton.
|
||||
// CODING-RULES PGD-AUTHZ-002: idEmpresa real vem do JWT (guard), não do env.
|
||||
// ADR 0006 revogado: workspaceId: string → idEmpresa: number
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ClsModule.forRootAsync({
|
||||
global: true,
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService<Env, true>) => ({
|
||||
useFactory: () => ({
|
||||
middleware: {
|
||||
mount: true,
|
||||
generateId: true,
|
||||
idGenerator: (req: Request) => {
|
||||
// Prioridade: req.id (pino-http já gerou/leu header) > header bruto > novo UUID.
|
||||
const fromPino = (req as Request & { id?: unknown }).id;
|
||||
if (typeof fromPino === 'string' && fromPino.length > 0) return fromPino;
|
||||
const headerVal = req.headers['x-request-id'];
|
||||
return typeof headerVal === 'string' && headerVal.length > 0
|
||||
? headerVal
|
||||
: randomUUID();
|
||||
return typeof headerVal === 'string' && headerVal.length > 0 ? headerVal : randomUUID();
|
||||
},
|
||||
setup: (cls, req: Request, res: Response) => {
|
||||
const store = cls as unknown as {
|
||||
set: <K extends keyof WorkspaceClsStore>(key: K, value: WorkspaceClsStore[K]) => void;
|
||||
getId: () => string;
|
||||
};
|
||||
|
||||
const requestId = store.getId();
|
||||
res.setHeader('x-request-id', requestId);
|
||||
store.set('requestId', requestId);
|
||||
store.set('workspaceId', config.get('DEFAULT_WORKSPACE_ID', { infer: true }));
|
||||
// Fallback para rotas públicas (ping, health). Guard sobrescreve em rotas protegidas.
|
||||
store.set('idEmpresa', 0);
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
exports: [ClsModule],
|
||||
providers: [WorkspacePrismaPool],
|
||||
exports: [ClsModule, WorkspacePrismaPool],
|
||||
})
|
||||
export class WorkspaceModule {}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { ClsStore } from 'nestjs-cls';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import type { JwtRole } from '../auth/jwt.types';
|
||||
|
||||
// Forma do CLS store por request — fonte da verdade para qualquer caller
|
||||
// que faça `cls.get(...)`. Quando o PrismaClient por workspace entrar
|
||||
// (ADR 0006), `prisma` virará obrigatório aqui — por hora segue opcional.
|
||||
// Forma do CLS store por request — fonte da verdade para qualquer caller.
|
||||
// CODING-RULES PGD-DB-009: nunca importe PrismaClient diretamente; use cls.get('prisma').
|
||||
// CODING-RULES PGD-AUTHZ-002: idEmpresa vem sempre do JWT, nunca de body/param/query.
|
||||
// ADR 0006 revogado: workspaceId: string → idEmpresa: number
|
||||
|
||||
export interface WorkspaceClsStore extends ClsStore {
|
||||
requestId: string;
|
||||
workspaceId: string;
|
||||
// userId virá quando master-login estiver plugado.
|
||||
userId?: string;
|
||||
// prisma: PrismaClient — adicionar quando WorkspacePrismaPool entrar.
|
||||
idEmpresa: number; // era workspaceId: string — agora Int da empresa no ERP
|
||||
userId?: string; // cod_vendedor como string; preenchido pelo JwtAuthGuard
|
||||
role?: JwtRole; // preenchido pelo JwtAuthGuard após validar o token
|
||||
prisma?: PrismaClient; // preenchido pelo JwtAuthGuard via WorkspacePrismaPool
|
||||
}
|
||||
|
||||
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",
|
||||
"projectType": "application",
|
||||
"sourceRoot": "apps/web-e2e/src",
|
||||
"tags": [],
|
||||
"tags": ["scope:web", "type:e2e", "domain:shared"],
|
||||
"implicitDependencies": ["web"],
|
||||
"// targets": "to see all targets run: nx show project web-e2e --web",
|
||||
"targets": {}
|
||||
|
||||
123
apps/web/public/sw.js
Normal file
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>
|
||||
);
|
||||
}
|
||||
511
apps/web/src/cockpits/rep/OrderDetailPage.tsx
Normal file
511
apps/web/src/cockpits/rep/OrderDetailPage.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Form,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Timeline,
|
||||
Typography,
|
||||
Input,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faShareNodes } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FilePdfOutlined } from '@ant-design/icons';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { Link, useParams, useNavigate } from '@tanstack/react-router';
|
||||
import type { PedidoItem, HistoricoPedido } from '@sar/api-interface';
|
||||
import { SITUA_LABEL } from '@sar/api-interface';
|
||||
import { useOrderDetail } from '../../lib/queries/orders';
|
||||
import { useClientOrders } from '../../lib/queries/orders';
|
||||
import { apiFetch } from '../../lib/api-client';
|
||||
import { authStore } from '../../lib/auth-store';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const SITUA_COLOR: Record<number, string> = {
|
||||
0: 'default',
|
||||
1: 'warning',
|
||||
2: 'processing',
|
||||
3: 'error',
|
||||
4: 'success',
|
||||
};
|
||||
|
||||
function fmt(v: string | number): string {
|
||||
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
function buildShareText(order: {
|
||||
numPedSar: string;
|
||||
idCliente: number;
|
||||
total: string;
|
||||
itens: Array<{ descProduto: string | null; qtd: string; precoUnitario: string }>;
|
||||
}): string {
|
||||
const lines = [
|
||||
`*Pedido ${order.numPedSar} — Cliente ${order.idCliente}*`,
|
||||
'',
|
||||
...order.itens.map(
|
||||
(it) =>
|
||||
`• ${it.descProduto ?? '?'} × ${Number(it.qtd).toLocaleString('pt-BR')} — ${fmt(it.precoUnitario)} un.`,
|
||||
),
|
||||
'',
|
||||
`*Total: ${fmt(order.total)}*`,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getRoleFromToken(): string {
|
||||
const token = authStore.get();
|
||||
if (!token) return 'rep';
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1] ?? ''));
|
||||
return (payload.role as string) ?? 'rep';
|
||||
} catch {
|
||||
return 'rep';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Subcomponents ────────────────────────────────────────────────────────────
|
||||
|
||||
const itemColumns: TableColumnsType<PedidoItem> = [
|
||||
{ title: 'Código', dataIndex: 'codProduto', width: 100 },
|
||||
{ title: 'Produto', dataIndex: 'descProduto', ellipsis: true },
|
||||
{ title: 'Qtd', dataIndex: 'qtd', width: 90, align: 'right' },
|
||||
{
|
||||
title: 'Preço Unit.',
|
||||
dataIndex: 'precoUnitario',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: (v: string) => fmt(v),
|
||||
},
|
||||
{
|
||||
title: 'Desc %',
|
||||
dataIndex: 'descontoPerc',
|
||||
width: 80,
|
||||
align: 'right',
|
||||
render: (v: string) => `${v}%`,
|
||||
},
|
||||
{
|
||||
title: 'Total',
|
||||
dataIndex: 'total',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
render: (v: string) => fmt(v),
|
||||
},
|
||||
];
|
||||
|
||||
function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
|
||||
return (
|
||||
<Timeline
|
||||
items={history.map((h) => ({
|
||||
color:
|
||||
SITUA_COLOR[h.situaNova] === 'success'
|
||||
? 'green'
|
||||
: SITUA_COLOR[h.situaNova] === 'warning'
|
||||
? 'orange'
|
||||
: SITUA_COLOR[h.situaNova] === 'error'
|
||||
? 'red'
|
||||
: 'blue',
|
||||
children: (
|
||||
<div>
|
||||
<Text strong>{SITUA_LABEL[h.situaNova] ?? String(h.situaNova)}</Text>
|
||||
{h.situaAnterior != null && (
|
||||
<Text type="secondary">
|
||||
{' '}
|
||||
(de {SITUA_LABEL[h.situaAnterior] ?? String(h.situaAnterior)})
|
||||
</Text>
|
||||
)}
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{new Date(h.changedAt).toLocaleString('pt-BR')} — cod. {h.changedBy}
|
||||
</Text>
|
||||
{h.nota && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Text italic>"{h.nota}"</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Approve Modal ────────────────────────────────────────────────────────────
|
||||
|
||||
function ApproveModal({
|
||||
open,
|
||||
originalDiscount,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
loading,
|
||||
}: {
|
||||
open: boolean;
|
||||
originalDiscount: string;
|
||||
onConfirm: (descontoPerc?: number, nota?: string) => void;
|
||||
onCancel: () => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [disc, setDisc] = useState<number | null>(null);
|
||||
const [nota, setNota] = useState('');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Aprovar Pedido"
|
||||
open={open}
|
||||
onOk={() => onConfirm(disc ?? undefined, nota || undefined)}
|
||||
onCancel={onCancel}
|
||||
okText="Confirmar Aprovação"
|
||||
cancelText="Voltar"
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item
|
||||
label={`Desconto global (original: ${originalDiscount}%)`}
|
||||
help="Deixe em branco para manter o desconto solicitado."
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
placeholder={originalDiscount}
|
||||
value={disc}
|
||||
onChange={(v) => setDisc(v)}
|
||||
addonAfter="%"
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Observação (opcional)">
|
||||
<TextArea
|
||||
rows={2}
|
||||
value={nota}
|
||||
onChange={(e) => setNota(e.target.value)}
|
||||
maxLength={300}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Reject Modal ─────────────────────────────────────────────────────────────
|
||||
|
||||
function RejectModal({
|
||||
open,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
loading,
|
||||
}: {
|
||||
open: boolean;
|
||||
onConfirm: (motivo: string) => void;
|
||||
onCancel: () => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [motivo, setMotivo] = useState('');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Recusar Pedido"
|
||||
open={open}
|
||||
onOk={() => motivo.trim() && onConfirm(motivo.trim())}
|
||||
onCancel={onCancel}
|
||||
okText="Confirmar Recusa"
|
||||
okButtonProps={{ danger: true, disabled: !motivo.trim() }}
|
||||
cancelText="Voltar"
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="Motivo da recusa" required>
|
||||
<TextArea
|
||||
rows={3}
|
||||
value={motivo}
|
||||
onChange={(e) => setMotivo(e.target.value)}
|
||||
maxLength={500}
|
||||
showCount
|
||||
placeholder="Informe o motivo para o representante..."
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── OrderDetailPage ──────────────────────────────────────────────────────────
|
||||
|
||||
export function OrderDetailPage() {
|
||||
const { id } = useParams({ from: '/pedidos/$id' });
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { data: order, isLoading, error } = useOrderDetail(id);
|
||||
const { data: clientOrders } = useClientOrders(order?.idCliente);
|
||||
|
||||
const role = getRoleFromToken();
|
||||
const canAct = role !== 'rep' && order?.situa === 1;
|
||||
const canTransmit = role === 'rep' && order?.situa === 0;
|
||||
const canShare =
|
||||
role === 'rep' &&
|
||||
(order?.situa === 2 || order?.situa === 4) &&
|
||||
typeof navigator !== 'undefined' &&
|
||||
!!navigator.share;
|
||||
|
||||
const [approveOpen, setApproveOpen] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: ({ descontoPerc, nota }: { descontoPerc?: number; nota?: string }) =>
|
||||
apiFetch(`/orders/${id}/approve`, { method: 'PATCH', body: { descontoPerc, nota } }),
|
||||
onSuccess: () => {
|
||||
setApproveOpen(false);
|
||||
void qc.invalidateQueries({ queryKey: ['orders', id] });
|
||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
setApproveOpen(false);
|
||||
setActionError(e instanceof Error ? e.message : 'Erro ao aprovar');
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (motivo: string) =>
|
||||
apiFetch(`/orders/${id}/reject`, { method: 'PATCH', body: { motivo } }),
|
||||
onSuccess: () => {
|
||||
setRejectOpen(false);
|
||||
void qc.invalidateQueries({ queryKey: ['orders', id] });
|
||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
setRejectOpen(false);
|
||||
setActionError(e instanceof Error ? e.message : 'Erro ao recusar');
|
||||
},
|
||||
});
|
||||
|
||||
const transmitMutation = useMutation({
|
||||
mutationFn: () => apiFetch(`/orders/${id}/transmit`, { method: 'PATCH' }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['orders', id] });
|
||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
// Mensagem de bloqueio de alçada (desconto acima do máximo) vem aqui.
|
||||
onError: (e: unknown) => setActionError(e instanceof Error ? e.message : 'Erro ao transmitir'),
|
||||
});
|
||||
|
||||
if (isLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
||||
if (error || !order)
|
||||
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
|
||||
|
||||
const timeWaiting =
|
||||
order.situa === 1
|
||||
? Math.floor((Date.now() - new Date(order.createdAt).getTime()) / 3_600_000)
|
||||
: null;
|
||||
|
||||
// Orçamento: tela mais larga para consulta/revisão com o cliente.
|
||||
const isOrcamento = order.situa === 0;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, maxWidth: isOrcamento ? 1320 : 960, margin: '0 auto' }}>
|
||||
<Space align="center" style={{ marginBottom: 16 }} wrap>
|
||||
<Link to="/pedidos">← Pedidos</Link>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
{order.numPedSar}
|
||||
</Title>
|
||||
<Badge
|
||||
status={
|
||||
(SITUA_COLOR[order.situa] ?? 'default') as
|
||||
| 'default'
|
||||
| 'warning'
|
||||
| 'processing'
|
||||
| 'success'
|
||||
| 'error'
|
||||
}
|
||||
text={
|
||||
<Tag color={SITUA_COLOR[order.situa] ?? 'default'}>
|
||||
{SITUA_LABEL[order.situa] ?? String(order.situa)}
|
||||
</Tag>
|
||||
}
|
||||
/>
|
||||
{timeWaiting !== null && timeWaiting > 2 && (
|
||||
<Tag color="red">Urgente — {timeWaiting}h aguardando</Tag>
|
||||
)}
|
||||
<Button
|
||||
icon={<FilePdfOutlined />}
|
||||
onClick={() => navigate({ to: '/pedidos/$id/imprimir', params: { id } })}
|
||||
>
|
||||
Gerar PDF
|
||||
</Button>
|
||||
{canTransmit && (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={transmitMutation.isPending}
|
||||
onClick={() => {
|
||||
setActionError(null);
|
||||
transmitMutation.mutate();
|
||||
}}
|
||||
style={{ backgroundColor: '#389e0d', borderColor: '#389e0d' }}
|
||||
>
|
||||
Transmitir pedido
|
||||
</Button>
|
||||
)}
|
||||
{canAct && (
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => setApproveOpen(true)}>
|
||||
Aprovar
|
||||
</Button>
|
||||
<Button danger onClick={() => setRejectOpen(true)}>
|
||||
Recusar
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
{canShare && (
|
||||
<Button
|
||||
icon={<FontAwesomeIcon icon={faShareNodes} />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.share({ text: buildShareText(order) });
|
||||
} catch {
|
||||
void message.info('Compartilhamento cancelado');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Compartilhar
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{actionError && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={actionError}
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setActionError(null)}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Descriptions
|
||||
bordered
|
||||
size={isOrcamento ? 'middle' : 'small'}
|
||||
column={isOrcamento ? 3 : 2}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<Descriptions.Item label="Cliente">
|
||||
<Link to="/clientes/$id" params={{ id: String(order.idCliente) }}>
|
||||
{order.razaoCliente ?? order.nomeCliente ?? `Cód. ${order.idCliente}`}
|
||||
</Link>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Representante">
|
||||
{order.nomeVendedor ?? `Cód. ${order.codVendedor}`}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Data">
|
||||
{new Date(order.dtPedido).toLocaleDateString('pt-BR')}
|
||||
</Descriptions.Item>
|
||||
{order.aprovadoEm && (
|
||||
<Descriptions.Item label="Aprovado em">
|
||||
{new Date(order.aprovadoEm).toLocaleString('pt-BR')} — cód. {order.aprovadoPor}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="Total produtos">{fmt(order.totalProdutos)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Desc. Global">{order.descontoPerc}%</Descriptions.Item>
|
||||
<Descriptions.Item label="Total">
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{fmt(order.total)}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
{order.obs && (
|
||||
<Descriptions.Item label="Observações" span={2}>
|
||||
{order.obs}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{order.motivoRecusa && (
|
||||
<Descriptions.Item label="Motivo Recusa" span={2}>
|
||||
<Text type="danger">{order.motivoRecusa}</Text>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
<Divider orientation="left">Itens ({order.itens.length})</Divider>
|
||||
<Table<PedidoItem>
|
||||
rowKey="id"
|
||||
columns={itemColumns}
|
||||
dataSource={order.itens}
|
||||
pagination={false}
|
||||
size={isOrcamento ? 'middle' : 'small'}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
{clientOrders && clientOrders.length > 0 && (
|
||||
<>
|
||||
<Divider orientation="left">Outros Pedidos do Cliente</Divider>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={clientOrders.filter((o) => o.id !== id).slice(0, 5)}
|
||||
columns={[
|
||||
{
|
||||
title: 'Nº',
|
||||
dataIndex: 'numPedSar',
|
||||
width: 110,
|
||||
render: (n: string, r: { id: string }) => (
|
||||
<Link to="/pedidos/$id" params={{ id: r.id }}>
|
||||
{n}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'situa',
|
||||
width: 130,
|
||||
render: (s: number) => (
|
||||
<Tag color={SITUA_COLOR[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Total',
|
||||
dataIndex: 'total',
|
||||
align: 'right' as const,
|
||||
render: (v: string) => fmt(v),
|
||||
},
|
||||
{
|
||||
title: 'Data',
|
||||
dataIndex: 'dtPedido',
|
||||
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||
},
|
||||
]}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider orientation="left">Histórico do Pedido</Divider>
|
||||
<HistoryTimeline history={order.historico} />
|
||||
|
||||
<ApproveModal
|
||||
open={approveOpen}
|
||||
originalDiscount={order.descontoPerc}
|
||||
onConfirm={(descontoPerc, nota) => approveMutation.mutate({ descontoPerc, nota })}
|
||||
onCancel={() => setApproveOpen(false)}
|
||||
loading={approveMutation.isPending}
|
||||
/>
|
||||
<RejectModal
|
||||
open={rejectOpen}
|
||||
onConfirm={(motivo) => rejectMutation.mutate(motivo)}
|
||||
onCancel={() => setRejectOpen(false)}
|
||||
loading={rejectMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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
450
apps/web/src/cockpits/rep/RepPainel.tsx
Normal file
450
apps/web/src/cockpits/rep/RepPainel.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import { Card, Col, Flex, Progress, Row, Skeleton, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faArrowTrendUp,
|
||||
faBullseye,
|
||||
faCircleExclamation,
|
||||
faClipboardList,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import type { MetaItem, PedidoSummary } from '@sar/api-interface';
|
||||
import { SITUA_LABEL } from '@sar/api-interface';
|
||||
import { useRepDashboard } from '../../lib/queries/dashboard';
|
||||
import { useCurrentUser } from '../../lib/queries/auth';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const SITUA_COLOR: Record<number, string> = {
|
||||
1: 'warning',
|
||||
2: 'processing',
|
||||
3: 'error',
|
||||
4: 'success',
|
||||
};
|
||||
|
||||
function fmt(v: number): string {
|
||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
function greeting(): string {
|
||||
const h = new Date().getHours();
|
||||
if (h < 12) return 'Bom dia';
|
||||
if (h < 18) return 'Boa tarde';
|
||||
return 'Boa noite';
|
||||
}
|
||||
|
||||
function today(): string {
|
||||
return new Date().toLocaleDateString('pt-BR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
}
|
||||
|
||||
function num(v: number, dec = 0): string {
|
||||
return v.toLocaleString('pt-BR', { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
||||
}
|
||||
|
||||
// Célula "realizado / meta" — realizado em destaque (verde se bateu), meta abaixo.
|
||||
function MetaCell({
|
||||
real,
|
||||
meta,
|
||||
money,
|
||||
dec = 0,
|
||||
}: {
|
||||
real: number;
|
||||
meta: number;
|
||||
money?: boolean;
|
||||
dec?: number;
|
||||
}) {
|
||||
const f = (v: number) => (money ? fmt(v) : num(v, dec));
|
||||
const ok = meta > 0 && real >= meta;
|
||||
return (
|
||||
<Space orientation="vertical" size={0} style={{ lineHeight: 1.15 }}>
|
||||
<Text strong className="tabular-nums" style={{ color: ok ? 'var(--green)' : undefined }}>
|
||||
{f(real)}
|
||||
</Text>
|
||||
<Text type="secondary" className="tabular-nums" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
/ {f(meta)}
|
||||
</Text>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const metaColumns: TableColumnsType<MetaItem> = [
|
||||
{
|
||||
title: 'Grupo',
|
||||
dataIndex: 'rotulo',
|
||||
key: 'rotulo',
|
||||
fixed: 'left',
|
||||
width: 180,
|
||||
render: (v: string) => <Text strong>{v}</Text>,
|
||||
},
|
||||
{
|
||||
title: 'Pedidos',
|
||||
dataIndex: 'pedidos',
|
||||
key: 'pedidos',
|
||||
align: 'right',
|
||||
width: 80,
|
||||
render: (v: number) => <span className="tabular-nums">{num(v)}</span>,
|
||||
},
|
||||
{
|
||||
title: 'Qtde',
|
||||
key: 'qtd',
|
||||
align: 'right',
|
||||
width: 110,
|
||||
render: (_: unknown, r: MetaItem) => <MetaCell real={r.qtdReal} meta={r.qtdMeta} />,
|
||||
},
|
||||
{
|
||||
title: 'Peso (kg)',
|
||||
key: 'peso',
|
||||
align: 'right',
|
||||
width: 120,
|
||||
render: (_: unknown, r: MetaItem) => <MetaCell real={r.pesoReal} meta={r.pesoMeta} />,
|
||||
},
|
||||
{
|
||||
title: 'Valor',
|
||||
key: 'valor',
|
||||
align: 'right',
|
||||
width: 160,
|
||||
render: (_: unknown, r: MetaItem) => <MetaCell real={r.valorReal} meta={r.valorMeta} money />,
|
||||
},
|
||||
{
|
||||
title: 'Fator (R$/kg)',
|
||||
key: 'fator',
|
||||
align: 'right',
|
||||
width: 110,
|
||||
render: (_: unknown, r: MetaItem) => <MetaCell real={r.fatorReal} meta={r.fatorMeta} dec={2} />,
|
||||
},
|
||||
{
|
||||
title: '% da meta (valor)',
|
||||
key: 'pct',
|
||||
align: 'center',
|
||||
width: 160,
|
||||
render: (_: unknown, r: MetaItem) => (
|
||||
<Progress
|
||||
percent={Math.min(r.pct, 100)}
|
||||
size="small"
|
||||
format={() => `${r.pct}%`}
|
||||
strokeColor={r.pct >= 100 ? 'var(--green)' : 'var(--jcs-blue)'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function RepPainel() {
|
||||
const { data, isLoading } = useRepDashboard();
|
||||
const { data: user } = useCurrentUser();
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||
<Skeleton active paragraph={{ rows: 2 }} />
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Skeleton active />
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Skeleton active />
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Skeleton active />
|
||||
</Col>
|
||||
</Row>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const { meta, metasPorGrupo, comissao, pedidosMes, pedidosRecentes, clientesInativos, syncedAt } =
|
||||
data;
|
||||
|
||||
return (
|
||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||
{/* Saudação */}
|
||||
<Flex vertical gap={4}>
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
{greeting()}, {user?.nome?.split(' ')[0] ?? '...'}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
{today()}
|
||||
{clientesInativos.length > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<span style={{ color: 'var(--orange)' }}>
|
||||
{clientesInativos.length} clientes inativos
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* Linha 1 — Meta + KPIs */}
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Card style={{ height: '100%' }}>
|
||||
<Flex vertical gap={16}>
|
||||
<Flex justify="space-between" align="flex-start">
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
META DO MÊS
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(meta.atingido)}
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
de <span className="tabular-nums">{fmt(meta.total)}</span>
|
||||
</Text>
|
||||
</Space>
|
||||
<Tag
|
||||
color={meta.pct >= 100 ? 'success' : meta.pct >= 75 ? 'processing' : 'default'}
|
||||
>
|
||||
{meta.pct}% atingido
|
||||
</Tag>
|
||||
</Flex>
|
||||
<Progress
|
||||
percent={Math.min(meta.pct, 100)}
|
||||
showInfo={false}
|
||||
strokeColor="var(--jcs-blue)"
|
||||
trailColor="var(--jcs-blue-light)"
|
||||
/>
|
||||
{meta.falta > 0 ? (
|
||||
<Text style={{ fontSize: 'var(--text-md)' }}>
|
||||
Faltam <strong className="tabular-nums">{fmt(meta.falta)}</strong> pra fechar o
|
||||
mês.
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={{ fontSize: 'var(--text-md)', color: 'var(--green)' }}>
|
||||
Meta batida! Comissão FLEX ativa.
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={12} md={6}>
|
||||
<Card>
|
||||
<Space orientation="vertical" size={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
PEDIDOS NO MÊS
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{pedidosMes}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
<FontAwesomeIcon icon={faArrowTrendUp} /> últimos 30 dias
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={12} md={6}>
|
||||
<Card>
|
||||
<Space orientation="vertical" size={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
COMISSÃO ACUMULADA
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(comissao.total)}
|
||||
</Title>
|
||||
{comissao.flex > 0 && (
|
||||
<Text type="success" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
FLEX: {fmt(comissao.flex)}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Metas por Grupo — acompanhamento multi-medida do mês */}
|
||||
{metasPorGrupo.length > 0 && (
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FontAwesomeIcon icon={faBullseye} style={{ color: 'var(--jcs-blue)' }} />
|
||||
Metas por Grupo
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Tag color={meta.pct >= 100 ? 'success' : meta.pct >= 75 ? 'processing' : 'default'}>
|
||||
{meta.pct}% no valor total
|
||||
</Tag>
|
||||
}
|
||||
>
|
||||
<Table<MetaItem>
|
||||
rowKey={(r) => String(r.codigo)}
|
||||
columns={metaColumns}
|
||||
dataSource={metasPorGrupo}
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ x: 820 }}
|
||||
summary={(rows) => {
|
||||
const t = rows.reduce(
|
||||
(a, r) => ({
|
||||
pedidos: a.pedidos + r.pedidos,
|
||||
qtdReal: a.qtdReal + r.qtdReal,
|
||||
qtdMeta: a.qtdMeta + r.qtdMeta,
|
||||
pesoReal: a.pesoReal + r.pesoReal,
|
||||
pesoMeta: a.pesoMeta + r.pesoMeta,
|
||||
valorReal: a.valorReal + r.valorReal,
|
||||
valorMeta: a.valorMeta + r.valorMeta,
|
||||
}),
|
||||
{
|
||||
pedidos: 0,
|
||||
qtdReal: 0,
|
||||
qtdMeta: 0,
|
||||
pesoReal: 0,
|
||||
pesoMeta: 0,
|
||||
valorReal: 0,
|
||||
valorMeta: 0,
|
||||
},
|
||||
);
|
||||
const pctTotal = t.valorMeta > 0 ? Math.round((t.valorReal / t.valorMeta) * 100) : 0;
|
||||
const fatorReal = t.pesoReal > 0 ? t.valorReal / t.pesoReal : 0;
|
||||
const fatorMeta = t.pesoMeta > 0 ? t.valorMeta / t.pesoMeta : 0;
|
||||
return (
|
||||
<Table.Summary.Row style={{ background: 'var(--bg-surface-alt)' }}>
|
||||
<Table.Summary.Cell index={0}>
|
||||
<Text strong>Total</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} align="right">
|
||||
<span className="tabular-nums">{num(t.pedidos)}</span>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2} align="right">
|
||||
<MetaCell real={t.qtdReal} meta={t.qtdMeta} />
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={3} align="right">
|
||||
<MetaCell real={t.pesoReal} meta={t.pesoMeta} />
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={4} align="right">
|
||||
<MetaCell real={t.valorReal} meta={t.valorMeta} money />
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={5} align="right">
|
||||
<MetaCell real={fatorReal} meta={fatorMeta} dec={2} />
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={6} align="center">
|
||||
<Text strong>{pctTotal}%</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Linha 2 — Clientes inativos + Pedidos recentes */}
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FontAwesomeIcon icon={faCircleExclamation} style={{ color: 'var(--orange)' }} />
|
||||
Clientes esfriando
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
clientesInativos.length > 0 ? (
|
||||
<Text type="secondary">{clientesInativos.length} clientes</Text>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{clientesInativos.length === 0 ? (
|
||||
<Text type="secondary">Nenhum cliente inativo. Ótimo trabalho!</Text>
|
||||
) : (
|
||||
<Flex vertical gap={12}>
|
||||
{clientesInativos.map((c) => (
|
||||
<Flex
|
||||
key={c.idCliente}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{
|
||||
padding: 'var(--space-sm) var(--space-md)',
|
||||
borderRadius: 12,
|
||||
background: c.diasSemCompra > 60 ? '#fff7e6' : 'var(--bg-surface-alt)',
|
||||
}}
|
||||
>
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
|
||||
<Text strong>{c.nome}</Text>
|
||||
</Link>
|
||||
{c.ultimaCompraValor && (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
Última compra:{' '}
|
||||
<span className="tabular-nums">
|
||||
{Number(c.ultimaCompraValor).toLocaleString('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL',
|
||||
})}
|
||||
</span>
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
<Tag
|
||||
color={c.diasSemCompra > 60 ? 'orange' : 'default'}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{c.diasSemCompra >= 999 ? 'nunca comprou' : `${c.diasSemCompra}d`}
|
||||
</Tag>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} />
|
||||
Pedidos recentes
|
||||
</Space>
|
||||
}
|
||||
extra={<Link to="/pedidos">Ver todos</Link>}
|
||||
>
|
||||
{pedidosRecentes.length === 0 ? (
|
||||
<Text type="secondary">Nenhum pedido nos últimos 7 dias.</Text>
|
||||
) : (
|
||||
<Flex vertical gap={10}>
|
||||
{pedidosRecentes.map((o: PedidoSummary) => (
|
||||
<Flex key={o.id} justify="space-between" align="center">
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Link to="/pedidos/$id" params={{ id: o.id }}>
|
||||
<Text strong className="tabular-nums">
|
||||
{o.numPedSar}
|
||||
</Text>
|
||||
</Link>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{o.razaoCliente ?? o.nomeCliente ?? `Cód. cliente ${o.idCliente}`}
|
||||
</Text>
|
||||
</Space>
|
||||
<Flex gap={8} align="center">
|
||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
{Number(o.total).toLocaleString('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL',
|
||||
})}
|
||||
</Text>
|
||||
<Tag color={SITUA_COLOR[o.situa] ?? 'default'}>
|
||||
{SITUA_LABEL[o.situa] ?? String(o.situa)}
|
||||
</Tag>
|
||||
</Flex>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Flex justify="space-between" style={{ paddingTop: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
SAR · Força de Vendas · Powered by JCS Sistemas
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
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 { Flex } from 'antd';
|
||||
import { Alert, Button, Flex, Tooltip } from 'antd';
|
||||
import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { Topbar } from './Topbar';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
|
||||
import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode;
|
||||
@@ -13,11 +17,23 @@ interface AppShellProps {
|
||||
* Variante mobile (Rafael) com bottom nav virá em ShellMobile separado.
|
||||
*/
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [, setSidebarOpen] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const isOnline = useNetworkStatus();
|
||||
useOfflineSync();
|
||||
|
||||
return (
|
||||
<Flex vertical style={{ minHeight: '100vh', background: 'var(--bg-body)' }}>
|
||||
{!isOnline && (
|
||||
<Alert
|
||||
type="warning"
|
||||
icon={<WifiOutlined />}
|
||||
showIcon
|
||||
banner
|
||||
message="Sem conexão — pedidos lançados ficam salvos e serão enviados ao reconectar"
|
||||
style={{ padding: '6px 16px', fontSize: 13 }}
|
||||
/>
|
||||
)}
|
||||
<Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} />
|
||||
<Flex flex={1}>
|
||||
<Sidebar />
|
||||
@@ -32,6 +48,31 @@ export function AppShell({ children }: AppShellProps) {
|
||||
{children}
|
||||
</main>
|
||||
</Flex>
|
||||
|
||||
{/* FAB — Novo Pedido */}
|
||||
<Tooltip title="Novo Pedido" placement="left">
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => void navigate({ to: '/pedidos/novo' })}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 32,
|
||||
right: 32,
|
||||
width: 52,
|
||||
height: 52,
|
||||
fontSize: 22,
|
||||
backgroundColor: '#389e0d',
|
||||
borderColor: '#389e0d',
|
||||
boxShadow: '0 4px 16px rgba(56,158,13,0.45)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const items: ItemType[] = [
|
||||
{
|
||||
key: '/',
|
||||
@@ -39,7 +38,7 @@ export function Sidebar() {
|
||||
label: 'Clientes',
|
||||
},
|
||||
{
|
||||
key: '/produtos',
|
||||
key: '/catalogo',
|
||||
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
|
||||
label: 'Catálogo',
|
||||
},
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { Avatar, Badge, Button, Flex, Input } from 'antd';
|
||||
import { Avatar, Badge, Button, Dropdown, Flex, Input, Typography } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faBell, faMagnifyingGlass, faBars } from '@fortawesome/free-solid-svg-icons';
|
||||
import {
|
||||
faBell,
|
||||
faMagnifyingGlass,
|
||||
faBars,
|
||||
faRightFromBracket,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { brandTokens } from '../../lib/theme';
|
||||
import { FoundationStatus } from './FoundationStatus';
|
||||
import { usePendingCount } from '../../lib/queries/notifications';
|
||||
import { useCurrentUser } from '../../lib/queries/auth';
|
||||
import { authStore } from '../../lib/auth-store';
|
||||
|
||||
interface TopbarProps {
|
||||
onToggleSidebar?: () => void;
|
||||
@@ -12,7 +23,53 @@ interface TopbarProps {
|
||||
* Apple-inspired clean: logo à esquerda, search central, notif + perfil à direita.
|
||||
* Variantes por cockpit: search desabilitada para Rafael mobile (vai pro bottom nav).
|
||||
*/
|
||||
function logout() {
|
||||
authStore.clear();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
const navigate = useNavigate();
|
||||
const { data: pendingData } = usePendingCount();
|
||||
const pendingCount = pendingData?.count ?? 0;
|
||||
const { data: user } = useCurrentUser();
|
||||
const initials = user?.nome
|
||||
? user.nome
|
||||
.split(' ')
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
: '?';
|
||||
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: 'profile',
|
||||
label: (
|
||||
<Flex vertical gap={2} style={{ padding: '4px 0', minWidth: 180 }}>
|
||||
<Typography.Text strong style={{ fontSize: 'var(--text-sm)' }}>
|
||||
{user?.nome?.trim() ?? '—'}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 'var(--text-xs)', textTransform: 'capitalize' }}
|
||||
>
|
||||
{user?.role ?? ''}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
disabled: true,
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'logout',
|
||||
danger: true,
|
||||
icon: <FontAwesomeIcon icon={faRightFromBracket} />,
|
||||
label: 'Sair',
|
||||
onClick: logout,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
@@ -37,11 +94,7 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
style={{ display: 'inline-flex' }}
|
||||
/>
|
||||
<Flex align="center" gap={12}>
|
||||
<img
|
||||
src="/sar-icon.png"
|
||||
alt="SAR"
|
||||
style={{ height: 40, width: 'auto' }}
|
||||
/>
|
||||
<img src="/sar-icon.png" alt="SAR" style={{ height: 40, width: 'auto' }} />
|
||||
<Flex vertical gap={0}>
|
||||
<span
|
||||
style={{
|
||||
@@ -68,25 +121,31 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* Centro: search (Sandra/Daniel/Alice) */}
|
||||
{/* Centro: search (Supervisor/Admin) */}
|
||||
<Flex flex={1} justify="center" style={{ maxWidth: 480, margin: '0 var(--space-2xl)' }}>
|
||||
<Input
|
||||
size="large"
|
||||
placeholder="Buscar cliente, pedido, produto..."
|
||||
prefix={
|
||||
<FontAwesomeIcon
|
||||
icon={faMagnifyingGlass}
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
/>
|
||||
<FontAwesomeIcon icon={faMagnifyingGlass} style={{ color: 'var(--text-muted)' }} />
|
||||
}
|
||||
style={{ borderRadius: 12 }}
|
||||
aria-label="Buscar"
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{/* Lado direito: notificações + perfil */}
|
||||
{/* Lado direito: novo pedido + status fundação + notificações + perfil */}
|
||||
<Flex align="center" gap={16}>
|
||||
<Badge count={3} color={brandTokens.red} offset={[-4, 4]}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => void navigate({ to: '/pedidos/novo' })}
|
||||
style={{ borderRadius: 8, fontWeight: 'var(--font-weight-semibold)' }}
|
||||
>
|
||||
Novo Pedido
|
||||
</Button>
|
||||
<FoundationStatus />
|
||||
<Badge count={pendingCount} color={brandTokens.red} offset={[-4, 4]}>
|
||||
<Button
|
||||
type="text"
|
||||
size="large"
|
||||
@@ -94,17 +153,19 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
aria-label="Notificações"
|
||||
/>
|
||||
</Badge>
|
||||
<Avatar
|
||||
size={40}
|
||||
style={{
|
||||
background: 'var(--jcs-blue-light)',
|
||||
color: 'var(--jcs-blue)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
R
|
||||
</Avatar>
|
||||
<Dropdown menu={{ items: userMenuItems }} trigger={['click']} placement="bottomRight">
|
||||
<Avatar
|
||||
size={40}
|
||||
style={{
|
||||
background: 'var(--jcs-blue-light)',
|
||||
color: 'var(--jcs-blue)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</Avatar>
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
53
apps/web/src/lib/queries/orders.ts
Normal file
53
apps/web/src/lib/queries/orders.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
PedidoListResponseSchema,
|
||||
PedidoDetailSchema,
|
||||
type PedidoListQuery,
|
||||
type PedidoListResponse,
|
||||
type PedidoDetail,
|
||||
type PedidoSummary,
|
||||
} from '@sar/api-interface';
|
||||
import { apiFetch } from '../api-client';
|
||||
|
||||
export function useOrderList(params: Partial<PedidoListQuery> = {}) {
|
||||
const search = new URLSearchParams();
|
||||
if (params.idCliente) search.set('idCliente', String(params.idCliente));
|
||||
if (params.situa) search.set('situa', String(params.situa));
|
||||
if (params.numPedSar) search.set('numPedSar', params.numPedSar);
|
||||
if (params.from) search.set('from', params.from);
|
||||
if (params.to) search.set('to', params.to);
|
||||
if (params.page) search.set('page', String(params.page));
|
||||
if (params.limit) search.set('limit', String(params.limit));
|
||||
|
||||
const qs = search.toString();
|
||||
return useQuery<PedidoListResponse>({
|
||||
queryKey: ['orders', params],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch(`/orders${qs ? `?${qs}` : ''}`);
|
||||
return PedidoListResponseSchema.parse(res);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useOrderDetail(id: string | undefined) {
|
||||
return useQuery<PedidoDetail>({
|
||||
queryKey: ['orders', id],
|
||||
enabled: !!id,
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch(`/orders/${id}`);
|
||||
return PedidoDetailSchema.parse(res);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useClientOrders(idCliente: number | undefined) {
|
||||
return useQuery<PedidoSummary[]>({
|
||||
queryKey: ['clients', idCliente, 'orders'],
|
||||
enabled: idCliente != null,
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch(`/orders?idCliente=${idCliente}&limit=10`);
|
||||
const data = PedidoListResponseSchema.parse(res);
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
}
|
||||
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 { RafaelPainel } from '../cockpits/rafael/RafaelPainel';
|
||||
import { RepPainel } from '../cockpits/rep/RepPainel';
|
||||
import { ClientsPage } from '../cockpits/rep/ClientsPage';
|
||||
import { ClientDetailPage } from '../cockpits/rep/ClientDetailPage';
|
||||
import { OrdersPage } from '../cockpits/rep/OrdersPage';
|
||||
import { OrderDetailPage } from '../cockpits/rep/OrderDetailPage';
|
||||
import { OrderPrintPage } from '../cockpits/rep/OrderPrintPage';
|
||||
import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
|
||||
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
||||
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
||||
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
|
||||
import { authStore } from './auth-store';
|
||||
|
||||
function getRoleFromToken(): string {
|
||||
const token = authStore.get();
|
||||
if (!token) return 'rep';
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1] ?? ''));
|
||||
return (payload.role as string) ?? 'rep';
|
||||
} catch {
|
||||
return 'rep';
|
||||
}
|
||||
}
|
||||
|
||||
function HomeRoute() {
|
||||
const role = getRoleFromToken();
|
||||
return role === 'supervisor' || role === 'manager' ? <SupervisorPainel /> : <RepPainel />;
|
||||
}
|
||||
|
||||
function NotFoundPage() {
|
||||
return (
|
||||
<div style={{ padding: 48, textAlign: 'center' }}>
|
||||
<Typography.Title level={3} type="secondary">
|
||||
Página não encontrada
|
||||
</Typography.Title>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Suprime aviso de notFound não utilizado — usado via throw notFound() em loaders futuros.
|
||||
void notFound;
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
@@ -8,22 +54,81 @@ const rootRoute = createRootRoute({
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
),
|
||||
notFoundComponent: NotFoundPage,
|
||||
});
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: RafaelPainel,
|
||||
component: HomeRoute,
|
||||
});
|
||||
|
||||
// Placeholder routes (cockpits a implementar)
|
||||
const rafaelRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/rep',
|
||||
component: RafaelPainel,
|
||||
component: RepPainel,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute, rafaelRoute]);
|
||||
const clientesRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/clientes',
|
||||
component: ClientsPage,
|
||||
});
|
||||
|
||||
const clienteDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/clientes/$id',
|
||||
component: ClientDetailPage,
|
||||
});
|
||||
|
||||
const pedidosRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/pedidos',
|
||||
component: OrdersPage,
|
||||
});
|
||||
|
||||
const novoOrderRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/pedidos/novo',
|
||||
component: NewOrderPage,
|
||||
});
|
||||
|
||||
const pedidoDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/pedidos/$id',
|
||||
component: OrderDetailPage,
|
||||
});
|
||||
|
||||
const pedidoPrintRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/pedidos/$id/imprimir',
|
||||
component: OrderPrintPage,
|
||||
});
|
||||
|
||||
const catalogoRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/catalogo',
|
||||
component: CatalogPage,
|
||||
});
|
||||
|
||||
const aprovacoes = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/aprovacoes',
|
||||
component: ApprovalQueuePage,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
rafaelRoute,
|
||||
clientesRoute,
|
||||
clienteDetailRoute,
|
||||
pedidosRoute,
|
||||
novoOrderRoute,
|
||||
pedidoDetailRoute,
|
||||
pedidoPrintRoute,
|
||||
catalogoRoute,
|
||||
aprovacoes,
|
||||
]);
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { StrictMode, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { ConfigProvider, App as AntdApp } from 'antd';
|
||||
import ptBR from 'antd/locale/pt_BR';
|
||||
@@ -12,9 +12,30 @@ import './styles/global.css';
|
||||
import { sarTheme } from './lib/theme';
|
||||
import { queryClient } from './lib/query-client';
|
||||
import { router } from './lib/router';
|
||||
import { authStore } from './lib/auth-store';
|
||||
import { DevLogin } from './components/dev/DevLogin';
|
||||
|
||||
dayjs.locale('pt-br');
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
// SW é opt-in — falha silenciosa não impede o app
|
||||
});
|
||||
}
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
function Root() {
|
||||
const [hasToken, setHasToken] = useState(() => !!authStore.get());
|
||||
|
||||
// Em dev, exibe DevLogin se não houver token. Em prod, fluxo de auth real virá aqui.
|
||||
if (isDev && !hasToken) {
|
||||
return <DevLogin onLogin={() => setHasToken(true)} />;
|
||||
}
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Root element not found');
|
||||
@@ -25,7 +46,7 @@ createRoot(rootEl).render(
|
||||
<ConfigProvider theme={sarTheme} locale={ptBR} componentSize="middle">
|
||||
<AntdApp>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<Root />
|
||||
</QueryClientProvider>
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
|
||||
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: {
|
||||
port: 4200,
|
||||
host: 'localhost',
|
||||
// Proxy /api/* → API Nest em :3000 (default API_PORT).
|
||||
// Evita CORS em dev e mantém URL relativa no código da Web — em produção,
|
||||
// mesmo origin via Nginx (/api/* → backend).
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
port: 4200,
|
||||
|
||||
16
commitlint.config.js
Normal file
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).
|
||||
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
|
||||
|
||||
@@ -21,10 +21,33 @@ export default [
|
||||
enforceBuildableLibDependency: true,
|
||||
allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$'],
|
||||
depConstraints: [
|
||||
// ── scope ────────────────────────────────────────────────────────
|
||||
// api só usa libs api ou shared; web só usa libs web ou shared
|
||||
{ sourceTag: 'scope:api', onlyDependOnLibsWithTags: ['scope:api', 'scope:shared'] },
|
||||
{ sourceTag: 'scope:web', onlyDependOnLibsWithTags: ['scope:web', 'scope:shared'] },
|
||||
// shared não pode importar código de app-scope
|
||||
{ sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
|
||||
|
||||
// ── type ─────────────────────────────────────────────────────────
|
||||
// apps só dependem de libs (feature/util/data), nunca de outro app
|
||||
{
|
||||
sourceTag: '*',
|
||||
onlyDependOnLibsWithTags: ['*'],
|
||||
sourceTag: 'type:app',
|
||||
onlyDependOnLibsWithTags: ['type:feature', 'type:util', 'type:data'],
|
||||
},
|
||||
// e2e depende do seu app-par e de utils; nunca de outro app
|
||||
{
|
||||
sourceTag: 'type:e2e',
|
||||
onlyDependOnLibsWithTags: ['type:app', 'type:util'],
|
||||
},
|
||||
// features dependem de features, utils e dados — não de apps
|
||||
{
|
||||
sourceTag: 'type:feature',
|
||||
onlyDependOnLibsWithTags: ['type:feature', 'type:util', 'type:data'],
|
||||
},
|
||||
// utils são folha — não importam features nem apps
|
||||
{ sourceTag: 'type:util', onlyDependOnLibsWithTags: ['type:util', 'type:data'] },
|
||||
// data é camada mais baixa — só pode depender de outra camada data
|
||||
{ sourceTag: 'type:data', onlyDependOnLibsWithTags: ['type:data'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
export * from './lib/ping.contract';
|
||||
export * from './lib/auth.contract';
|
||||
export * from './lib/client.contract';
|
||||
export * from './lib/order.contract';
|
||||
export * from './lib/product.contract';
|
||||
export * from './lib/dashboard.contract';
|
||||
export * from './lib/notifications.contract';
|
||||
export * from './lib/company.contract';
|
||||
|
||||
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